SearchHop - Search Engine Switcher

Quickly switch between search engines on any search results page. Lightweight, draggable, customizable.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

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

(I already have a user script manager, let me install it!)

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.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         SearchHop - Search Engine Switcher
// @name:zh-CN   SearchHop - 搜索跳跃
// @namespace    https://github.com/searchhop
// @version      1.0.1
// @description  Quickly switch between search engines on any search results page. Lightweight, draggable, customizable.
// @description:zh-CN  在搜索结果页面快速切换搜索引擎。轻量、可拖动、可自定义。
// @author       SearchHop
// @license      MIT
// @match        *://*.google.com/search*
// @match        *://*.google.co.uk/search*
// @match        *://*.google.co.jp/search*
// @match        *://*.google.co.kr/search*
// @match        *://*.google.co.in/search*
// @match        *://*.google.co.id/search*
// @match        *://*.google.co.th/search*
// @match        *://*.google.co.nz/search*
// @match        *://*.google.co.za/search*
// @match        *://*.google.com.hk/search*
// @match        *://*.google.com.tw/search*
// @match        *://*.google.com.sg/search*
// @match        *://*.google.com.au/search*
// @match        *://*.google.com.br/search*
// @match        *://*.google.com.mx/search*
// @match        *://*.google.com.ar/search*
// @match        *://*.google.com.my/search*
// @match        *://*.google.com.ph/search*
// @match        *://*.google.com.vn/search*
// @match        *://*.google.com.tr/search*
// @match        *://*.google.ca/search*
// @match        *://*.google.de/search*
// @match        *://*.google.fr/search*
// @match        *://*.google.es/search*
// @match        *://*.google.it/search*
// @match        *://*.google.ru/search*
// @match        *://*.google.nl/search*
// @match        *://*.google.pl/search*
// @match        *://*.google.se/search*
// @match        *://www.baidu.com/s*
// @match        *://*.bing.com/search*
// @match        *://duckduckgo.com/*
// @match        *://search.brave.com/search*
// @match        *://*.youtube.com/results*
// @match        *://search.bilibili.com/*
// @match        *://github.com/search*
// @match        *://*.sogou.com/web*
// @match        *://yandex.com/search*
// @match        *://*.yahoo.com/search*
// @match        *://scholar.google.com/*
// @match        *://*.wikipedia.org/w/index.php*
// @match        *://*.wikipedia.org/wiki/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @run-at       document-idle
// @icon         https://www.google.com/s2/favicons?domain=google.com&sz=32
// ==/UserScript==

