Cambridge Dictionary Helper

Select text and press a configurable hotkey to show Cambridge Dictionary results with pronunciation + audio. Popup UI is style-isolated using Shadow DOM. Audio playback is CSP-safe by using blob URLs only. TrustedTypes-safe: no innerHTML. Modal key shield: blocks page/browser shortcuts while popup is open.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Cambridge Dictionary Helper
// @namespace    yaser.cambridge.popup.dictionary
// @version      1.7.4
// @description  Select text and press a configurable hotkey to show Cambridge Dictionary results with pronunciation + audio. Popup UI is style-isolated using Shadow DOM. Audio playback is CSP-safe by using blob URLs only. TrustedTypes-safe: no innerHTML. Modal key shield: blocks page/browser shortcuts while popup is open.
// @match        *://*/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @connect      dictionary.cambridge.org
// @license      MIT
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  const DEFAULT_HOTKEY = {
    altKey: true,
    ctrlKey: false,
    shiftKey: false,
    metaKey: false,
    code: 'KeyD',
  };

  const UI = {
    hostId: 'cambridge-popup-dict-host',
    backdropId: 'cambridge-popup-dict-backdrop',
    storageKeyPos: 'cambridge_popup_dict_position_v1',
    storageKeyHotkey: 'cambridge_popup_dict_hotkey_v1',
    maxItems: 8,
    signature: 'cpd-shadow-ui-v4-tt-safe-modal',
  };

  let ACTIVE_HOTKEY = loadHotkey();
  let isCapturingHotkey = false;
  let hotkeyCaptureListener = null;
  let isShortcutView = false;

  let currentAudio = {
    uk: null,
    us: null,
  };

  let lastViewState = {
    type: 'input',
    payload: {
      prefill: '',
    },
  };

  const audioBlobCache = new Map();
  let activeAudioEl = null;

  function clamp(value, min, max) {
    if (Number.isNaN(value)) {
      return min;
    }

    return Math.max(min, Math.min(max, value));
  }

  function stripTags(s) {
    return String(s || '').replace(/<[^>]*>/g, '');
  }

  function decodeEntities(s) {
    let out = String(s || '');

    const named = {
      '&amp;': '&',
      '&lt;': '<',
      '&gt;': '>',
      '&quot;': '"',
      '&#039;': "'",
      '&apos;': "'",
      '&nbsp;': ' ',
      '&hellip;': '...',
      '&ndash;': '-',
      '&mdash;': '-',
      '&middot;': '·',
    };

    for (const [k, v] of Object.entries(named)) {
      out = out.split(k).join(v);
    }

    out = out.replace(/&#(\d+);/g, (_, num) => {
      const code = Number(num);

      if (!Number.isFinite(code)) {
        return _;
      }

      return String.fromCodePoint(code);
    });

    out = out.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
      const code = parseInt(hex, 16);

      if (!Number.isFinite(code)) {
        return _;
      }

      return String.fromCodePoint(code);
    });

    return out;
  }

  function cleanText(s) {
    return decodeEntities(stripTags(s))
      .replace(/\s+/g, ' ')
      .trim();
  }

  function normalizeCambridgeUrl(url) {
    if (!url) {
      return '';
    }

    if (url.startsWith('http://') || url.startsWith('https://')) {
      return url;
    }

    if (url.startsWith('/')) {
      return `https://dictionary.cambridge.org${url}`;
    }

    return `https://dictionary.cambridge.org/${url}`;
  }

  function buildUrl(query) {
    const safe = encodeURIComponent(String(query || '').toLowerCase());

    return `https://dictionary.cambridge.org/dictionary/english/${safe}`;
  }

  function loadHotkey() {
    const stored = GM_getValue(
      UI.storageKeyHotkey,
      null,
    );

    if (!stored || typeof stored !== 'object') {
      return DEFAULT_HOTKEY;
    }

    return {
      altKey: Boolean(stored.altKey),
      ctrlKey: Boolean(stored.ctrlKey),
      shiftKey: Boolean(stored.shiftKey),
      metaKey: Boolean(stored.metaKey),
      code: String(stored.code || DEFAULT_HOTKEY.code),
    };
  }

  function saveHotkey(hotkey) {
    GM_setValue(
      UI.storageKeyHotkey,
      hotkey,
    );
  }

  function hotkeyToString(h) {
    const parts = [];

    if (h.ctrlKey) {
      parts.push('Ctrl');
    }

    if (h.altKey) {
      parts.push('Alt');
    }

    if (h.shiftKey) {
      parts.push('Shift');
    }

    if (h.metaKey) {
      parts.push('Meta');
    }

    let key = h.code || '';
    if (key.startsWith('Key')) {
      key = key.slice(3);
    } else if (key.startsWith('Digit')) {
      key = key.slice(5);
    }

    parts.push(key);

    return parts.join(' + ');
  }

  function isModifierEvent(e) {
    const modifierKeys = new Set([
      'Alt',
      'Shift',
      'Control',
      'Meta',
    ]);

    const modifierCodes = new Set([
      'AltLeft',
      'AltRight',
      'ShiftLeft',
      'ShiftRight',
      'ControlLeft',
      'ControlRight',
      'MetaLeft',
      'MetaRight',
    ]);

    if (modifierKeys.has(e.key)) {
      return true;
    }

    if (modifierCodes.has(e.code)) {
      return true;
    }

    return false;
  }

  function matchesHotkey(e) {
    return (
      e.code === ACTIVE_HOTKEY.code &&
      e.altKey === ACTIVE_HOTKEY.altKey &&
      e.ctrlKey === ACTIVE_HOTKEY.ctrlKey &&
      e.shiftKey === ACTIVE_HOTKEY.shiftKey &&
      e.metaKey === ACTIVE_HOTKEY.metaKey
    );
  }

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

    const tag = (target.tagName || '').toLowerCase();

    if (tag === 'input' || tag === 'textarea') {
      return true;
    }

    return Boolean(target.isContentEditable);
  }

  function fetchCambridgeHtml(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        headers: {
          Accept: 'text/html',
        },
        onload: (res) => {
          if (res.status >= 200 && res.status < 300) {
            resolve(res.responseText);

            return;
          }

          reject(new Error(`HTTP ${res.status}`));
        },
        onerror: () => reject(new Error('Network error')),
        ontimeout: () => reject(new Error('Timeout')),
        timeout: 15000,
      });
    });
  }

  function fetchMp3AsBlobUrl(mp3Url) {
    if (audioBlobCache.has(mp3Url)) {
      return Promise.resolve(audioBlobCache.get(mp3Url));
    }

    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url: mp3Url,
        responseType: 'arraybuffer',
        onload: (res) => {
          if (res.status < 200 || res.status >= 300) {
            reject(new Error(`Audio HTTP ${res.status}`));

            return;
          }

          try {
            const bytes = res.response;
            const blob = new Blob([bytes], { type: 'audio/mpeg' });
            const blobUrl = URL.createObjectURL(blob);

            audioBlobCache.set(mp3Url, blobUrl);

            resolve(blobUrl);
          } catch (err) {
            reject(err);
          }
        },
        onerror: () => reject(new Error('Audio network error')),
        ontimeout: () => reject(new Error('Audio timeout')),
        timeout: 15000,
      });
    });
  }

  function matchFirst(html, re) {
    const m = re.exec(html);

    if (!m) {
      return '';
    }

    return cleanText(m[1] || '');
  }

  function matchFirstRaw(html, re) {
    const m = re.exec(html);

    if (!m) {
      return '';
    }

    return String(m[1] || '').trim();
  }

  function extractAudio(html, variant) {
    const re = variant === 'uk'
      ? /class="uk[^"]*dpron-i[\s\S]*?<source[^>]+type="audio\/mpeg"[^>]+src="([^"]+\.mp3[^"]*)"/i
      : /class="us[^"]*dpron-i[\s\S]*?<source[^>]+type="audio\/mpeg"[^>]+src="([^"]+\.mp3[^"]*)"/i;

    const raw = matchFirstRaw(html, re);

    return normalizeCambridgeUrl(raw);
  }

  function parseCambridge(html) {
    const notFound =
      /di-search-error/i.test(html) ||
      /did not match any words/i.test(html);

    const headword =
      matchFirst(html, /class="hw\s+dhw"[^>]*>([\s\S]*?)<\/span>/i) ||
      matchFirst(html, /class="hw"[^>]*>([\s\S]*?)<\/span>/i) ||
      '';

    const pos =
      matchFirst(html, /class="pos\s+dpos"[^>]*>([\s\S]*?)<\/span>/i) ||
      '';

    const ukPron = matchFirst(
      html,
      /class="uk[^"]*dpron-i[\s\S]*?class="pron\s+dpron"[^>]*>([\s\S]*?)<\/span>/i,
    );
    const usPron = matchFirst(
      html,
      /class="us[^"]*dpron-i[\s\S]*?class="pron\s+dpron"[^>]*>([\s\S]*?)<\/span>/i,
    );

    const ukAudio = extractAudio(html, 'uk');
    const usAudio = extractAudio(html, 'us');

    const senses = [];
    const defRe = /class="def\s+ddef_d[^"]*"[^>]*>([\s\S]*?)<\/span>/gi;

    let defMatch;
    while ((defMatch = defRe.exec(html)) !== null) {
      const defText = cleanText(defMatch[1]);
      if (!defText) {
        continue;
      }

      let exText = '';

      const start = defMatch.index;
      const windowHtml = html.slice(start, start + 2500);

      const ex = /class="eg\s+deg[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(windowHtml);
      if (ex && ex[1]) {
        exText = cleanText(ex[1]);
      }

      senses.push({
        def: defText,
        ex: exText,
      });

      if (senses.length >= UI.maxItems) {
        break;
      }
    }

    return {
      headword,
      pos,
      ukPron,
      usPron,
      ukAudio,
      usAudio,
      senses,
      notFound,
    };
  }

  function removeBrokenUI() {
    const existingHost = document.getElementById(UI.hostId);
    const existingBackdrop = document.getElementById(UI.backdropId);

    if (existingHost) {
      try {
        existingHost.remove();
      } catch (_) {
        // ignore
      }
    }

    if (existingBackdrop) {
      try {
        existingBackdrop.remove();
      } catch (_) {
        // ignore
      }
    }
  }

  function uiLooksValid(host) {
    if (!host) {
      return false;
    }

    if (host.getAttribute('data-cpd-signature') !== UI.signature) {
      return false;
    }

    const shadow = host.shadowRoot;
    if (!shadow) {
      return false;
    }

    const requiredRoles = [
      'word',
      'pos',
      'ukPron',
      'usPron',
      'playUk',
      'playUs',
      'shortcut',
      'open',
      'close',
      'searchInput',
      'searchSubmit',
      'body',
      'drag',
    ];

    for (const role of requiredRoles) {
      if (!shadow.querySelector(`[data-role="${role}"]`)) {
        return false;
      }
    }

    return true;
  }

  function el(tag, attrs = {}, text = null) {
    const node = document.createElement(tag);

    for (const [k, v] of Object.entries(attrs || {})) {
      if (k === 'style') {
        node.style.cssText = String(v);

        continue;
      }

      if (k === 'className') {
        node.className = String(v);

        continue;
      }

      if (k.startsWith('data-')) {
        node.setAttribute(k, String(v));

        continue;
      }

      if (k === 'type') {
        node.type = String(v);

        continue;
      }

      if (k === 'value') {
        node.value = String(v);

        continue;
      }

      if (k === 'spellcheck') {
        node.spellcheck = Boolean(v);

        continue;
      }

      if (k === 'autocomplete') {
        node.setAttribute('autocomplete', String(v));

        continue;
      }

      if (k === 'autocapitalize') {
        node.setAttribute('autocapitalize', String(v));

        continue;
      }

      if (k === 'title') {
        node.title = String(v);

        continue;
      }

      if (k === 'href') {
        node.setAttribute('href', String(v));

        continue;
      }

      if (k === 'rel') {
        node.setAttribute('rel', String(v));

        continue;
      }

      if (k === 'target') {
        node.setAttribute('target', String(v));

        continue;
      }

      node.setAttribute(k, String(v));
    }

    if (text !== null && text !== undefined) {
      node.textContent = String(text);
    }

    return node;
  }

  function clearChildren(node) {
    while (node.firstChild) {
      node.removeChild(node.firstChild);
    }
  }

  function isVisible() {
    const host = document.getElementById(UI.hostId);

    return Boolean(host && host.style.display === 'block');
  }

  function show() {
    const { host, backdrop } = ensureUI();
    backdrop.style.display = 'block';
    host.style.display = 'block';
  }

  function hide() {
    cancelHotkeyCapture();

    const host = document.getElementById(UI.hostId);
    const backdrop = document.getElementById(UI.backdropId);

    if (host) {
      host.style.display = 'none';
    }

    if (backdrop) {
      backdrop.style.display = 'none';
    }
  }

  function isEventInsidePopup(e) {
    const host = document.getElementById(UI.hostId);

    if (!host) {
      return false;
    }

    const path = typeof e.composedPath === "function" ? e.composedPath() : [];

    if (path.includes(host)) {
      return true;
    }

    return Boolean(e.target && host.contains(e.target));
  }

  function isPopupEditableTarget(target) {
    if (!target) {
      return false;
    }

    const tag = (target.tagName || '').toLowerCase();

    if (tag === 'input' || tag === 'textarea') {
      return true;
    }

    return Boolean(target.isContentEditable);
  }

  function bindShadowKeyShield(shadow) {
    if (!shadow || shadow.__cpdKeyShieldBound) {
      return;
    }

    shadow.__cpdKeyShieldBound = true;

    shadow.addEventListener(
      'keydown',
      (e) => {
        if (!isVisible()) {
          return;
        }

        if (e.key === 'Escape') {
          e.preventDefault();
          e.stopPropagation();

          hide();

          return;
        }

        // Stop keys from escaping the shadow root to the page/app.
        // Let editable elements behave normally (typing), but still stop bubbling to the page.
        e.stopPropagation();

        const editable = isPopupEditableTarget(e.target);

        // If focus is NOT in an input/textarea/contenteditable, prevent browser/page shortcuts.
        if (!editable) {
          e.preventDefault();
        }
      },
      false,
    );
  }

  function ensureUI() {
    let backdrop = document.getElementById(UI.backdropId);
    let host = document.getElementById(UI.hostId);

    if (host && !uiLooksValid(host)) {
      removeBrokenUI();
      backdrop = null;
      host = null;
    }

    if (!backdrop) {
      backdrop = document.createElement('div');
      backdrop.id = UI.backdropId;
      backdrop.style.cssText = [
        'position:fixed',
        'inset:0',
        'background:rgba(0,0,0,0.18)',
        'z-index:2147483646',
        'display:none',
      ].join(';');
      document.body.appendChild(backdrop);

      if (!backdrop.dataset.cpdBound) {
        backdrop.dataset.cpdBound = '1';
        backdrop.addEventListener('click', () => hide());
      }
    }

    if (!host) {
      host = document.createElement('div');
      host.id = UI.hostId;
      host.setAttribute('data-cpd-signature', UI.signature);

      host.style.cssText = [
        'all:initial',
        'position:fixed',
        'left:12px',
        'top:12px',
        'z-index:2147483647',
        'display:none',
      ].join(';');

      document.body.appendChild(host);

      const shadow = host.attachShadow({ mode: 'open' });

      const style = document.createElement('style');
      style.textContent = `
        :host { all: initial; }

        .root {
          width: min(640px, calc(100vw - 24px));
          max-height: min(78vh, 760px);
          background: #0b1220;
          color: #e8eefc;
          border: 1px solid rgba(255,255,255,0.12);
          border-radius: 14px;
          box-shadow: 0 18px 50px rgba(0,0,0,0.45);
          overflow: hidden;
          font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
          -webkit-font-smoothing: antialiased;
          text-rendering: optimizeLegibility;
        }

        .header {
          display: flex;
          flex-direction: column;
          gap: 10px;
          padding: 10px 12px;
          background: rgba(255,255,255,0.06);
          cursor: move;
          user-select: none;
        }

        .headerRow1 {
          display: flex;
          align-items: flex-start;
          justify-content: space-between;
          gap: 10px;
          min-width: 0;
        }

        .title {
          display: flex;
          flex-direction: column;
          gap: 2px;
          min-width: 0;
        }

        .title .word {
          font-size: 15px;
          font-weight: 750;
          letter-spacing: 0.2px;
          white-space: nowrap;
          overflow: hidden;
          text-overflow: ellipsis;
          max-width: 460px;
        }

        .title .meta {
          font-size: 12px;
          opacity: 0.92;
          display: flex;
          gap: 10px;
          flex-wrap: wrap;
          align-items: center;
        }

        .pill {
          padding: 2px 8px;
          border: 1px solid rgba(255,255,255,0.14);
          background: rgba(255,255,255,0.08);
          border-radius: 999px;
          font-size: 11px;
          opacity: 0.95;
          white-space: nowrap;
        }

        .actions {
          display: flex;
          gap: 8px;
          align-items: center;
          flex-shrink: 0;
          flex-wrap: wrap;
          justify-content: flex-end;
        }

        button { all: unset; }

        .btn {
          border: 1px solid rgba(255,255,255,0.18);
          background: rgba(255,255,255,0.10);
          border-radius: 10px;
          padding: 6px 10px;
          font-size: 12px;
          cursor: pointer;
          transition: transform 0.06s ease, background 0.12s ease;
          white-space: nowrap;
          color: #e8eefc;
          display: inline-flex;
          align-items: center;
          justify-content: center;
          user-select: none;
        }

        .btn:hover { background: rgba(255,255,255,0.14); }
        .btn:active { transform: translateY(1px); }
        .btn.icon { padding: 6px 9px; }
        .btn.closeIcon { padding: 6px 10px; font-size: 16px; line-height: 1; }

        .searchRow {
          display: flex;
          gap: 8px;
          width: 100%;
          cursor: default;
          user-select: text;
        }

        input { all: unset; }

        .searchInput {
          flex: 1;
          padding: 10px 12px;
          border-radius: 10px;
          border: 1px solid rgba(255,255,255,0.2);
          background: #0f172a;
          color: #f1f6ff;
          font-size: 14px;
          outline: none;
          cursor: text;
        }

        .searchInput::placeholder { color: rgba(232, 238, 252, 0.65); }

        .body {
          padding: 12px;
          overflow: auto;
          max-height: calc(min(78vh, 760px) - 118px);
        }

        .loading {
          display: flex;
          align-items: center;
          gap: 10px;
          font-size: 13px;
          opacity: 0.95;
        }

        .spinner {
          width: 18px;
          height: 18px;
          border-radius: 999px;
          border: 2px solid rgba(255,255,255,0.18);
          border-top-color: rgba(255,255,255,0.90);
          animation: spin 0.8s linear infinite;
        }

        @keyframes spin { to { transform: rotate(360deg); } }

        .error {
          padding: 10px 12px;
          border: 1px solid rgba(255, 99, 99, 0.32);
          background: rgba(255, 99, 99, 0.10);
          border-radius: 12px;
          font-size: 13px;
          line-height: 1.6;
        }

        .sense {
          padding: 12px 12px;
          border: 1px solid rgba(255,255,255,0.12);
          background: rgba(255,255,255,0.06);
          border-radius: 12px;
          margin-bottom: 10px;
        }

        .def {
          font-size: 14px;
          line-height: 1.65;
          margin: 0 0 8px 0;
          color: #f1f6ff;
        }

        .ex {
          font-size: 13px;
          line-height: 1.6;
          margin: 8px 0 0 0;
          padding-left: 10px;
          border-left: 2px solid rgba(255,255,255,0.14);
          opacity: 0.92;
          color: #d7e4ff;
        }

        .footer {
          margin-top: 10px;
          font-size: 12px;
          opacity: 0.9;
          display: flex;
          gap: 10px;
          flex-wrap: wrap;
          align-items: center;
        }

        a { color: #9cc2ff; text-decoration: none; }
        a:hover { text-decoration: underline; }
      `;

      const root = el('div', { className: 'root' });

      const header = el('div', { className: 'header', 'data-role': 'drag' });
      const headerRow1 = el('div', { className: 'headerRow1' });

      const title = el('div', { className: 'title' });
      const word = el('div', { className: 'word', 'data-role': 'word' }, 'Cambridge Dictionary');

      const meta = el('div', { className: 'meta' });
      const pos = el('span', { 'data-role': 'pos' }, '');
      const ukPron = el('span', { className: 'pill', 'data-role': 'ukPron', style: 'display:none;' }, '');
      const usPron = el('span', { className: 'pill', 'data-role': 'usPron', style: 'display:none;' }, '');

      meta.appendChild(pos);
      meta.appendChild(ukPron);
      meta.appendChild(usPron);

      title.appendChild(word);
      title.appendChild(meta);

      const actions = el('div', { className: 'actions' });

      const playUk = el(
        'button',
        {
          className: 'btn icon',
          type: 'button',
          'data-role': 'playUk',
          title: 'Play UK audio',
          style: 'display:none;',
        },
        '🔊 UK',
      );

      const playUs = el(
        'button',
        {
          className: 'btn icon',
          type: 'button',
          'data-role': 'playUs',
          title: 'Play US audio',
          style: 'display:none;',
        },
        '🔊 US',
      );

      const shortcut = el(
        'button',
        {
          className: 'btn',
          type: 'button',
          'data-role': 'shortcut',
        },
        'Shortcut',
      );

      const open = el(
        'button',
        {
          className: 'btn',
          type: 'button',
          'data-role': 'open',
        },
        'Open',
      );

      const close = el(
        'button',
        {
          className: 'btn closeIcon',
          type: 'button',
          'data-role': 'close',
          title: 'Close',
        },
        '✕',
      );

      actions.appendChild(playUk);
      actions.appendChild(playUs);
      actions.appendChild(shortcut);
      actions.appendChild(open);
      actions.appendChild(close);

      headerRow1.appendChild(title);
      headerRow1.appendChild(actions);

      const searchRow = el('div', { className: 'searchRow', 'data-role': 'searchRow' });

      const searchInput = el('input', {
        type: 'text',
        className: 'searchInput',
        'data-role': 'searchInput',
        placeholder: 'Type a word and press Enter...',
        value: '',
        autocomplete: 'off',
        autocapitalize: 'none',
        spellcheck: false,
      });

      const searchSubmit = el(
        'button',
        {
          className: 'btn',
          type: 'button',
          'data-role': 'searchSubmit',
          style: 'padding:10px 14px;',
        },
        'Search',
      );

      searchRow.appendChild(searchInput);
      searchRow.appendChild(searchSubmit);

      header.appendChild(headerRow1);
      header.appendChild(searchRow);

      const body = el('div', { className: 'body', 'data-role': 'body' });

      root.appendChild(header);
      root.appendChild(body);

      shadow.appendChild(style);
      shadow.appendChild(root);

      bindShadowKeyShield(shadow);
      bindUIEvents(host, root);

      setupDrag(host, root.querySelector('[data-role="drag"]'));
      restorePosition(host);
    }

    return {
      backdrop,
      host,
      shadow: host.shadowRoot,
    };
  }

  function bindUIEvents(host, root) {
    if (host.dataset.cpdBound === '1') {
      return;
    }

    host.dataset.cpdBound = '1';

    root.querySelector('[data-role="close"]').addEventListener('click', () => hide());

    root.querySelector('[data-role="open"]').addEventListener('click', () => {
      const url = host.getAttribute('data-url');
      if (url) {
        window.open(url, '_blank', 'noopener,noreferrer');
      }
    });

    root.querySelector('[data-role="shortcut"]').addEventListener('click', () => {
      if (isShortcutView) {
        showMainView();

        return;
      }

      showHotkeyConfig();
    });

    root.querySelector('[data-role="playUk"]').addEventListener('click', () => {
      playAudio('uk');
    });

    root.querySelector('[data-role="playUs"]').addEventListener('click', () => {
      playAudio('us');
    });

    const searchInput = root.querySelector('[data-role="searchInput"]');
    const searchSubmit = root.querySelector('[data-role="searchSubmit"]');

    const triggerSearch = () => {
      const value = searchInput.value.trim();
      if (!value) {
        searchInput.focus();

        return;
      }

      lookupWord(value);
    };

    searchInput.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') {
        e.preventDefault();
        triggerSearch();
      }
    });

    searchSubmit.addEventListener('click', () => {
      triggerSearch();
    });
  }

  function qs(role) {
    const { shadow, host } = ensureUI();
    const elx = shadow.querySelector(`[data-role="${role}"]`);

    if (elx) {
      return elx;
    }

    if (!uiLooksValid(host)) {
      removeBrokenUI();
      const rebuilt = ensureUI();

      return rebuilt.shadow.querySelector(`[data-role="${role}"]`);
    }

    return null;
  }

  function setHeader({ word, pos, url, ukPron, usPron, ukAudio, usAudio }) {
    const { host } = ensureUI();

    const wordEl = qs('word');
    const posEl = qs('pos');

    if (!wordEl || !posEl) {
      removeBrokenUI();
      ensureUI();

      return;
    }

    wordEl.textContent = word || 'Cambridge Dictionary';
    posEl.textContent = pos || '';

    host.setAttribute('data-url', url || '');
    host.setAttribute('data-current-word', word || '');

    currentAudio = {
      uk: ukAudio || null,
      us: usAudio || null,
    };

    const ukPronEl = qs('ukPron');
    const usPronEl = qs('usPron');

    if (ukPronEl) {
      if (ukPron) {
        ukPronEl.textContent = `UK /${ukPron}/`;
        ukPronEl.style.display = 'inline-flex';
      } else {
        ukPronEl.style.display = 'none';
      }
    }

    if (usPronEl) {
      if (usPron) {
        usPronEl.textContent = `US /${usPron}/`;
        usPronEl.style.display = 'inline-flex';
      } else {
        usPronEl.style.display = 'none';
      }
    }

    const playUk = qs('playUk');
    const playUs = qs('playUs');

    if (playUk) {
      playUk.style.display = currentAudio.uk ? 'inline-flex' : 'none';
    }

    if (playUs) {
      playUs.style.display = currentAudio.us ? 'inline-flex' : 'none';
    }
  }

  function setSearchValue(value) {
    const input = qs('searchInput');
    if (!input) {
      return;
    }

    input.value = value || '';
  }

  function setBodyContent(builder) {
    const body = qs('body');
    if (!body) {
      return;
    }

    clearChildren(body);

    if (typeof builder === 'function') {
      builder(body);
    }
  }

  function savePosition(host) {
    const rect = host.getBoundingClientRect();
    const payload = {
      left: Math.round(rect.left),
      top: Math.round(rect.top),
    };

    try {
      GM_setValue(
        UI.storageKeyPos,
        payload,
      );
    } catch (_) {
      // ignore
    }
  }

  function restorePosition(host) {
    try {
      const parsed = GM_getValue(
        UI.storageKeyPos,
        null,
      );

      if (!parsed) {
        return;
      }

      const left = clamp(
        parsed.left,
        8,
        window.innerWidth - host.offsetWidth - 8,
      );
      const top = clamp(
        parsed.top,
        8,
        window.innerHeight - host.offsetHeight - 8,
      );

      host.style.left = `${left}px`;
      host.style.top = `${top}px`;
    } catch (_) {
      // ignore
    }
  }

  function setupDrag(host, handle) {
    let dragging = false;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    const onMouseDown = (e) => {
      if (e.button !== 0) {
        return;
      }

      dragging = true;
      startX = e.clientX;
      startY = e.clientY;

      const rect = host.getBoundingClientRect();
      startLeft = rect.left;
      startTop = rect.top;

      document.addEventListener('mousemove', onMouseMove, true);
      document.addEventListener('mouseup', onMouseUp, true);

      e.preventDefault();
      e.stopPropagation();
    };

    const onMouseMove = (e) => {
      if (!dragging) {
        return;
      }

      const dx = e.clientX - startX;
      const dy = e.clientY - startY;

      const nextLeft = clamp(
        startLeft + dx,
        8,
        window.innerWidth - host.offsetWidth - 8,
      );
      const nextTop = clamp(
        startTop + dy,
        8,
        window.innerHeight - host.offsetHeight - 8,
      );

      host.style.left = `${nextLeft}px`;
      host.style.top = `${nextTop}px`;

      savePosition(host);
    };

    const onMouseUp = () => {
      dragging = false;

      document.removeEventListener('mousemove', onMouseMove, true);
      document.removeEventListener('mouseup', onMouseUp, true);
    };

    handle.addEventListener('mousedown', onMouseDown);
  }

  function cancelHotkeyCapture() {
    if (hotkeyCaptureListener) {
      document.removeEventListener('keydown', hotkeyCaptureListener, true);
      hotkeyCaptureListener = null;
    }

    isCapturingHotkey = false;
  }

  async function playAudio(which) {
    const src = which === 'uk' ? currentAudio.uk : currentAudio.us;

    if (!src) {
      return;
    }

    if (activeAudioEl) {
      try {
        activeAudioEl.pause();
      } catch (_) {
        // ignore
      }
    }

    const url = document.getElementById(UI.hostId)?.getAttribute('data-url') || '';
    const word = document.getElementById(UI.hostId)?.getAttribute('data-current-word') || 'Audio';

    try {
      const blobUrl = await fetchMp3AsBlobUrl(src);

      const audio = new Audio(blobUrl);
      activeAudioEl = audio;

      await audio.play();
    } catch (err) {
      setError(
        word,
        `Audio failed: ${err?.message || 'blocked or failed to fetch'}. Try "Open" on Cambridge.`,
        url,
      );
    }
  }

  function setLoading(query) {
    lastViewState = {
      type: 'loading',
      payload: {
        query,
      },
    };

    isShortcutView = false;
    cancelHotkeyCapture();

    setHeader({
      word: query,
      pos: '',
      url: '',
      ukPron: '',
      usPron: '',
      ukAudio: null,
      usAudio: null,
    });

    setSearchValue(query);

    setBodyContent((body) => {
      const loading = el('div', { className: 'loading' });
      loading.appendChild(el('span', { className: 'spinner' }));
      loading.appendChild(el('span', {}, 'Looking up Cambridge Dictionary...'));

      const footer = el('div', { className: 'footer' });
      footer.appendChild(el('span', {}, `Hotkey: ${hotkeyToString(ACTIVE_HOTKEY)}`));
      footer.appendChild(el('span', {}, 'Esc to close'));

      body.appendChild(loading);
      body.appendChild(footer);
    });
  }

  function setError(query, message, url) {
    lastViewState = {
      type: 'error',
      payload: {
        query,
        message,
        url,
      },
    };

    isShortcutView = false;
    cancelHotkeyCapture();

    setHeader({
      word: query,
      pos: '',
      url: url || '',
      ukPron: '',
      usPron: '',
      ukAudio: null,
      usAudio: null,
    });

    setSearchValue(query);

    setBodyContent((body) => {
      body.appendChild(el('div', { className: 'error' }, message));

      const footer = el('div', { className: 'footer' });

      if (url) {
        footer.appendChild(
          el(
            'a',
            {
              href: url,
              target: '_blank',
              rel: 'noopener noreferrer',
            },
            'Open on Cambridge',
          ),
        );
      }

      footer.appendChild(el('span', {}, 'Tip: click Open and play audio there'));

      body.appendChild(footer);
    });
  }

  function renderResult({ headword, pos, ukPron, usPron, ukAudio, usAudio, senses, url }) {
    lastViewState = {
      type: 'result',
      payload: {
        headword,
        pos,
        ukPron,
        usPron,
        ukAudio,
        usAudio,
        senses,
        url,
      },
    };

    isShortcutView = false;
    cancelHotkeyCapture();

    setHeader({
      word: headword || 'Result',
      pos,
      url,
      ukPron: ukPron || '',
      usPron: usPron || '',
      ukAudio: ukAudio || null,
      usAudio: usAudio || null,
    });

    setSearchValue(headword || '');

    if (!senses.length) {
      setBodyContent((body) => {
        const box = el('div', { className: 'error' }, 'I found the page, but couldn’t extract definitions.');
        const hint = el(
          'div',
          { style: 'margin-top:8px; opacity:0.9;' },
          'This usually means Cambridge served a consent/interstitial page or changed markup again.',
        );
        box.appendChild(hint);

        const footer = el('div', { className: 'footer' });
        footer.appendChild(el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Open on Cambridge'));
        footer.appendChild(el('span', {}, `Hotkey: ${hotkeyToString(ACTIVE_HOTKEY)}`));

        body.appendChild(box);
        body.appendChild(footer);
      });

      return;
    }

    setBodyContent((body) => {
      for (const s of senses) {
        const sense = el('div', { className: 'sense' });

        sense.appendChild(el('p', { className: 'def' }, s.def));

        if (s.ex) {
          sense.appendChild(el('p', { className: 'ex' }, s.ex));
        }

        body.appendChild(sense);
      }

      const footer = el('div', { className: 'footer' });
      footer.appendChild(el('a', { href: url, target: '_blank', rel: 'noopener noreferrer' }, 'Open on Cambridge'));
      footer.appendChild(el('span', {}, `Hotkey: ${hotkeyToString(ACTIVE_HOTKEY)}`));
      footer.appendChild(el('span', {}, 'Esc to close'));

      body.appendChild(footer);
    });
  }

  function showMainView() {
    isShortcutView = false;
    cancelHotkeyCapture();

    if (lastViewState.type === 'result') {
      renderResult(lastViewState.payload);

      return;
    }

    if (lastViewState.type === 'error') {
      setError(
        lastViewState.payload.query,
        lastViewState.payload.message,
        lastViewState.payload.url,
      );

      return;
    }

    setHeader({
      word: 'Cambridge Dictionary',
      pos: '',
      url: '',
      ukPron: '',
      usPron: '',
      ukAudio: null,
      usAudio: null,
    });

    setSearchValue(lastViewState.payload?.prefill || '');

    setBodyContent((body) => {
      const sense = el('div', { className: 'sense' });

      sense.appendChild(el('p', { className: 'def' }, 'Type a word in the search box above and press Enter.'));
      sense.appendChild(el('p', { className: 'ex' }, 'Tip: you can also select text on the page and use the hotkey.'));

      const footer = el('div', { className: 'footer' });
      footer.appendChild(el('span', {}, `Hotkey: ${hotkeyToString(ACTIVE_HOTKEY)}`));
      footer.appendChild(el('span', {}, 'Esc to close'));

      body.appendChild(sense);
      body.appendChild(footer);
    });
  }

  function showHotkeyConfig() {
    show();
    cancelHotkeyCapture();

    isShortcutView = true;
    isCapturingHotkey = true;

    setHeader({
      word: 'Shortcut settings',
      pos: '',
      url: '',
      ukPron: '',
      usPron: '',
      ukAudio: null,
      usAudio: null,
    });

    setBodyContent((body) => {
      const sense = el('div', { className: 'sense' });

      const p1 = el('p', { className: 'def', style: 'margin-bottom:6px;' }, 'Current shortcut');
      const current = el('div', { className: 'ex', style: 'border-left:none; padding-left:0; opacity:1;' }, hotkeyToString(ACTIVE_HOTKEY));

      const p2 = el('p', { className: 'def', style: 'margin-top:12px; margin-bottom:6px;' }, 'Set new shortcut');
      const info = el(
        'div',
        { className: 'ex', style: 'border-left:none; padding-left:0;' },
        'Hold modifiers (Ctrl/Alt/Shift/Meta) and press a key. Example: hold Alt, then press G.',
      );

      const footer = el('div', { className: 'footer', style: 'margin-top:12px;' });
      footer.appendChild(el('span', {}, 'Must include at least one modifier'));
      footer.appendChild(el('span', {}, 'Esc to cancel'));
      footer.appendChild(el('span', {}, 'Click “Shortcut” again to go back'));

      sense.appendChild(p1);
      sense.appendChild(current);
      sense.appendChild(p2);
      sense.appendChild(info);
      sense.appendChild(footer);

      body.appendChild(sense);
    });

    hotkeyCaptureListener = (e) => {
      e.preventDefault();
      e.stopPropagation();

      if (e.key === 'Escape') {
        cancelHotkeyCapture();
        showMainView();

        return;
      }

      if (isModifierEvent(e)) {
        return;
      }

      const hasModifier = Boolean(e.ctrlKey || e.altKey || e.shiftKey || e.metaKey);
      if (!hasModifier) {
        return;
      }

      ACTIVE_HOTKEY = {
        code: e.code,
        altKey: e.altKey,
        ctrlKey: e.ctrlKey,
        shiftKey: e.shiftKey,
        metaKey: e.metaKey,
      };

      saveHotkey(ACTIVE_HOTKEY);

      cancelHotkeyCapture();
      showMainView();
    };

    document.addEventListener('keydown', hotkeyCaptureListener, true);
  }

  async function lookupWord(query) {
    const normalized = String(query || '').trim();
    if (!normalized) {
      showMainView();

      return;
    }

    const url = buildUrl(normalized);

    show();
    setLoading(normalized);

    try {
      const html = await fetchCambridgeHtml(url);
      const parsed = parseCambridge(html);

      if (parsed.notFound) {
        setError(
          normalized,
          'No Cambridge entry found for this word.',
          url,
        );

        return;
      }

      renderResult({
        headword: parsed.headword || normalized,
        pos: parsed.pos,
        ukPron: parsed.ukPron,
        usPron: parsed.usPron,
        ukAudio: parsed.ukAudio,
        usAudio: parsed.usAudio,
        senses: parsed.senses,
        url,
      });
    } catch (err) {
      setError(
        normalized,
        `Lookup failed: ${err?.message || 'unknown error'}.`,
        url,
      );
    }
  }

  function getSelectionText() {
    const text = (window.getSelection?.().toString() || '').trim();

    if (!text) {
      return '';
    }

    return text
      .replace(/\s+/g, ' ')
      .replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, '')
      .trim();
  }

  function focusSearch() {
    try {
      const input = qs('searchInput');
      if (input) {
        input.focus();
      }
    } catch (_) {
      // ignore
    }
  }

  function wireSearchPrefillFromSelection(selected) {
    if (!selected) {
      return;
    }

    setSearchValue(selected);
    focusSearch();
  }

  // Modal key shield (document-capture):
  // - If popup is open and the key event originates OUTSIDE the popup, block it hard.
  // - If it originates INSIDE, let it reach the popup; the shadow root will stop it from reaching the page.
  document.addEventListener(
    'keydown',
    (e) => {
      if (!isVisible()) {
        return;
      }

      if (e.key === 'Escape') {
        e.preventDefault();
        e.stopPropagation();
        e.stopImmediatePropagation();

        hide();

        return;
      }

      if (isEventInsidePopup(e)) {
        return;
      }

      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
    },
    true,
  );

  // Hotkey + overall behavior
  document.addEventListener(
    'keydown',
    (e) => {
      if (isCapturingHotkey) {
        return;
      }

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

      if (!matchesHotkey(e)) {
        return;
      }

      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();

      show();

      const selected = getSelectionText();
      if (selected) {
        wireSearchPrefillFromSelection(selected);
        lookupWord(selected);

        return;
      }

      showMainView();
      focusSearch();
    },
    true,
  );
})();