Quickly switch between search engines on any search results page. Lightweight, draggable, customizable.
// ==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 = '✕';
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);
})();