Grep-style complementary search engine for wiki/static sites
// ==UserScript==
// @name PageGrep
// @version 3.3
// @description Grep-style complementary search engine for wiki/static sites
// @author Rust1667
// @match https://retrofmhy.pages.dev/*
// @match https://fmhy.net/*
// @match https://fluffle.cc/*
// @match https://rentry.co/*
// @match https://rentry.org/*
// @match https://www.reddit.com/r/*/wiki/*
// @match https://*.wikipedia.org/wiki/*
// @match https://github.com/*/*/wiki/*
// @grant none
// @icon data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔍</text></svg>
// @namespace http://tampermonkey.net/
// ==/UserScript==
(function () {
'use strict';
// ── CONFIG ───────────────────────────────────────────────────────────────────
const CONTENT_SELECTOR = '#mainScroll'; // adjust per site if needed
const MAX_RESULTS = 10;
// ─────────────────────────────────────────────────────────────────────────────
// ── SHADOW DOM HOST ──────────────────────────────────────────────────────────
const host = document.createElement('div');
host.id = 'pagegrep-host';
host.style.cssText = 'all:initial;position:fixed;z-index:2147483647;';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
// ── STYLES (scoped inside shadow) ────────────────────────────────────────────
const styleEl = document.createElement('style');
styleEl.textContent = `
*, *::before, *::after { box-sizing: border-box; }
/* ── FAB ── */
#wgs-fab {
position: fixed;
bottom: 24px;
right: 24px;
width: 42px;
height: 42px;
border-radius: 50%;
background: #1a1a2e;
border: 1px solid #3a3a5c;
box-shadow: 0 2px 12px rgba(0,0,0,.4);
color: #7c83fd;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.35;
transition: opacity .2s, box-shadow .2s;
user-select: none;
touch-action: none;
}
#wgs-fab:hover { opacity: 1; box-shadow: 0 4px 20px rgba(0,0,0,.55); }
#wgs-fab.active { opacity: 1; }
/* ── SPOTLIGHT OVERLAY ── */
#wgs-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 18vh;
opacity: 0;
pointer-events: none;
transition: opacity .18s;
z-index: 1;
}
#wgs-overlay.open {
opacity: 1;
pointer-events: all;
}
#wgs-spotlight {
background: var(--wgs-site-bg, #1a1a2e);
border: 1px solid #3a3a5c;
border-radius: 12px;
box-shadow: 0 8px 40px rgba(0,0,0,.6);
padding: 10px 14px;
display: flex;
align-items: center;
gap: 8px;
width: min(560px, 90vw);
font-family: 'Consolas', 'Menlo', monospace;
}
#wgs-search-icon {
color: #7c83fd;
font-size: 16px;
flex-shrink: 0;
opacity: 0.7;
}
#wgs-input {
background: transparent;
border: none;
outline: none;
color: var(--wgs-site-text, #e0e0f0);
font-size: 15px;
font-family: inherit;
width: 100%;
caret-color: #7c83fd;
}
#wgs-input::placeholder { color: #555577; }
#wgs-kbd {
color: #3a3a5c;
font-size: 10px;
flex-shrink: 0;
white-space: nowrap;
}
/* ── RESULTS PANEL ── */
#wgs-panel {
position: fixed;
top: 0;
right: 0;
width: 38.196601vw;
height: 100vh;
background: var(--wgs-site-bg, #11111e);
border-left: 2px solid #3a3a5c;
box-shadow: -6px 0 40px rgba(0,0,0,.6);
display: flex;
flex-direction: column;
font-family: 'Consolas', 'Menlo', monospace;
transform: translateX(100%);
transition: transform .2s cubic-bezier(.4,0,.2,1);
overflow: hidden;
z-index: 2;
}
#wgs-panel.open { transform: translateX(0); }
#wgs-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #2a2a4e;
background: var(--wgs-site-bg, #1a1a2e);
flex-shrink: 0;
}
#wgs-panel-title {
color: #7c83fd;
font-size: var(--wgs-site-fontsize, 14px);
letter-spacing: .08em;
text-transform: uppercase;
}
#wgs-close {
background: none;
border: none;
color: #555577;
font-size: 18px;
cursor: pointer;
line-height: 1;
padding: 2px 6px;
border-radius: 4px;
transition: color .15s, background .15s;
}
#wgs-close:hover { color: #e0e0f0; background: #2a2a4e; }
#wgs-results {
overflow-y: auto;
flex: 1;
padding: 8px 0;
}
#wgs-results::-webkit-scrollbar { width: 6px; }
#wgs-results::-webkit-scrollbar-track { background: transparent; }
#wgs-results::-webkit-scrollbar-thumb { background: #2a2a4e; border-radius: 3px; }
.wgs-result {
padding: 9px 16px;
border-bottom: 1px solid #1e1e34;
transition: background .1s;
cursor: pointer;
}
.wgs-result:hover { background: color-mix(in srgb, var(--wgs-site-bg, #1e1e34) 85%, white 15%); }
.wgs-breadcrumb {
display: block;
margin-bottom: 2px;
font-size: calc(var(--wgs-site-fontsize, 14px) * 0.82);
line-height: 1.5;
}
.wgs-breadcrumb-seg {
color: #7c83fd;
text-decoration: none;
font-weight: 600;
letter-spacing: .03em;
transition: color .1s;
}
.wgs-breadcrumb-seg:hover { color: #a0a6ff; text-decoration: underline; }
.wgs-breadcrumb-sep {
color: #3a3a5c;
margin: 0 4px;
font-size: 10px;
}
.wgs-line li, .wgs-line ul, .wgs-line ol { list-style: none; margin: 0; padding: 0; }
.wgs-line {
color: var(--wgs-site-text, #c8c8e0);
font-size: var(--wgs-site-fontsize, 14px);
line-height: 1.5;
word-break: break-word;
}
.wgs-line a { color: var(--wgs-site-link, #7ca4fd) !important; }
.wgs-line a:hover { filter: brightness(1.25); }
.wgs-match {
background: #3d3500;
color: #ffd54f;
border-radius: 2px;
padding: 0 1px;
}
.wgs-empty {
color: #555577;
font-size: 13px;
text-align: center;
padding: 40px 20px;
}
.wgs-rank-exact .wgs-breadcrumb-seg { color: #a0ffa0; }
.wgs-rank-exact .wgs-match { background: #1a3a00; color: #a0ffa0; }
`;
shadow.appendChild(styleEl);
// Flash styles injected into the HOST page (not shadow) so they apply to page elements
let lastFlashTarget = null;
// ── BUILD UI ──────────────────────────────────────────────────────────────────
// FAB
const fab = document.createElement('button');
fab.id = 'wgs-fab';
fab.title = 'PageGrep (Alt+G)';
fab.textContent = '🔍';
shadow.appendChild(fab);
// Spotlight overlay
const overlay = document.createElement('div');
overlay.id = 'wgs-overlay';
overlay.innerHTML = `
<div id="wgs-spotlight">
<span id="wgs-search-icon">🔍</span>
<input id="wgs-input" type="text" placeholder="search this page…" autocomplete="off" spellcheck="false"/>
<span id="wgs-kbd">Alt+G</span>
</div>
`;
shadow.appendChild(overlay);
// Results panel
const panel = document.createElement('div');
panel.id = 'wgs-panel';
panel.innerHTML = `
<div id="wgs-panel-header">
<span id="wgs-panel-title">Results</span>
<button id="wgs-close">✕</button>
</div>
<div id="wgs-results"></div>
`;
shadow.appendChild(panel);
// ── THEME SAMPLING ────────────────────────────────────────────────────────────
function sampleSiteTheme() {
const contentRoot = document.querySelector(CONTENT_SELECTOR) || document.body;
const docStyle = getComputedStyle(document.documentElement);
function cssVar(...names) {
for (const name of names) {
const val = docStyle.getPropertyValue(name).trim();
if (val) return val;
}
return null;
}
function usable(c) {
return c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent';
}
const vpBg = cssVar('--vp-c-bg', '--vp-c-bg-soft', '--c-bg');
const vpText = cssVar('--vp-c-text-1', '--vp-c-text', '--c-text');
const vpLink = cssVar('--vp-c-brand-1', '--vp-c-brand', '--c-brand');
const vpFont = cssVar('--vp-font-family-base');
const dsBg = cssVar('--ifm-background-color', '--ifm-background-surface-color');
const dsText = cssVar('--ifm-font-color-base');
const dsLink = cssVar('--ifm-link-color', '--ifm-color-primary');
const dsFont = cssVar('--ifm-font-size-base');
const gbBg = cssVar('--color-base', '--background');
const gbText = cssVar('--color-text-default', '--text-default');
const gbLink = cssVar('--color-link', '--link');
const mkBg = cssVar('--md-default-bg-color');
const mkText = cssVar('--md-default-fg-color');
const mkLink = cssVar('--md-accent-fg-color', '--md-primary-fg-color');
function getBgFromEl(el) {
let node = el;
while (node && node !== document.documentElement) {
const bg = getComputedStyle(node).backgroundColor;
if (usable(bg)) return bg;
node = node.parentElement;
}
return getComputedStyle(document.documentElement).backgroundColor;
}
function getLinkColorNear(el) {
for (const a of (el ? el.querySelectorAll('a') : [])) {
const c = getComputedStyle(a).color;
if (usable(c)) return c;
}
let parent = el ? el.parentElement : null;
while (parent && parent !== document.body) {
for (const a of parent.querySelectorAll('a')) {
if (a.closest('#pagegrep-host, nav, header, aside, [role="navigation"]')) continue;
const c = getComputedStyle(a).color;
if (usable(c)) return c;
}
parent = parent.parentElement;
}
return null;
}
const textEl = contentRoot.querySelector('li, p');
const elemBg = getBgFromEl(contentRoot);
const elemText = textEl ? getComputedStyle(textEl).color : null;
const elemLink = getLinkColorNear(textEl || contentRoot);
const elemFont = textEl ? getComputedStyle(textEl).fontSize : null;
const finalBg = vpBg || dsBg || gbBg || mkBg || (usable(elemBg) ? elemBg : null);
const finalText = vpText || dsText || gbText || mkText || (usable(elemText) ? elemText : null);
const finalLink = vpLink || dsLink || gbLink || mkLink || (usable(elemLink) ? elemLink : null);
const finalFont = vpFont || dsFont || elemFont;
// Apply to the shadow root's :host via the panel (vars cascade inside shadow)
const root = shadow.querySelector('#wgs-panel');
setTimeout(() => {
if (finalBg) { root.style.setProperty('--wgs-site-bg', finalBg); overlay.querySelector('#wgs-spotlight').style.setProperty('--wgs-site-bg', finalBg); }
if (finalText) { root.style.setProperty('--wgs-site-text', finalText); overlay.querySelector('#wgs-input').style.color = finalText; }
if (finalLink) root.style.setProperty('--wgs-site-link', finalLink);
if (finalFont) root.style.setProperty('--wgs-site-fontsize', finalFont);
}, 0);
}
sampleSiteTheme();
setTimeout(sampleSiteTheme, 800);
new MutationObserver(sampleSiteTheme).observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'data-theme', 'data-dark-mode'] });
const input = shadow.getElementById('wgs-input');
const closeBtn = shadow.getElementById('wgs-close');
const results = shadow.getElementById('wgs-results');
const titleEl = shadow.getElementById('wgs-panel-title');
// ── INDEX ─────────────────────────────────────────────────────────────────────
// Each entry:
// text – full visible text of the line element (whole <li>, <p>, etc.)
// searchText – lowercased: all ancestor heading texts + line text (for matching)
// headings – [{level, text, id, node}, …] outermost→innermost
let index = null;
const LINE_TAGS = new Set(['LI','P','DT','DD','TD','TH','BLOCKQUOTE','FIGCAPTION']);
const HEADING_TAGS = new Set(['H1','H2','H3','H4','H5','H6']);
function headingLevel(tag) { return parseInt(tag[1], 10); }
function buildIndex() {
if (index) return;
index = [];
const root = document.querySelector(CONTENT_SELECTOR) || document.body;
// We walk the DOM in tree order using querySelectorAll (DOM order guaranteed).
// We maintain a heading stack as we encounter headings.
// For LINE_TAGS we emit an index entry — but skip ones that only contain
// other LINE_TAG descendants (avoid double-indexing outer <li> of nested lists).
const headingStack = []; // [{level, text, id, node}]
const allEl = root.querySelectorAll('*');
for (const el of allEl) {
const tag = el.tagName;
// ── Update heading stack ──
if (HEADING_TAGS.has(tag)) {
const lv = headingLevel(tag);
while (headingStack.length && headingStack[headingStack.length - 1].level >= lv) {
headingStack.pop();
}
// Strip leading decorator symbols common on wiki sites (▷ ► ★ • etc.)
const rawText = el.textContent.trim();
const cleanText = rawText.replace(/^[\s\u00a0\u2000-\u27bf|>\/-]+/g, '').trim() || rawText;
headingStack.push({ level: lv, text: cleanText, id: el.id || null, node: el });
continue;
}
// ── Emit line entry ──
if (!LINE_TAGS.has(tag)) continue;
// Skip if this element's meaningful text is entirely inside nested line elements
// (e.g. a bare <li> wrapping <ul> with no own text)
let ownText = '';
for (const child of el.childNodes) {
if (child.nodeType === Node.TEXT_NODE) ownText += child.textContent;
}
// If the element contains a nested LINE_TAG and has no meaningful direct text, skip
if (el.querySelector('li,p,dt,dd,td,th,blockquote,figcaption') && ownText.trim().length < 2) {
continue;
}
const text = el.textContent.trim().replace(/\s+/g, ' ');
if (text.length < 2) continue;
// snapshot: one heading per level max, sorted ascending
const seenLevels = new Map();
for (const h of headingStack) seenLevels.set(h.level, h);
const headings = [...seenLevels.values()].sort((a, b) => a.level - b.level).map(h => ({ ...h }));
const headingText = headings.map(h => h.text).join(' ');
// Also include href values so queries like "codeberg bypass" can match
// link URLs even when the URL text isn't part of the visible line text
const hrefText = Array.from(el.querySelectorAll('a[href]'))
.map(a => a.getAttribute('href'))
.join(' ');
const searchText = (headingText + ' ' + text + ' ' + hrefText).toLowerCase();
index.push({ text, searchText, headings, el });
}
}
// ── SCORING ───────────────────────────────────────────────────────────────────
// 3 – exact phrase in line text
// 2 – exact phrase in headings text
// Scoring rationale:
// Primary: how many query words match as full words (0..N), normalised to 0..4
// so even one full-word match beats all-substring matches.
// Secondary: phrase/order/unordered tier (0..3), same as before.
// Final: primary * 4 + secondary → range 0..19 (no overlap between bands)
//
// Example with query "ha ai":
// "AI Studio" → ai=full, ha=partial → 1 full-word → primary=2 → beats
// "Chat - Decentralized" → ai=partial, ha=partial → 0 full-words → primary=0
function countFullWords(searchL, words) {
let count = 0;
for (const w of words) {
const escaped = w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp('(?<![a-z0-9])' + escaped + '(?![a-z0-9])', 'i');
if (re.test(searchL)) count++;
}
return count;
}
function score(entry, words, phrase) {
const lineL = entry.text.toLowerCase();
const searchL = entry.searchText;
// Primary: full-word match count, scaled to 0-4
// (0 full words=0, 1..N-1 partial=2, all N=4 — gives clear bands)
const fw = countFullWords(searchL, words);
const primary = fw === 0 ? 0 : fw === words.length ? 4 : 2;
// Secondary: phrase / order / unordered (0-3)
let secondary;
if (lineL.includes(phrase)) secondary = 3;
else if (searchL.includes(phrase)) secondary = 2;
else {
let pos = 0, inOrder = true;
for (const w of words) {
const idx = searchL.indexOf(w, pos);
if (idx === -1) { inOrder = false; break; }
pos = idx + w.length;
}
secondary = inOrder ? 1 : 0;
}
return primary * 4 + secondary;
}
// ── HIGHLIGHT ─────────────────────────────────────────────────────────────────
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function highlightText(text, words) {
const safe = text.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
if (!words.length) return safe;
const pat = new RegExp(`(${words.map(escapeRe).join('|')})`, 'gi');
return safe.replace(pat, '<span class="wgs-match">$1</span>');
}
// ── SEARCH ────────────────────────────────────────────────────────────────────
function doSearch() {
const query = input.value.trim();
if (!query) return;
buildIndex();
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
const phrase = words.join(' ');
let matches = [];
for (const entry of index) {
if (words.every(w => entry.searchText.includes(w))) {
matches.push({ entry, sc: score(entry, words, phrase) });
}
}
matches.sort((a, b) => b.sc - a.sc);
const total = matches.length;
matches = matches.slice(0, MAX_RESULTS);
titleEl.textContent = total === 0
? `No results — "${query}"`
: total > matches.length
? `top ${matches.length} of ${total} — "${query}"`
: `${total} result${total > 1 ? 's' : ''} — "${query}"`;
results.innerHTML = '';
if (!matches.length) {
results.innerHTML = '<div class="wgs-empty">No matching lines found.</div>';
openPanel();
return;
}
const frag = document.createDocumentFragment();
for (const { entry, sc } of matches) {
const div = document.createElement('div');
div.className = 'wgs-result' + (sc >= 16 ? ' wgs-rank-exact' : '');
// ── Breadcrumb ──
const bc = document.createElement('span');
bc.className = 'wgs-breadcrumb';
const heads = entry.headings.length ? entry.headings : [{ text: '(top)', id: null, node: null }];
heads.forEach((h, i) => {
if (i > 0) {
const sep = document.createElement('span');
sep.className = 'wgs-breadcrumb-sep';
sep.textContent = '/';
bc.appendChild(sep);
}
const seg = document.createElement('a');
seg.className = 'wgs-breadcrumb-seg';
seg.href = h.id ? `#${h.id}` : '#';
seg.textContent = h.text;
if (h.node) {
seg.addEventListener('click', (e) => {
e.preventDefault();
h.node.scrollIntoView({ behavior: 'smooth', block: 'start' });
closePanel();
});
}
bc.appendChild(seg);
});
// ── Line ──
const lineDiv = document.createElement('div');
lineDiv.className = 'wgs-line';
// Clone the original element to preserve links, then highlight text nodes
const lineWords = words.filter(w => entry.text.toLowerCase().includes(w));
if (entry.el) {
const clone = entry.el.cloneNode(true);
// Strip leading decorator symbols/bullets from the clone's text nodes
// Walk all text nodes and clean leading decorators from the first non-empty one
(function stripDecorators(node) {
const tw = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, null, false);
let tn;
while ((tn = tw.nextNode())) {
const trimmed = tn.textContent.replace(/^[\s\u00a0\u25b6\u25b7\u25ba\u25b8\u25b9\u25bc\u25bd\u25c6\u25c7\u2022\u00b7\u002a\u002d\u25cf]+/g, '');
if (tn.textContent.trim().length > 0) {
tn.textContent = trimmed;
break;
}
}
})(clone);
// Open all links in new tab and stop them from closing the panel accidentally
for (const a of clone.querySelectorAll('a')) {
a.target = '_blank';
a.rel = 'noopener noreferrer';
}
// Walk text nodes and wrap matched words in highlight spans
if (lineWords.length) {
const pat = new RegExp('(' + lineWords.map(w => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|') + ')', 'gi');
const textWalker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null, false);
const textNodes = [];
let tn = textWalker.nextNode();
while (tn) { textNodes.push(tn); tn = textWalker.nextNode(); }
for (const tn of textNodes) {
if (!pat.test(tn.textContent)) continue;
pat.lastIndex = 0;
const frag2 = document.createDocumentFragment();
let last = 0, m;
while ((m = pat.exec(tn.textContent)) !== null) {
if (m.index > last) frag2.appendChild(document.createTextNode(tn.textContent.slice(last, m.index)));
const mark = document.createElement('span');
mark.className = 'wgs-match';
mark.textContent = m[1];
frag2.appendChild(mark);
last = m.index + m[1].length;
}
if (last < tn.textContent.length) frag2.appendChild(document.createTextNode(tn.textContent.slice(last)));
tn.parentNode.replaceChild(frag2, tn);
}
}
lineDiv.appendChild(clone);
} else {
// Fallback: plain text with highlights
const span = document.createElement('span');
span.innerHTML = highlightText(entry.text, lineWords);
lineDiv.appendChild(span);
}
div.appendChild(bc);
div.appendChild(lineDiv);
// Click anywhere on the result row → navigate + flash
div.style.cursor = 'pointer';
div.addEventListener('click', (e) => {
// Don't intercept breadcrumb link clicks (they have their own handler)
if (e.target.closest('.wgs-breadcrumb-seg')) return;
navigateToResult(entry);
});
frag.appendChild(div);
}
results.appendChild(frag);
selectedIdx = -1;
openPanel();
}
// ── PANEL ─────────────────────────────────────────────────────────────────────
let flashTimeout = null;
function navigateToResult(entry) {
const target = entry.el;
const headings = entry.headings;
// Scroll directly to the matched element, centered in the viewport
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Flash: directly set inline transition on the element
if (target) {
clearTimeout(flashTimeout);
// Clear any previous flash on other elements
if (lastFlashTarget && lastFlashTarget !== target) {
lastFlashTarget.style.background = '';
lastFlashTarget.style.boxShadow = '';
lastFlashTarget.style.transition = '';
lastFlashTarget.style.borderRadius = '';
}
lastFlashTarget = target;
// Apply highlight immediately
target.style.transition = 'none';
target.style.background = 'rgba(255,220,50,.22)';
target.style.boxShadow = 'inset 3px 0 0 rgba(255,200,0,.9)';
target.style.borderRadius = '3px';
// After 21s start fading out over 9s
flashTimeout = setTimeout(() => {
target.style.transition = 'background 9s ease-out, box-shadow 9s ease-out';
target.style.background = 'transparent';
target.style.boxShadow = 'none';
}, 21000);
// Clean up after full 30s
setTimeout(() => {
if (lastFlashTarget === target) {
target.style.background = '';
target.style.boxShadow = '';
target.style.transition = '';
target.style.borderRadius = '';
lastFlashTarget = null;
}
}, 31000);
}
}
function openPanel() { panel.classList.add('open'); }
function closePanel() { panel.classList.remove('open'); }
// ── SPOTLIGHT OPEN/CLOSE ──────────────────────────────────────────────────────
function openSpotlight() {
overlay.classList.add('open');
fab.classList.add('active');
requestAnimationFrame(() => input.focus());
}
function closeSpotlight() {
overlay.classList.remove('open');
fab.classList.remove('active');
}
// ── EVENTS ────────────────────────────────────────────────────────────────────
let searchDebounce = null;
function scheduleSearch() {
clearTimeout(searchDebounce);
searchDebounce = setTimeout(doSearch, 220);
}
// FAB click: reopen panel if results exist, else open spotlight
fab.addEventListener('click', () => {
if (overlay.classList.contains('open')) {
closeSpotlight();
} else if (!panel.classList.contains('open') && input.value.trim() && results.children.length) {
openPanel();
openSpotlight();
} else {
openSpotlight();
}
});
// Clicking backdrop (not spotlight box) closes it
overlay.addEventListener('click', (e) => {
if (e.target === overlay) { closeSpotlight(); closePanel(); }
});
input.addEventListener('input', () => {
if (input.value.trim()) scheduleSearch();
else closePanel();
});
// Click input: reopen panel if results already exist
input.addEventListener('click', () => {
if (input.value.trim() && !panel.classList.contains('open') && results.children.length) {
openPanel();
}
});
let selectedIdx = -1;
function getResultDivs() {
return Array.from(results.querySelectorAll('.wgs-result'));
}
function selectResult(idx) {
const divs = getResultDivs();
if (!divs.length) return;
// Clamp
idx = Math.max(0, Math.min(divs.length - 1, idx));
// Clear previous
divs.forEach(d => d.style.outline = '');
selectedIdx = idx;
divs[idx].style.outline = '2px solid #7c83fd';
divs[idx].scrollIntoView({ block: 'nearest' });
}
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchDebounce);
if (selectedIdx >= 0) {
const divs = getResultDivs();
if (divs[selectedIdx]) divs[selectedIdx].click();
} else {
doSearch();
}
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!panel.classList.contains('open')) return;
selectResult(selectedIdx < 0 ? 0 : selectedIdx + 1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (!panel.classList.contains('open')) return;
selectResult(selectedIdx <= 0 ? 0 : selectedIdx - 1);
}
if (e.key === 'Escape') {
if (panel.classList.contains('open')) closePanel();
else closeSpotlight();
}
});
closeBtn.addEventListener('click', () => { closePanel(); });
// Alt+G global hotkey
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key === 'g') {
e.preventDefault();
if (overlay.classList.contains('open')) closeSpotlight();
else openSpotlight();
}
});
// Close panel when clicking outside shadow host on the page
document.addEventListener('click', (e) => {
if (panel.classList.contains('open') && !host.contains(e.target)) {
closePanel();
}
});
// ── FAB DRAG ──────────────────────────────────────────────────────────────────
const FAB_STORE_KEY = 'pagegrep-fab-pos-' + location.hostname;
function saveFabPos(x, y) {
try { localStorage.setItem(FAB_STORE_KEY, JSON.stringify({ x, y })); } catch(_) {}
}
function loadFabPos() {
try { return JSON.parse(localStorage.getItem(FAB_STORE_KEY)); } catch(_) { return null; }
}
function applyFabPos(x, y) {
// Clamp to viewport
const s = 42; // FAB size
x = Math.max(0, Math.min(window.innerWidth - s, x));
y = Math.max(0, Math.min(window.innerHeight - s, y));
fab.style.right = 'auto';
fab.style.bottom = 'auto';
fab.style.left = x + 'px';
fab.style.top = y + 'px';
}
// Restore saved position
const savedPos = loadFabPos();
if (savedPos) applyFabPos(savedPos.x, savedPos.y);
let dragState = null;
fab.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
const rect = fab.getBoundingClientRect();
dragState = {
startX: e.clientX,
startY: e.clientY,
fabX: rect.left,
fabY: rect.top,
moved: false
};
fab.setPointerCapture(e.pointerId);
e.preventDefault();
});
fab.addEventListener('pointermove', (e) => {
if (!dragState) return;
const dx = e.clientX - dragState.startX;
const dy = e.clientY - dragState.startY;
if (!dragState.moved && Math.hypot(dx, dy) < 4) return;
dragState.moved = true;
requestAnimationFrame(() => {
if (!dragState) return;
applyFabPos(dragState.fabX + (e.clientX - dragState.startX),
dragState.fabY + (e.clientY - dragState.startY));
});
});
fab.addEventListener('pointerup', (e) => {
if (!dragState) return;
if (dragState.moved) {
const rect = fab.getBoundingClientRect();
saveFabPos(rect.left, rect.top);
}
dragState = null;
});
})();