(function () {
  'use strict';

  if (document.getElementById('se-switcher-host')) return;

  // ── i18n ──
  const isZH = navigator.language.startsWith('zh');
  const L = {
    settings: isZH ? '搜索引擎管理' : 'Engine Management',
    addEngine: isZH ? '+ 添加搜索引擎' : '+ Add Search Engine',
    restoreDefaults: isZH ? '恢复默认引擎' : 'Restore Defaults',
    delete: isZH ? '删除' : 'Delete',
    searchUrl: isZH ? '搜索URL' : 'Search URL',
    urlPlaceholder: isZH ? '粘贴搜索结果页的URL' : 'Paste a search result page URL',
    urlHint: isZH ? '在目标搜索引擎中搜索任意关键词,粘贴结果页URL' : 'Search anything on the target engine, paste the result page URL',
    name: isZH ? '名称' : 'Name',
    namePlaceholder: isZH ? '自动识别' : 'Auto-detected',
    parseSuccess: isZH ? '✓ 识别成功' : '✓ Parsed successfully',
    parseFail: isZH ? '✗ 无法识别搜索参数' : '✗ Cannot detect search params',
    duplicateEngine: isZH ? '⚠ 已存在相同引擎' : '⚠ Engine already exists',
    cancel: isZH ? '取消' : 'Cancel',
    add: isZH ? '添加' : 'Add',
    displayMode: isZH ? '显示模式' : 'Display Mode',
    alwaysVisible: isZH ? '始终可见' : 'Always Visible',
    autoCollapse: isZH ? '贴边隐藏' : 'Edge Auto-hide',
    resetPosition: isZH ? '重置位置' : 'Reset Position',
    shortcutHint: isZH ? '快捷键 Alt+S 显示/隐藏' : 'Alt+S to show/hide',
    barSettings: isZH ? '工具栏设置' : 'Bar Settings',
  };

  const DEFAULT_ENGINES = [
    { id: 'google', name: 'Google', urlTemplate: 'https://www.google.com/search?q={q}', hostPatterns: ['google.com', 'google.co.'], queryParams: ['q'], enabled: true },
    { id: 'google-ai', name: 'Google AI', urlTemplate: 'https://www.google.com/search?q={q}&udm=50', hostPatterns: ['__google_ai__'], queryParams: ['q'], enabled: true },
    { id: 'brave', name: 'Brave', urlTemplate: 'https://search.brave.com/search?q={q}', hostPatterns: ['brave.com'], queryParams: ['q'], enabled: true },
    { id: 'bing', name: 'Bing', urlTemplate: 'https://www.bing.com/search?q={q}', hostPatterns: ['bing.com'], queryParams: ['q'], enabled: true },
    { id: 'duckduckgo', name: 'DuckDuckGo', urlTemplate: 'https://duckduckgo.com/?q={q}', hostPatterns: ['duckduckgo.com'], queryParams: ['q'], enabled: true },
    { id: 'baidu', name: isZH ? '百度' : 'Baidu', urlTemplate: 'https://www.baidu.com/s?wd={q}', hostPatterns: ['baidu.com'], queryParams: ['wd', 'q'], enabled: true },
  ];

  // ── Storage ──
  function getEngines() {
    const v = GM_getValue('engines');
    return v || JSON.parse(JSON.stringify(DEFAULT_ENGINES));
  }
  function setEngines(e) { GM_setValue('engines', e); }
  function getBarPrefs() {
    return { x: 10, y: null, collapsed: false, displayMode: 'visible', ...GM_getValue('barPrefs', {}) };
  }
  function setBarPrefs(p) { GM_setValue('barPrefs', p); }

  // ── Engine matching ──
  function matchEngine(engines, host) {
    const params = new URLSearchParams(location.search);
    if (host.includes('google.') && params.get('udm') === '50') {
      const aiEngine = engines.find(e => e.id === 'google-ai' && e.enabled !== false);
      if (aiEngine) return aiEngine;
    }
    return engines.find(e => e.enabled !== false && e.hostPatterns.some(p => p !== '__google_ai__' && host.includes(p)));
  }

  function getQuery(engine) {
    const params = new URLSearchParams(location.search);
    for (const k of engine.queryParams) {
      const v = params.get(k);
      if (v) return v;
    }
    return '';
  }

  // ── Favicon ──
  function faviconUrl(engine) {
    try {
      const u = new URL(engine.urlTemplate.replace('{q}', 'test'));
      const parts = u.hostname.split('.');
      const root = parts.length > 2 ? parts.slice(-2).join('.') : u.hostname;
      return `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${root}&size=32`;
    } catch {
      const domain = engine.hostPatterns[0].replace(/\.$/, '');
      return `https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://${domain}&size=32`;
    }
  }

  function createFavicon(engine, size = 20) {
    const img = document.createElement('img');
    img.src = faviconUrl(engine);
    img.alt = engine.name;
    img.width = size;
    img.height = size;
    img.style.borderRadius = '4px';
    img.onerror = () => {
      img.style.display = 'none';
      const av = document.createElement('span');
      av.className = 'sh-letter-avatar';
      av.textContent = engine.name[0].toUpperCase();
      av.style.cssText = `width:${size}px;height:${size}px;display:flex;align-items:center;justify-content:center;border-radius:4px;background:#e8eaed;color:#5f6368;font-size:${size * 0.6}px;font-weight:600;`;
      img.parentNode?.insertBefore(av, img);
    };
    return img;
  }

  // ── URL auto-parse ──
  function parseSearchUrl(url) {
    try {
      const u = new URL(url);
      const host = u.hostname.replace(/^www\./, '');
      const params = new URLSearchParams(u.search);
      const common = ['q', 'query', 'search', 'keyword', 'wd', 'text', 'p', 's', 'k', 'word', 'key', 'keywords'];
      let qp = null, qv = '';
      for (const p of common) { if (params.has(p)) { qp = p; qv = params.get(p); break; } }
      if (!qp) { for (const [k, v] of params) { if (v && v.length > 1) { qp = k; qv = v; break; } } }
      if (!qp) return null;
      let tmpl = u.origin + u.pathname + '?';
      const parts = [];
      for (const [k, v] of params) { parts.push(k === qp ? `${k}={q}` : `${k}=${encodeURIComponent(v)}`); }
      tmpl += parts.join('&');
      const domainParts = host.split('.');
      const hostPattern = domainParts.length > 2 ? domainParts.slice(-2).join('.') : host;
      const ccSlds = ['co', 'com', 'net', 'org', 'edu', 'gov', 'ac', 'or', 'ne', 'go'];
      let nameBase;
      if (domainParts.length >= 3 && ccSlds.includes(domainParts[domainParts.length - 2])) {
        nameBase = domainParts[domainParts.length - 3] || domainParts[0];
      } else if (domainParts.length >= 2) {
        nameBase = domainParts[domainParts.length - 2];
      } else {
        nameBase = domainParts[0];
      }
      const name = nameBase.charAt(0).toUpperCase() + nameBase.slice(1);
      return { id: 'custom_' + Date.now(), name, urlTemplate: tmpl, hostPatterns: [hostPattern], queryParams: [qp], enabled: true };
    } catch { return null; }
  }

  // ── Init ──
  let engines = getEngines();
  let barPrefs = getBarPrefs();
  const host = location.hostname.toLowerCase();
  const currentEngine = matchEngine(engines, host);
  if (!currentEngine) return;
  const query = getQuery(currentEngine);
  if (!query) return;

  // ── Shadow DOM ──
  const hostEl = document.createElement('div');
  hostEl.id = 'se-switcher-host';
  const shadow = hostEl.attachShadow({ mode: 'closed' });
  document.body.appendChild(hostEl);

  // ── CSS ──
  const style = document.createElement('style');
  style.textContent = `
    *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

    .bar {
      position: fixed; z-index: 2147483647;
      display: flex; flex-direction: column; align-items: center; gap: 4px;
      padding: 10px 7px;
      background: rgba(255,255,255,0.88);
      backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
      border-radius: 16px;
      box-shadow: 0 2px 24px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04);
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      transition: box-shadow 0.2s;
      cursor: grab; user-select: none;
    }
    .bar:hover { box-shadow: 0 4px 32px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.06); }
    .bar.dragging { cursor: grabbing; }

    .collapse-toggle {
      width: 28px; height: 28px; border-radius: 8px; border: none;
      background: transparent; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      color: #ccc; font-size: 14px; transition: all 0.15s;
    }
    .collapse-toggle:hover { background: rgba(0,0,0,0.05); color: #888; }
    .bar.collapsed { padding: 6px; border-radius: 50%; }
    .bar.collapsed > :not(.collapse-toggle) { display: none; }
    .bar.collapsed .collapse-toggle { width: 24px; height: 24px; }

    /* Edge-snapping auto-hide */
    .bar.edge-hidden { transition: transform 0.3s cubic-bezier(0.4,0,0.2,1); }
    .bar.edge-hidden.snap-left { transform: translateX(calc(-100% + 8px)) !important; }
    .bar.edge-hidden.snap-right { transform: translateX(calc(100% - 8px)) !important; }
    .bar.edge-hidden:hover { transform: translateX(0) !important; }

    .engine-btn {
      width: 38px; height: 38px; border-radius: 11px;
      border: 2px solid transparent; background: transparent; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      transition: all 0.2s cubic-bezier(0.34,1.56,0.64,1); position: relative;
    }
    .engine-btn:hover { background: rgba(0,0,0,0.05); transform: scale(1.15); }
    .engine-btn.active { border-color: #4285f4; background: rgba(66,133,244,0.08); }
    .engine-btn img, .engine-btn .sh-letter-avatar { pointer-events: none; }
    .engine-btn[title]::after {
      content: attr(title); position: absolute; left: 48px; top: 50%; transform: translateY(-50%);
      background: rgba(30,30,30,0.9); color: #fff;
      padding: 4px 10px; border-radius: 6px; font-size: 12px; white-space: nowrap;
      opacity: 0; pointer-events: none; transition: opacity 0.15s;
    }
    .engine-btn:hover::after { opacity: 1; }

    .divider { width: 26px; height: 1px; background: rgba(0,0,0,0.08); margin: 2px 0; }

    .gear-btn {
      width: 32px; height: 32px; border-radius: 8px; border: none;
      background: transparent; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      color: #bbb; font-size: 16px; transition: all 0.2s;
    }
    .gear-btn:hover { background: rgba(0,0,0,0.05); color: #888; }
    .gear-btn svg { width: 16px; height: 16px; fill: currentColor; }

    /* Bar Settings */
    .bar-settings { padding: 16px 20px; border-top: 1px solid #f2f2f2; }
    .bar-settings h3 { font-size: 13px; font-weight: 600; color: #888; margin-bottom: 12px; }
    .setting-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
    .setting-row label { font-size: 13px; color: #555; }
    .setting-row select { padding: 4px 8px; border: 1px solid #ddd; border-radius: 6px; font-size: 12px; background: #fff; }
    .reset-pos-btn { width: 100%; padding: 8px; border: 1px solid #e0e0e0; border-radius: 8px; background: transparent; color: #888; font-size: 12px; cursor: pointer; transition: all 0.15s; margin-top: 4px; }
    .reset-pos-btn:hover { border-color: #4285f4; color: #4285f4; }
    .shortcut-hint { font-size: 11px; color: #bbb; text-align: center; margin-top: 8px; }

    /* Modal */
    .overlay {
      position: fixed; inset: 0;
      background: rgba(0,0,0,0.35); backdrop-filter: blur(3px);
      z-index: 2147483647;
      display: flex; align-items: center; justify-content: center;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      animation: shFadeIn 0.15s;
    }
    @keyframes shFadeIn { from { opacity: 0; } }

    .modal {
      background: #fff; border-radius: 18px;
      width: 440px; max-height: 75vh;
      box-shadow: 0 24px 80px rgba(0,0,0,0.18);
      display: flex; flex-direction: column;
      animation: shSlideUp 0.2s cubic-bezier(0.34,1.56,0.64,1);
      overflow: hidden;
    }
    @keyframes shSlideUp { from { transform: translateY(20px); opacity: 0; } }

    .modal-hd {
      padding: 20px 24px 14px;
      display: flex; justify-content: space-between; align-items: center;
      border-bottom: 1px solid #f2f2f2;
    }
    .modal-hd h2 { font-size: 17px; font-weight: 700; color: #222; }
    .close-btn {
      width: 30px; height: 30px; border-radius: 8px; border: none;
      background: #f5f5f5; cursor: pointer; font-size: 18px; color: #999;
      display: flex; align-items: center; justify-content: center; transition: all 0.15s;
    }
    .close-btn:hover { background: #eee; color: #666; }

    .modal-bd { padding: 12px 20px; overflow-y: auto; flex: 1; }

    .engine-row {
      display: flex; align-items: center; gap: 10px;
      padding: 9px 8px; border-radius: 10px; transition: background 0.12s;
      border: 2px solid transparent;
    }
    .engine-row:hover { background: #fafafa; }
    .engine-row.drag-over { border-color: #4285f4; background: #f0f6ff; }
    .engine-row.dragging { opacity: 0.4; }

    .drag-handle { cursor: grab; color: #ccc; font-size: 15px; user-select: none; line-height: 1; padding: 2px; }
    .drag-handle:active { cursor: grabbing; }

    .engine-row .name { flex: 1; font-size: 14px; color: #333; font-weight: 500; }
    .engine-row .host { font-size: 11px; color: #aaa; margin-left: 4px; font-weight: 400; }

    .toggle { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
    .toggle input { opacity: 0; width: 0; height: 0; position: absolute; }
    .toggle-track {
      position: absolute; inset: 0; background: #ddd; border-radius: 10px;
      cursor: pointer; transition: background 0.2s;
    }
    .toggle-track::before {
      content: ''; position: absolute; width: 16px; height: 16px; left: 2px; top: 2px;
      background: #fff; border-radius: 50%; transition: transform 0.2s;
      box-shadow: 0 1px 3px rgba(0,0,0,0.15);
    }
    .toggle input:checked + .toggle-track { background: #4285f4; }
    .toggle input:checked + .toggle-track::before { transform: translateX(16px); }

    .del-btn {
      width: 28px; height: 28px; border-radius: 7px;
      border: none; background: transparent; cursor: pointer;
      color: #ccc; display: flex; align-items: center; justify-content: center;
      font-size: 16px; transition: all 0.15s; flex-shrink: 0;
    }
    .del-btn:hover { background: #fef0f0; color: #e53935; }

    .add-area { padding: 12px 20px 20px; border-top: 1px solid #f2f2f2; }
    .add-btn {
      width: 100%; padding: 11px; border: 2px dashed #e0e0e0; border-radius: 10px;
      background: transparent; color: #aaa; font-size: 14px; cursor: pointer; transition: all 0.15s;
    }
    .add-btn:hover { border-color: #4285f4; color: #4285f4; background: rgba(66,133,244,0.03); }

    .add-form { display: flex; flex-direction: column; gap: 12px; }
    .form-row { display: flex; flex-direction: column; gap: 4px; }
    .form-row label { font-size: 12px; color: #888; font-weight: 600; }
    .form-row input {
      padding: 9px 12px; border: 1.5px solid #e5e5e5; border-radius: 9px;
      font-size: 13px; outline: none; transition: border-color 0.15s; font-family: inherit;
    }
    .form-row input:focus { border-color: #4285f4; }
    .form-row .hint { font-size: 11px; color: #bbb; margin-top: 2px; }

    .parse-preview {
      display: flex; align-items: center; gap: 8px;
      padding: 10px 12px; background: #f8fafb; border-radius: 9px;
      font-size: 13px; color: #555;
    }
    .parse-preview img { border-radius: 4px; }
    .parse-preview .ok { color: #34a853; font-weight: 600; }
    .parse-preview .fail { color: #ea4335; }

    .btn-row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px; }
    .btn { padding: 8px 18px; border-radius: 9px; border: none; font-size: 13px; cursor: pointer; transition: all 0.15s; font-weight: 600; }
    .btn-p { background: #4285f4; color: #fff; }
    .btn-p:hover { background: #3367d6; }
    .btn-p:disabled { background: #ccc; cursor: default; }
    .btn-s { background: #f5f5f5; color: #666; }
    .btn-s:hover { background: #eee; }

    @media (prefers-color-scheme: dark) {
      .bar { background: rgba(40,40,40,0.92); box-shadow: 0 2px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.06); }
      .bar:hover { box-shadow: 0 4px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08); }
      .engine-btn:hover { background: rgba(255,255,255,0.08); }
      .engine-btn.active { border-color: #8ab4f8; background: rgba(138,180,248,0.12); }
      .engine-btn[title]::after { background: rgba(50,50,50,0.95); }
      .divider { background: rgba(255,255,255,0.08); }
      .gear-btn { color: #666; }
      .gear-btn:hover { background: rgba(255,255,255,0.06); color: #aaa; }
      .collapse-toggle { color: #666; }
      .collapse-toggle:hover { background: rgba(255,255,255,0.06); color: #aaa; }
      .bar-settings { border-color: #3a3a3a; }
      .bar-settings h3 { color: #999; }
      .setting-row label { color: #bbb; }
      .setting-row select { background: #333; border-color: #444; color: #ddd; }
      .reset-pos-btn { border-color: #444; color: #777; }
      .reset-pos-btn:hover { border-color: #8ab4f8; color: #8ab4f8; }
      .modal { background: #2a2a2a; }
      .modal-hd { border-color: #3a3a3a; }
      .modal-hd h2 { color: #eee; }
      .close-btn { background: #3a3a3a; color: #888; }
      .close-btn:hover { background: #444; color: #bbb; }
      .engine-row:hover { background: #333; }
      .engine-row.drag-over { border-color: #8ab4f8; background: #2d3548; }
      .engine-row .name { color: #ddd; }
      .engine-row .host { color: #777; }
      .del-btn:hover { background: #3d2020; color: #f28b82; }
      .add-area { border-color: #3a3a3a; }
      .add-btn { border-color: #444; color: #777; }
      .add-btn:hover { border-color: #8ab4f8; color: #8ab4f8; }
      .form-row label { color: #999; }
      .form-row input { background: #333; border-color: #444; color: #ddd; }
      .form-row input:focus { border-color: #8ab4f8; }
      .parse-preview { background: #333; color: #bbb; }
      .btn-s { background: #3a3a3a; color: #bbb; }
      .btn-s:hover { background: #444; }
      .sh-letter-avatar { background: #444 !important; color: #bbb !important; }
    }
  `;
  shadow.appendChild(style);

  const GEAR_SVG = '<svg viewBox="0 0 20 20"><path d="M8.6 1.3a1 1 0 0 1 1-.8h.8a1 1 0 0 1 1 .8l.2 1.5a6.5 6.5 0 0 1 1.6.9l1.4-.6a1 1 0 0 1 1.2.4l.4.7a1 1 0 0 1-.2 1.2l-1.1 1a6.5 6.5 0 0 1 0 1.8l1.1 1a1 1 0 0 1 .2 1.2l-.4.7a1 1 0 0 1-1.2.4l-1.4-.6a6.5 6.5 0 0 1-1.6.9l-.2 1.5a1 1 0 0 1-1 .8h-.8a1 1 0 0 1-1-.8l-.2-1.5a6.5 6.5 0 0 1-1.6-.9l-1.4.6a1 1 0 0 1-1.2-.4l-.4-.7a1 1 0 0 1 .2-1.2l1.1-1a6.5 6.5 0 0 1 0-1.8l-1.1-1a1 1 0 0 1-.2-1.2l.4-.7a1 1 0 0 1 1.2-.4l1.4.6a6.5 6.5 0 0 1 1.6-.9l.2-1.5ZM10 7a3 3 0 1 0 0 6 3 3 0 0 0 0-6Z"/></svg>';

  // ── Render bar ──
  const bar = document.createElement('div');
  bar.className = 'bar';

  function applyBarPosition() {
    bar.style.left = barPrefs.x + 'px';
    if (barPrefs.y != null) {
      bar.style.top = barPrefs.y + 'px';
      bar.style.transform = 'none';
    } else {
      bar.style.top = '50%';
      bar.style.transform = 'translateY(-50%)';
    }
  }
  applyBarPosition();
  if (barPrefs.collapsed) bar.classList.add('collapsed');
  shadow.appendChild(bar);

  // ── Drag ──
  let isDragging = false, dragStartX, dragStartY, barStartX, barStartY;
  bar.addEventListener('pointerdown', e => {
    if (e.target.closest('.engine-btn, .gear-btn, .collapse-toggle')) return;
    isDragging = false;
    dragStartX = e.clientX; dragStartY = e.clientY;
    const rect = bar.getBoundingClientRect();
    barStartX = rect.left; barStartY = rect.top;
    bar.setPointerCapture(e.pointerId);
  });
  bar.addEventListener('pointermove', e => {
    if (!bar.hasPointerCapture(e.pointerId)) return;
    const dx = e.clientX - dragStartX, dy = e.clientY - dragStartY;
    if (!isDragging && Math.abs(dx) + Math.abs(dy) < 4) return;
    isDragging = true;
    bar.classList.add('dragging');
    bar.classList.remove('edge-hidden', 'snap-left', 'snap-right');
    clearTimeout(edgeHideTimer);
    bar.style.transform = 'none';
    const x = Math.max(0, Math.min(window.innerWidth - bar.offsetWidth, barStartX + dx));
    const y = Math.max(0, Math.min(window.innerHeight - bar.offsetHeight, barStartY + dy));
    bar.style.left = x + 'px'; bar.style.top = y + 'px';
  });
  bar.addEventListener('pointerup', e => {
    if (!bar.hasPointerCapture(e.pointerId)) return;
    bar.releasePointerCapture(e.pointerId);
    bar.classList.remove('dragging');
    if (isDragging) {
      barPrefs.x = parseInt(bar.style.left);
      barPrefs.y = parseInt(bar.style.top);
      setBarPrefs(barPrefs);
      isDragging = false;
      if (barPrefs.displayMode === 'auto-collapse') {
        edgeHideTimer = setTimeout(() => snapToEdge(), 800);
      }
    }
  });

  // ── Edge-snapping auto-hide ──
  let edgeHideTimer = null;
  let settingsOpen = false;

  function getSnapSide() {
    const rect = bar.getBoundingClientRect();
    return (rect.left + rect.width / 2) < window.innerWidth / 2 ? 'left' : 'right';
  }

  function applyDisplayMode() {
    bar.removeEventListener('mouseenter', onBarEnter);
    bar.removeEventListener('mouseleave', onBarLeave);
    bar.classList.remove('edge-hidden', 'snap-left', 'snap-right');
    if (barPrefs.displayMode === 'auto-collapse') {
      bar.addEventListener('mouseenter', onBarEnter);
      bar.addEventListener('mouseleave', onBarLeave);
      edgeHideTimer = setTimeout(() => snapToEdge(), 600);
    }
  }

  function snapToEdge() {
    if (bar.style.transform === 'translateY(-50%)') {
      const rect = bar.getBoundingClientRect();
      bar.style.top = rect.top + 'px';
      bar.style.transform = 'none';
      barPrefs.y = rect.top;
    }
    const side = getSnapSide();
    bar.classList.remove('snap-left', 'snap-right');
    bar.classList.add('edge-hidden', side === 'left' ? 'snap-left' : 'snap-right');
  }

  function onBarEnter() {
    clearTimeout(edgeHideTimer);
    bar.classList.remove('edge-hidden', 'snap-left', 'snap-right');
  }
  function onBarLeave() {
    if (settingsOpen) return;
    edgeHideTimer = setTimeout(() => snapToEdge(), 400);
  }
  applyDisplayMode();

  function renderBar() {
    bar.innerHTML = '';

    // Collapse toggle
    const collapseBtn = document.createElement('button');
    collapseBtn.className = 'collapse-toggle';
    collapseBtn.innerHTML = barPrefs.collapsed ? '\u2295' : '\u2296';
    collapseBtn.title = barPrefs.collapsed ? 'Expand' : 'Collapse';
    collapseBtn.addEventListener('click', e => {
      e.stopPropagation();
      barPrefs.collapsed = !barPrefs.collapsed;
      setBarPrefs(barPrefs);
      bar.classList.toggle('collapsed', barPrefs.collapsed);
      collapseBtn.innerHTML = barPrefs.collapsed ? '\u2295' : '\u2296';
      collapseBtn.title = barPrefs.collapsed ? 'Expand' : 'Collapse';
    });
    bar.appendChild(collapseBtn);

    const enabled = engines.filter(e => e.enabled !== false);
    enabled.forEach(eng => {
      const btn = document.createElement('button');
      btn.className = 'engine-btn';
      if (eng.id === currentEngine.id) btn.classList.add('active');
      btn.title = eng.name;
      btn.appendChild(createFavicon(eng));
      btn.addEventListener('click', () => { location.href = eng.urlTemplate.replace('{q}', encodeURIComponent(query)); });
      bar.appendChild(btn);
    });

    const sep = document.createElement('div');
    sep.className = 'divider';
    bar.appendChild(sep);

    const gear = document.createElement('button');
    gear.className = 'gear-btn';
    gear.innerHTML = GEAR_SVG;
    gear.title = L.settings;
    gear.addEventListener('click', showSettings);
    bar.appendChild(gear);
  }

  // ── Settings modal ──
  function showSettings() {
    settingsOpen = true;
    function closeSettings() {
      settingsOpen = false;
      overlay.remove();
      if (barPrefs.displayMode === 'auto-collapse') {
        edgeHideTimer = setTimeout(() => snapToEdge(), 400);
      }
    }

    const overlay = document.createElement('div');
    overlay.className = 'overlay';
    overlay.addEventListener('click', e => { if (e.target === overlay) closeSettings(); });

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

    const hd = document.createElement('div');
    hd.className = 'modal-hd';
    const h2 = document.createElement('h2');
    h2.textContent = L.settings;
    const closeBtn = document.createElement('button');
    closeBtn.className = 'close-btn';
    closeBtn.textContent = '\u00d7';
    closeBtn.addEventListener('click', closeSettings);
    hd.appendChild(h2); hd.appendChild(closeBtn);
    modal.appendChild(hd);

    const bd = document.createElement('div');
    bd.className = 'modal-bd';
    modal.appendChild(bd);

    let dragIdx = -1;

    function renderList() {
      bd.innerHTML = '';
      engines.forEach((eng, i) => {
        const row = document.createElement('div');
        row.className = 'engine-row';
        row.draggable = true;
        row.dataset.idx = i;

        row.addEventListener('dragstart', e => { dragIdx = i; row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); });
        row.addEventListener('dragend', () => { row.classList.remove('dragging'); dragIdx = -1; bd.querySelectorAll('.engine-row').forEach(r => r.classList.remove('drag-over')); });
        row.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; bd.querySelectorAll('.engine-row').forEach(r => r.classList.remove('drag-over')); if (parseInt(row.dataset.idx) !== dragIdx) row.classList.add('drag-over'); });
        row.addEventListener('dragleave', () => row.classList.remove('drag-over'));
        row.addEventListener('drop', e => {
          e.preventDefault(); row.classList.remove('drag-over');
          const from = dragIdx, to = parseInt(row.dataset.idx);
          if (from === to || from < 0) return;
          const [moved] = engines.splice(from, 1);
          engines.splice(to, 0, moved);
          setEngines(engines); renderList(); renderBar();
        });

        const handle = document.createElement('span');
        handle.className = 'drag-handle';
        handle.textContent = '\u283f';
        row.appendChild(handle);

        row.appendChild(createFavicon(eng, 22));

        const nameSpan = document.createElement('span');
        nameSpan.className = 'name';
        nameSpan.textContent = eng.name;
        const hostSpan = document.createElement('span');
        hostSpan.className = 'host';
        hostSpan.textContent = eng.hostPatterns[0];
        nameSpan.appendChild(hostSpan);
        row.appendChild(nameSpan);

        const toggle = document.createElement('label');
        toggle.className = 'toggle';
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.checked = eng.enabled !== false;
        cb.addEventListener('change', () => { eng.enabled = cb.checked; setEngines(engines); renderBar(); });
        const track = document.createElement('span');
        track.className = 'toggle-track';
        toggle.appendChild(cb); toggle.appendChild(track);
        row.appendChild(toggle);

        const del = document.createElement('button');
        del.className = 'del-btn';
        del.innerHTML = '&#x2715;';
        del.title = L.delete;
        del.addEventListener('click', () => { engines.splice(i, 1); setEngines(engines); renderList(); renderBar(); });
        row.appendChild(del);

        bd.appendChild(row);
      });
    }
    renderList();

    // Add section
    const addArea = document.createElement('div');
    addArea.className = 'add-area';

    function showAddBtn() {
      addArea.innerHTML = '';
      const ab = document.createElement('button');
      ab.className = 'add-btn';
      ab.textContent = L.addEngine;
      ab.addEventListener('click', showAddForm);
      addArea.appendChild(ab);
    }

    function showAddForm() {
      addArea.innerHTML = '';
      const form = document.createElement('div');
      form.className = 'add-form';

      const urlRow = document.createElement('div');
      urlRow.className = 'form-row';
      const urlLabel = document.createElement('label');
      urlLabel.textContent = L.searchUrl;
      const urlInput = document.createElement('input');
      urlInput.type = 'text';
      urlInput.placeholder = L.urlPlaceholder;
      const urlHint = document.createElement('div');
      urlHint.className = 'hint';
      urlHint.textContent = L.urlHint;
      urlRow.appendChild(urlLabel); urlRow.appendChild(urlInput); urlRow.appendChild(urlHint);
      form.appendChild(urlRow);

      const preview = document.createElement('div');
      preview.className = 'parse-preview';
      preview.style.display = 'none';
      form.appendChild(preview);

      const nameRow = document.createElement('div');
      nameRow.className = 'form-row';
      const nameLabel = document.createElement('label');
      nameLabel.textContent = L.name;
      const nameInput = document.createElement('input');
      nameInput.type = 'text';
      nameInput.placeholder = L.namePlaceholder;
      nameRow.appendChild(nameLabel); nameRow.appendChild(nameInput);
      form.appendChild(nameRow);

      let parsed = null;
      let userEditedName = false;
      nameInput.addEventListener('input', () => { userEditedName = true; });

      let parseTimer;
      urlInput.addEventListener('input', () => {
        clearTimeout(parseTimer);
        userEditedName = false;
        parseTimer = setTimeout(() => {
          const val = urlInput.value.trim();
          if (!val) { preview.style.display = 'none'; parsed = null; nameInput.value = ''; return; }
          parsed = parseSearchUrl(val);
          preview.style.display = 'flex';
          if (parsed) {
            preview.innerHTML = '';
            preview.appendChild(createFavicon(parsed, 20));
            const txt = document.createElement('span');
            txt.className = 'ok';
            txt.textContent = `${L.parseSuccess} (${parsed.hostPatterns[0]}, ${parsed.queryParams[0]})`;
            preview.appendChild(txt);
            if (!userEditedName) nameInput.value = parsed.name;
          } else {
            preview.innerHTML = `<span class="fail">${L.parseFail}</span>`;
            if (!userEditedName) nameInput.value = '';
          }
        }, 300);
      });

      const btnRow = document.createElement('div');
      btnRow.className = 'btn-row';
      const cancelBtn = document.createElement('button');
      cancelBtn.className = 'btn btn-s';
      cancelBtn.textContent = L.cancel;
      cancelBtn.addEventListener('click', showAddBtn);
      const saveBtn = document.createElement('button');
      saveBtn.className = 'btn btn-p';
      saveBtn.textContent = L.add;
      saveBtn.addEventListener('click', () => {
        if (!parsed) return;
        if (nameInput.value.trim()) parsed.name = nameInput.value.trim();
        const dup = engines.find(e => e.hostPatterns.some(p => parsed.hostPatterns.includes(p)));
        if (dup) { preview.innerHTML = `<span class="fail">${L.duplicateEngine}: ${dup.name}</span>`; preview.style.display = 'flex'; return; }
        engines.push(parsed);
        setEngines(engines); renderList(); renderBar(); showAddBtn();
      });
      btnRow.appendChild(cancelBtn); btnRow.appendChild(saveBtn);
      form.appendChild(btnRow);
      addArea.appendChild(form);
      urlInput.focus();
    }

    showAddBtn();
    modal.appendChild(addArea);

    // Restore defaults
    const restoreArea = document.createElement('div');
    restoreArea.style.cssText = 'padding: 0 20px 16px; text-align: center;';
    const restoreBtn = document.createElement('button');
    restoreBtn.style.cssText = 'background:none;border:none;color:#aaa;font-size:12px;cursor:pointer;padding:6px 12px;border-radius:6px;transition:all 0.15s;';
    restoreBtn.textContent = L.restoreDefaults;
    restoreBtn.addEventListener('mouseenter', () => { restoreBtn.style.color = '#4285f4'; restoreBtn.style.background = 'rgba(66,133,244,0.06)'; });
    restoreBtn.addEventListener('mouseleave', () => { restoreBtn.style.color = '#aaa'; restoreBtn.style.background = 'none'; });
    restoreBtn.addEventListener('click', () => {
      const existing = new Set(engines.map(e => e.id));
      let added = 0;
      for (const def of DEFAULT_ENGINES) { if (!existing.has(def.id)) { engines.push({ ...def }); added++; } }
      if (added > 0) { setEngines(engines); renderList(); renderBar(); }
    });
    restoreArea.appendChild(restoreBtn);
    modal.appendChild(restoreArea);

    // Bar settings
    const barSettingsArea = document.createElement('div');
    barSettingsArea.className = 'bar-settings';
    const barH3 = document.createElement('h3');
    barH3.textContent = L.barSettings;
    barSettingsArea.appendChild(barH3);

    const modeRow = document.createElement('div');
    modeRow.className = 'setting-row';
    const modeLabel = document.createElement('label');
    modeLabel.textContent = L.displayMode;
    const modeSelect = document.createElement('select');
    ['visible', 'auto-collapse'].forEach(v => {
      const opt = document.createElement('option');
      opt.value = v;
      opt.textContent = v === 'visible' ? L.alwaysVisible : L.autoCollapse;
      if (barPrefs.displayMode === v) opt.selected = true;
      modeSelect.appendChild(opt);
    });
    modeSelect.addEventListener('change', () => { barPrefs.displayMode = modeSelect.value; setBarPrefs(barPrefs); applyDisplayMode(); });
    modeRow.appendChild(modeLabel); modeRow.appendChild(modeSelect);
    barSettingsArea.appendChild(modeRow);

    const resetBtn = document.createElement('button');
    resetBtn.className = 'reset-pos-btn';
    resetBtn.textContent = L.resetPosition;
    resetBtn.addEventListener('click', () => {
      barPrefs.x = 10; barPrefs.y = null; barPrefs.collapsed = false;
      setBarPrefs(barPrefs); applyBarPosition();
      bar.classList.remove('collapsed'); renderBar();
    });
    barSettingsArea.appendChild(resetBtn);

    const hint = document.createElement('div');
    hint.className = 'shortcut-hint';
    hint.textContent = L.shortcutHint;
    barSettingsArea.appendChild(hint);

    modal.appendChild(barSettingsArea);
    overlay.appendChild(modal);
    shadow.appendChild(overlay);
  }

  renderBar();

  // ── Keyboard shortcut ──
  document.addEventListener('keydown', e => {
    if (e.altKey && (e.key === 's' || e.key === 'S')) {
      e.preventDefault();
      bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
    }
  });

  // ── Window resize ──
  window.addEventListener('resize', () => {
    if (barPrefs.y != null) {
      const x = Math.max(0, Math.min(window.innerWidth - bar.offsetWidth, barPrefs.x));
      const y = Math.max(0, Math.min(window.innerHeight - bar.offsetHeight, barPrefs.y));
      bar.style.left = x + 'px'; bar.style.top = y + 'px';
    }
  });

  // ── GM menu command ──
  GM_registerMenuCommand(L.settings, showSettings);
})();