// ==UserScript== // @name 高级页面查找 // @namespace local.advanced.find.pro.v3.fixed // @version 3.1.0 // @description 替代 Ctrl+F,支持布尔表达式、括号、结果侧栏、CSS 范围、搜索历史、同域 iframe 搜索;增量扫描 DOM、大页面提速、iframe 缓存、可折叠高级选项、拖拽面板与位置记忆。修复初始化失败、页面乱跳、iframe 变动失效、语法损坏等问题。 // @match *://*/* // @grant GM_registerMenuCommand // @run-at document-start // @license AI写的,随便用随便改 // ==/UserScript== (function () { 'use strict'; /********************************************************* * 常量 *********************************************************/ const STORAGE_KEY = '__tm_adv_find_v3_1_0_config__'; const HISTORY_KEY = '__tm_adv_find_v3_1_0_history__'; const PANEL_ID = 'tm-adv-find-v3-panel'; const STYLE_ID = 'tm-adv-find-v3-style'; const MARK_CLASS = 'tm-adv-find-v3-mark'; const CURRENT_CLASS = 'tm-adv-find-v3-current'; const RESULT_ITEM_CLASS = 'tm-adv-find-v3-result-item'; const MAX_HISTORY = 20; const CONTAINER_UID_PROP = '__tm_adv_find_v310_uid__'; const DEFAULT_COLORS = [ '#fff59d', '#ffccbc', '#b3e5fc', '#c8e6c9', '#e1bee7', '#f8bbd0', '#dcedc8', '#ffe082', '#b2dfdb', '#d1c4e9', '#ffecb3', '#c5cae9' ]; const BASE_EXCLUDE_TAGS = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT', 'OPTION', 'BUTTON' ]); const BLOCKISH_SELECTOR = [ 'p', 'li', 'td', 'th', 'dt', 'dd', 'pre', 'code', 'blockquote', 'section', 'article', 'main', 'aside', 'header', 'footer', 'nav', 'div', 'span', 'label', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ].join(','); /********************************************************* * 状态 *********************************************************/ const state = { panel: null, inputQuery: null, inputScope: null, resultInfo: null, colorsWrap: null, historyWrap: null, sidebarWrap: null, advancedWrap: null, advancedToggle: null, progressInfo: null, chkHighlight: null, chkCaseSensitive: null, chkWholeWord: null, chkAutoRefresh: null, chkVisibleOnly: null, chkIncludeCode: null, btnSearch: null, btnPrev: null, btnNext: null, btnClear: null, btnClose: null, query: '', scopeSelector: '', panelVisible: false, currentIndex: -1, currentVirtualParent: null, matches: [], observers: [], frameLoadHandlers: [], docsSnapshot: [], isInternalUpdating: false, currentParsed: null, searchJobId: 0, uidSeed: 1, visibleCache: new WeakMap(), dirtyContainers: new Map(), frameCache: { dirty: true, stamp: '', docs: [] }, options: { highlight: true, caseSensitive: false, wholeWord: false, autoRefresh: true, visibleOnly: true, includeCode: false, termColorMap: {}, scopeSelector: '', advancedCollapsed: false, panelPos: { top: 16, left: null } }, containerRecords: new Map() }; /********************************************************* * 存储 *********************************************************/ function loadConfig() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; const cfg = JSON.parse(raw); if (!cfg || typeof cfg !== 'object') return; Object.assign(state.options, cfg); if (!state.options.termColorMap || typeof state.options.termColorMap !== 'object') { state.options.termColorMap = {}; } if (!state.options.panelPos || typeof state.options.panelPos !== 'object') { state.options.panelPos = { top: 16, left: null }; } } catch (e) {} } function saveConfig() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state.options)); } catch (e) {} } function loadHistory() { try { const raw = localStorage.getItem(HISTORY_KEY); const arr = JSON.parse(raw || '[]'); return Array.isArray(arr) ? arr : []; } catch (e) { return []; } } function saveHistory(query, scopeSelector) { const q = String(query || '').trim(); const s = String(scopeSelector || '').trim(); if (!q) return; const key = JSON.stringify({ q, s }); let arr = loadHistory().filter(x => JSON.stringify(x) !== key); arr.unshift({ q, s }); arr = arr.slice(0, MAX_HISTORY); try { localStorage.setItem(HISTORY_KEY, JSON.stringify(arr)); } catch (e) {} } /********************************************************* * 工具 *********************************************************/ function debounce(fn, delay = 300) { let t = null; return function (...args) { clearTimeout(t); t = setTimeout(() => fn.apply(this, args), delay); }; } function runInternalUpdate(fn) { state.isInternalUpdating = true; try { fn(); } finally { setTimeout(() => { state.isInternalUpdating = false; }, 0); } } function escapeRegExp(str) { return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function escapeHtml(str) { return String(str).replace(/[&<>"']/g, s => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[s])); } function unique(arr) { return Array.from(new Set(arr)); } function normalizeHexColor(color) { try { if (/^#[0-9a-fA-F]{6}$/.test(color)) return color; if (!document.body) return '#fff59d'; const el = document.createElement('div'); el.style.color = color; document.body.appendChild(el); const rgb = getComputedStyle(el).color; el.remove(); const m = rgb.match(/\d+/g); if (!m || m.length < 3) return '#fff59d'; const [r, g, b] = m.map(Number); return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); } catch (e) { return '#fff59d'; } } function getDefaultColor(i) { return DEFAULT_COLORS[i % DEFAULT_COLORS.length]; } function makeSnippet(text, start, end, radius = 30) { const s = Math.max(0, start - radius); const e = Math.min(text.length, end + radius); let snippet = text.slice(s, e).replace(/\s+/g, ' ').trim(); if (s > 0) snippet = '…' + snippet; if (e < text.length) snippet += '…'; return snippet; } function yieldToUI() { return new Promise(resolve => { if (typeof requestIdleCallback === 'function') { requestIdleCallback(() => resolve(), { timeout: 30 }); } else { setTimeout(resolve, 0); } }); } function getElementUid(el) { if (!el) return ''; if (!el[CONTAINER_UID_PROP]) { Object.defineProperty(el, CONTAINER_UID_PROP, { value: 'c' + (state.uidSeed++), enumerable: false, configurable: true }); } return el[CONTAINER_UID_PROP]; } function getExcludeTags() { const set = new Set(BASE_EXCLUDE_TAGS); if (!state.options.includeCode) { set.add('CODE'); set.add('PRE'); } return set; } function updateProgress(text) { if (state.progressInfo) state.progressInfo.textContent = text || ''; } function updateResultInfo() { const total = state.matches.length; const curr = total ? state.currentIndex + 1 : 0; if (state.resultInfo) state.resultInfo.textContent = `${curr} / ${total}`; } function ensureTermColors(terms) { terms.forEach((t, i) => { if (!state.options.termColorMap[t]) { state.options.termColorMap[t] = getDefaultColor(i); } }); saveConfig(); } function isSameOriginFrame(frame) { try { return !!frame.contentDocument; } catch (e) { return false; } } function getFramePath(doc) { if (doc === document) return '主文档'; try { const allDocs = collectSearchDocumentsCached(); const idx = allDocs.indexOf(doc); return idx >= 0 ? `iframe #${idx}` : 'iframe'; } catch (e) { return 'iframe'; } } function getSearchRootsForDoc(doc, selector) { if (!doc || !doc.body) return []; if (!selector || !selector.trim()) return [doc.body]; try { const list = Array.from(doc.querySelectorAll(selector)); return list.length ? list : []; } catch (e) { return []; } } function isAsciiWord(str) { return /^[A-Za-z0-9_]+$/.test(str); } function hasCJK(str) { return /[\u3400-\u9FFF]/.test(str); } function isBoundaryCharForTerm(term, ch) { if (!ch) return false; if (hasCJK(term)) return /[\u3400-\u9FFF]/.test(ch); if (isAsciiWord(term)) return /[A-Za-z0-9_]/.test(ch); return /[A-Za-z0-9_\u3400-\u9FFF]/.test(ch); } function isWholeWordMatch(text, start, end, term) { const prev = start > 0 ? text[start - 1] : ''; const next = end < text.length ? text[end] : ''; const prevBlock = isBoundaryCharForTerm(term, prev); const nextBlock = isBoundaryCharForTerm(term, next); return !prevBlock && !nextBlock; } function isElementInViewport(el, margin = 40) { try { if (!el || !el.isConnected) return false; const rect = el.getBoundingClientRect(); const vw = el.ownerDocument.defaultView || window; const h = vw.innerHeight || 0; const w = vw.innerWidth || 0; return ( rect.bottom >= margin && rect.right >= 0 && rect.top <= h - margin && rect.left <= w ); } catch (e) { return false; } } function scrollElementIntoViewIfNeeded(el, opts = {}) { try { if (!el || !el.isConnected) return; if (isElementInViewport(el, 50)) return; el.scrollIntoView({ behavior: opts.behavior || 'smooth', block: opts.block || 'center', inline: opts.inline || 'nearest' }); } catch (e) {} } function safeFocus(el) { if (!el) return; try { el.focus({ preventScroll: true }); } catch (e) { try { el.focus(); } catch (e2) {} } } function whenDocumentReady(fn) { if (document.body && document.head) { fn(); return; } const onReady = () => { if (document.body && document.head) { document.removeEventListener('DOMContentLoaded', onReady, true); fn(); } }; document.addEventListener('DOMContentLoaded', onReady, true); const timer = setInterval(() => { if (document.body && document.head) { clearInterval(timer); document.removeEventListener('DOMContentLoaded', onReady, true); fn(); } }, 30); } /********************************************************* * 样式 *********************************************************/ function injectStyleToDoc(doc) { if (!doc || !doc.head) return; if (doc.getElementById(STYLE_ID)) return; const style = doc.createElement('style'); style.id = STYLE_ID; style.textContent = ` .${MARK_CLASS} { transition: box-shadow .15s ease, background-color .15s ease; border-radius: 2px; padding: 0 1px; } .${CURRENT_CLASS} { outline: 2px solid #ff3b30 !important; box-shadow: 0 0 0 3px rgba(255,59,48,.18) !important; } #${PANEL_ID} { position: fixed; top: 16px; right: 16px; width: 520px; max-width: calc(100vw - 20px); max-height: calc(100vh - 20px); overflow: hidden; background: #fff; border: 1px solid #d0d7de; border-radius: 12px; box-shadow: 0 12px 36px rgba(0,0,0,.18); z-index: 2147483647; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: #222; user-select: none; } #${PANEL_ID} * { box-sizing: border-box; } #${PANEL_ID} input, #${PANEL_ID} textarea { user-select: text; } #${PANEL_ID} .tm-head { display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid #eee; font-size:14px; font-weight:600; gap:10px; cursor: move; background: linear-gradient(to bottom, #fff, #fafafa); } #${PANEL_ID} .tm-title { min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #${PANEL_ID} .tm-head-actions { display:flex; align-items:center; gap:6px; flex-shrink:0; } #${PANEL_ID} .tm-body { padding:10px 12px 12px; max-height: calc(100vh - 80px); overflow:auto; } #${PANEL_ID} .tm-row { margin-top:8px; } #${PANEL_ID} .tm-row:first-child { margin-top:0; } #${PANEL_ID} .tm-label { font-size:12px; color:#555; margin-bottom:5px; } #${PANEL_ID} input[type="text"] { width:100%; padding:9px 10px; border:1px solid #cfd8dc; border-radius:8px; outline:none; font-size:13px; } #${PANEL_ID} input[type="text"]:focus { border-color:#409eff; box-shadow:0 0 0 3px rgba(64,158,255,.12); } #${PANEL_ID} .tm-grid { display:grid; grid-template-columns:1fr 1fr 1fr; gap:6px 8px; margin-top:10px; } #${PANEL_ID} .tm-opt { display:flex; align-items:center; gap:6px; font-size:12px; cursor:pointer; user-select:none; } #${PANEL_ID} .tm-actions { display:flex; gap:6px; margin-top:10px; flex-wrap:wrap; align-items:center; } #${PANEL_ID} button { border:1px solid #cfd8dc; background:#f8f9fa; color:#222; border-radius:8px; padding:6px 10px; cursor:pointer; font-size:12px; } #${PANEL_ID} button:hover { background:#eef3f8; } #${PANEL_ID} .tm-btn-primary { background:#409eff; color:#fff; border-color:#409eff; } #${PANEL_ID} .tm-btn-primary:hover { background:#2f8ef0; } #${PANEL_ID} .tm-result { margin-left:auto; font-size:12px; color:#444; } #${PANEL_ID} .tm-progress { font-size:11px; color:#777; margin-left:6px; } #${PANEL_ID} .tm-box { margin-top:10px; border:1px solid #f0f0f0; border-radius:8px; padding:8px; background:#fcfcfc; } #${PANEL_ID} .tm-box-title { font-size:12px; color:#444; font-weight:600; margin-bottom:6px; } #${PANEL_ID} .tm-colors { max-height:120px; overflow:auto; } #${PANEL_ID} .tm-history { max-height:100px; overflow:auto; } #${PANEL_ID} .tm-sidebar { max-height:240px; overflow:auto; } #${PANEL_ID} .tm-help { max-height:180px; overflow:auto; font-size:12px; line-height:1.55; color:#555; white-space:normal; } #${PANEL_ID} .tm-color-row { display:flex; align-items:center; gap:8px; margin:4px 0; } #${PANEL_ID} .tm-color-label { flex:1; min-width:0; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #${PANEL_ID} .tm-history-item { padding:5px 6px; border-radius:6px; cursor:pointer; font-size:12px; margin:3px 0; border:1px solid transparent; } #${PANEL_ID} .tm-history-item:hover { background:#f1f6fb; border-color:#d8e7f8; } #${PANEL_ID} .${RESULT_ITEM_CLASS} { padding:6px 8px; border-radius:8px; cursor:pointer; border:1px solid #edf0f2; margin:5px 0; font-size:12px; line-height:1.45; background:#fff; } #${PANEL_ID} .${RESULT_ITEM_CLASS}:hover { background:#f7fbff; border-color:#cfe4ff; } #${PANEL_ID} .${RESULT_ITEM_CLASS}.active { background:#eef6ff; border-color:#8ab8ff; } #${PANEL_ID} .tm-muted { color:#666; font-size:12px; } #${PANEL_ID} .tm-mini { color:#888; font-size:11px; margin-top:2px; } #${PANEL_ID} .tm-snippet { color:#222; word-break:break-word; } #${PANEL_ID} .tm-result-meta { color:#777; font-size:11px; margin-top:3px; } #${PANEL_ID} .tm-advanced-toggle { width:100%; display:flex; align-items:center; justify-content:space-between; padding:8px 10px; background:#f7f9fb; border:1px solid #e8edf2; border-radius:8px; margin-top:10px; font-size:12px; } #${PANEL_ID} .tm-advanced-wrap.collapsed { display:none; } #${PANEL_ID} .tm-close-btn { min-width:34px; } .tm-dragging, .tm-dragging * { cursor: move !important; } `; doc.head.appendChild(style); } /********************************************************* * iframe 缓存 *********************************************************/ function getFrameCacheStamp(rootDoc = document) { const parts = []; const seen = new Set(); function walk(doc, path) { if (!doc || seen.has(doc)) return; seen.add(doc); let frames = []; try { frames = Array.from(doc.querySelectorAll('iframe, frame')); } catch (e) {} parts.push(`${path}:${frames.length}`); frames.forEach((frame, i) => { let same = 0; let subDoc = null; try { if (frame.contentDocument) { same = 1; subDoc = frame.contentDocument; } } catch (e) {} parts.push(`${path}.${i}:${same}:${frame.src || ''}`); if (same && subDoc) walk(subDoc, `${path}.${i}`); }); } walk(rootDoc, 'r'); return parts.join('|'); } function collectSearchDocumentsCached(rootDoc = document) { const stamp = getFrameCacheStamp(rootDoc); if (!state.frameCache.dirty && state.frameCache.stamp === stamp && state.frameCache.docs.length) { return state.frameCache.docs.slice(); } const out = []; const seen = new Set(); function walk(doc) { try { if (!doc || seen.has(doc)) return; seen.add(doc); out.push(doc); const frames = doc.querySelectorAll('iframe, frame'); for (const frame of frames) { if (!isSameOriginFrame(frame)) continue; const subDoc = frame.contentDocument; if (subDoc) walk(subDoc); } } catch (e) {} } walk(rootDoc); state.frameCache.docs = out; state.frameCache.stamp = stamp; state.frameCache.dirty = false; return out.slice(); } function markFrameCacheDirty() { state.frameCache.dirty = true; } /********************************************************* * Tokenizer / Parser *********************************************************/ function tokenizeQuery(query) { const tokens = []; const re = /kw:"[^"]*"|"[^"]*"|\(|\)|\bAND\b|\bOR\b|\bNOT\b|[^\s()]+/gi; let m; while ((m = re.exec(query)) !== null) { const tk = m[0].trim(); if (!tk) continue; // 跳过空引号(如 "" 或 kw:""),避免产生空关键词污染解析与颜色配置 if (/^(?:kw:)?""$/.test(tk)) continue; tokens.push(tk); } return tokens; } function isOperator(token) { return /^(AND|OR|NOT)$/i.test(token); } function precedence(op) { const u = op.toUpperCase(); if (u === 'NOT') return 3; if (u === 'AND') return 2; if (u === 'OR') return 1; return 0; } function stripOuterQuotes(s) { if (/^".*"$/.test(s)) return s.slice(1, -1); return s; } function buildTypeRegex(typeName, options) { const map = { num: /-?\d+(?:\.\d+)?/g, int: /-?\d+\b/g, float: /-?\d+\.\d+\b/g, en: /[A-Za-z]+/g, enword: /\b[A-Za-z]+\b/g, zh: /[\u4e00-\u9fff]+/g, upper: /\b[A-Z]+\b/g, lower: /\b[a-z]+\b/g, email: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, url: /\bhttps?:\/\/[^\s<>"']+[^\s<>"'.,;:!?,。;:!?]/g, date: /\b\d{4}[-/]\d{1,2}[-/]\d{1,2}\b/g, percent: /-?\d+(?:\.\d+)?%/g, money: /(?:¥|¥|\$|€|£)\s?-?\d+(?:,\d{3})*(?:\.\d+)?|\b-?\d+(?:,\d{3})*(?:\.\d+)?\s?(?:元|美元|usd|cny|rmb|eur)\b/gi }; const src = map[typeName]; if (!src) return null; let flags = src.flags; if (!options.caseSensitive && !flags.includes('i')) flags += 'i'; if (options.caseSensitive) flags = flags.replace(/i/g, ''); flags = unique(flags.split('')).join(''); return new RegExp(src.source, flags); } function buildNumberConditionMatcher(expr) { expr = expr.trim(); const range = expr.match(/^(-?\d+(?:\.\d+)?)\.\.(-?\d+(?:\.\d+)?)$/); if (range) { const a = parseFloat(range[1]); const b = parseFloat(range[2]); const min = Math.min(a, b); const max = Math.max(a, b); return function (text) { const out = []; const re = /-?\d+(?:\.\d+)?/g; let m; while ((m = re.exec(text)) !== null) { const v = parseFloat(m[0]); if (v >= min && v <= max) { out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } } return out; }; } const cmp = expr.match(/^(>=|<=|>|<|=)(-?\d+(?:\.\d+)?)$/); if (cmp) { const op = cmp[1]; const n = parseFloat(cmp[2]); return function (text) { const out = []; const re = /-?\d+(?:\.\d+)?/g; let m; while ((m = re.exec(text)) !== null) { const v = parseFloat(m[0]); let ok = false; if (op === '>') ok = v > n; if (op === '>=') ok = v >= n; if (op === '<') ok = v < n; if (op === '<=') ok = v <= n; if (op === '=') ok = v === n; if (ok) out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } return out; }; } return null; } function getSubstringMatches(text, keyword, options) { const out = []; if (!keyword) return out; const sourceText = options.caseSensitive ? text : text.toLowerCase(); const sourceKey = options.caseSensitive ? keyword : keyword.toLowerCase(); let from = 0; while (from <= sourceText.length) { const idx = sourceText.indexOf(sourceKey, from); if (idx < 0) break; const matchLen = sourceKey.length; const end = idx + matchLen; if (!options.wholeWord || isWholeWordMatch(text, idx, end, keyword)) { out.push({ start: idx, end, text: text.slice(idx, end) }); } from = idx + Math.max(1, matchLen); } return out; } function buildKeywordMatcher(keyword, options) { return { test(text) { return getSubstringMatches(text, keyword, options).length > 0; }, getMatches(text) { return getSubstringMatches(text, keyword, options); } }; } function makeRegexGetter(regex) { return function (text) { const out = []; regex.lastIndex = 0; let m; while ((m = regex.exec(text)) !== null) { if (!m[0]) { regex.lastIndex++; continue; } out.push({ start: m.index, end: m.index + m[0].length, text: m[0] }); } return out; }; } function makeRegexTester(regex) { return function (text) { regex.lastIndex = 0; return regex.test(text); }; } function buildTerm(token, options) { if (!token) return null; if (/^kw:/i.test(token)) { const body = stripOuterQuotes(token.replace(/^kw:/i, '')); const matcher = buildKeywordMatcher(body, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } if (/^num:/i.test(token)) { const body = token.replace(/^num:/i, ''); const fn = buildNumberConditionMatcher(body); if (!fn) throw new Error(`非法数字条件:${body}`); return { raw: token, colorKey: token, test(text) { return fn(text).length > 0; }, getMatches: fn }; } if (/^type:/i.test(token)) { const body = token.replace(/^type:/i, '').toLowerCase(); const regex = buildTypeRegex(body, options); if (!regex) throw new Error(`未知类型:${body}`); return { raw: token, colorKey: token, test: makeRegexTester(regex), getMatches: makeRegexGetter(regex) }; } if (/^re:/i.test(token)) { const body = token.replace(/^re:/i, ''); try { const regex = new RegExp(body, options.caseSensitive ? 'g' : 'gi'); return { raw: token, colorKey: token, test: makeRegexTester(regex), getMatches: makeRegexGetter(regex) }; } catch (e) { throw new Error(`非法正则:${body} (${e.message || '编译失败'})`); } } if (/^(>=|<=|>|<|=)-?\d+(?:\.\d+)?$/.test(token) || /^-?\d+(?:\.\d+)?\.\.-?\d+(?:\.\d+)?$/.test(token)) { const fn = buildNumberConditionMatcher(token); if (!fn) throw new Error(`非法数字条件:${token}`); return { raw: token, colorKey: token, test(text) { return fn(text).length > 0; }, getMatches: fn }; } if (/^".*"$/.test(token)) { const body = stripOuterQuotes(token); const matcher = buildKeywordMatcher(body, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } const knownType = buildTypeRegex(token.toLowerCase(), options); if (knownType) { return { raw: token, colorKey: token, test: makeRegexTester(knownType), getMatches: makeRegexGetter(knownType) }; } const matcher = buildKeywordMatcher(token, options); return { raw: token, colorKey: token, test: matcher.test, getMatches: matcher.getMatches }; } function toRPN(tokens) { const output = []; const ops = []; for (const token of tokens) { if (token === '(') { ops.push(token); } else if (token === ')') { while (ops.length && ops[ops.length - 1] !== '(') { output.push(ops.pop()); } if (!ops.length) throw new Error('括号不匹配'); ops.pop(); } else if (isOperator(token)) { const op = token.toUpperCase(); while ( ops.length && isOperator(ops[ops.length - 1]) && ( (op !== 'NOT' && precedence(ops[ops.length - 1]) >= precedence(op)) || (op === 'NOT' && precedence(ops[ops.length - 1]) > precedence(op)) ) ) { output.push(ops.pop()); } ops.push(op); } else { output.push(token); } } while (ops.length) { const op = ops.pop(); if (op === '(' || op === ')') throw new Error('括号不匹配'); output.push(op); } return output; } function buildASTFromRPN(rpn, options) { const stack = []; const termsForColor = []; for (const token of rpn) { if (isOperator(token)) { const op = token.toUpperCase(); if (op === 'NOT') { const a = stack.pop(); if (!a) throw new Error('NOT 缺少操作数'); stack.push({ type: 'NOT', child: a }); } else { const b = stack.pop(); const a = stack.pop(); if (!a || !b) throw new Error(`${op} 缺少操作数`); stack.push({ type: op, left: a, right: b }); } } else { const term = buildTerm(token, options); stack.push({ type: 'TERM', term }); termsForColor.push(term.colorKey); } } if (stack.length !== 1) throw new Error('表达式不完整'); return { ast: stack[0], termsForColor }; } function parseBooleanQuery(query, options) { const tokens = tokenizeQuery(query); if (!tokens.length) return { ast: null, termsForColor: [] }; const rpn = toRPN(tokens); return buildASTFromRPN(rpn, options); } function evaluateAST(ast, text) { if (!ast) return false; switch (ast.type) { case 'TERM': return ast.term.test(text); case 'NOT': return !evaluateAST(ast.child, text); case 'AND': return evaluateAST(ast.left, text) && evaluateAST(ast.right, text); case 'OR': return evaluateAST(ast.left, text) || evaluateAST(ast.right, text); default: return false; } } function collectPositiveTerms(ast, negated = false, out = []) { if (!ast) return out; if (ast.type === 'TERM') { if (!negated) out.push(ast.term); return out; } if (ast.type === 'NOT') { collectPositiveTerms(ast.child, !negated, out); return out; } if (ast.type === 'AND' || ast.type === 'OR') { collectPositiveTerms(ast.left, negated, out); collectPositiveTerms(ast.right, negated, out); } return out; } function uniqueTermsByKey(arr) { const map = new Map(); for (const t of arr) { if (!map.has(t.colorKey)) map.set(t.colorKey, t); } return Array.from(map.values()); } /********************************************************* * 可见性 / 文本节点 *********************************************************/ function isElementActuallyVisible(el) { if (!el || !el.isConnected) return false; if (state.visibleCache.has(el)) { return state.visibleCache.get(el); } const doc = el.ownerDocument; const win = doc.defaultView; if (!win) { state.visibleCache.set(el, false); return false; } let cur = el; while (cur && cur.nodeType === 1) { if (cur.hidden) { state.visibleCache.set(el, false); return false; } if (cur.getAttribute && cur.getAttribute('aria-hidden') === 'true') { state.visibleCache.set(el, false); return false; } const style = win.getComputedStyle(cur); if ( style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || style.opacity === '0' ) { state.visibleCache.set(el, false); return false; } cur = cur.parentElement; } const ok = el.getClientRects().length > 0; state.visibleCache.set(el, ok); return ok; } function isSearchableTextNode(node) { if (!node || !node.parentElement) return false; const parent = node.parentElement; const excludeTags = getExcludeTags(); if (excludeTags.has(parent.tagName)) return false; if (parent.closest(`#${PANEL_ID}`)) return false; if (parent.closest(`.${MARK_CLASS}`)) return false; if (parent.closest('[contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]')) return false; if (!node.nodeValue || !node.nodeValue.trim()) return false; if (state.options.visibleOnly && !isElementActuallyVisible(parent)) return false; return true; } function collectTextNodes(root) { const doc = root.ownerDocument || root; let walker; try { walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(node) { return isSearchableTextNode(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; } }); } catch (e) { return []; } const nodes = []; let n; while ((n = walker.nextNode())) nodes.push(n); return nodes; } function mergeMatches(matches) { if (!matches.length) return []; matches.sort((a, b) => { if (a.start !== b.start) return a.start - b.start; return b.end - a.end; }); const result = []; let lastEnd = -1; for (const m of matches) { if (m.start >= lastEnd) { result.push(m); lastEnd = m.end; } } return result; } function getMatchesForNodeText(text, ast, positiveTerms) { if (!evaluateAST(ast, text)) return []; const out = []; for (const term of positiveTerms) { const color = state.options.termColorMap[term.colorKey] || '#fff59d'; const arr = term.getMatches(text) || []; for (const m of arr) { out.push({ start: m.start, end: m.end, text: m.text, token: term.colorKey, color }); } } return mergeMatches(out); } /********************************************************* * 记录管理 *********************************************************/ function getRecordKey(el) { return `${getFramePath(el.ownerDocument)}::${getElementUid(el)}`; } function getOrCreateRecord(el, docLabel) { const key = getRecordKey(el); let record = state.containerRecords.get(key); if (!record) { record = { key, el, doc: el.ownerDocument, docLabel: docLabel || getFramePath(el.ownerDocument), marks: [], matches: [] }; state.containerRecords.set(key, record); } return record; } function clearRecord(record) { if (!record) return; const touchedParents = new Set(); for (const mark of record.marks) { try { if (!mark || !mark.isConnected) continue; const parent = mark.parentNode; if (!parent) continue; touchedParents.add(parent); parent.replaceChild(mark.ownerDocument.createTextNode(mark.textContent || ''), mark); } catch (e) {} } touchedParents.forEach(p => { try { p.normalize(); } catch (e) {} }); record.marks = []; record.matches = []; } function clearAllRecords() { for (const record of state.containerRecords.values()) { clearRecord(record); } state.containerRecords.clear(); state.matches = []; state.currentIndex = -1; if (state.currentVirtualParent && state.currentVirtualParent.isConnected) { state.currentVirtualParent.classList.remove(CURRENT_CLASS); } state.currentVirtualParent = null; renderResultsSidebar(); updateResultInfo(); } function removeRecordsInSubtree(container) { const keysToDelete = []; for (const [key, record] of state.containerRecords.entries()) { const el = record.el; if (!el) { keysToDelete.push(key); continue; } if (!el.isConnected) { clearRecord(record); keysToDelete.push(key); continue; } if (container === el || container.contains(el)) { clearRecord(record); keysToDelete.push(key); } } keysToDelete.forEach(k => state.containerRecords.delete(k)); } function cleanupDisconnectedRecords() { const keysToDelete = []; for (const [key, record] of state.containerRecords.entries()) { if (!record.el || !record.el.isConnected) { clearRecord(record); keysToDelete.push(key); } } keysToDelete.forEach(k => state.containerRecords.delete(k)); } function compareRecords(a, b) { if (a.doc !== b.doc) { const docs = state.docsSnapshot; const ai = docs.indexOf(a.doc); const bi = docs.indexOf(b.doc); return ai - bi; } if (a.el === b.el) return 0; if (!a.el || !b.el) return 0; const pos = a.el.compareDocumentPosition(b.el); if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; } function rebuildGlobalMatches() { cleanupDisconnectedRecords(); const orderedRecords = Array.from(state.containerRecords.values()).sort(compareRecords); state.matches = []; for (const record of orderedRecords) { if (record.matches && record.matches.length) { state.matches.push(...record.matches); } } if (!state.matches.length) { state.currentIndex = -1; } else if (state.currentIndex >= state.matches.length) { state.currentIndex = state.matches.length - 1; } } /********************************************************* * 高亮与结果 *********************************************************/ function pushVirtualOnlyResult(parentEl, node, text, docLabel) { const record = getOrCreateRecord(parentEl, docLabel); record.matches.push({ el: null, node, parentEl, doc: node.ownerDocument, docLabel, snippet: makeSnippet(text, 0, Math.min(text.length, 1)), term: 'NOT-only' }); } function highlightTextNode(node, ast, positiveTerms, docLabel) { const text = node.nodeValue; if (!text || !text.trim() || !node.parentElement || !node.parentNode) return; const parentEl = node.parentElement; const record = getOrCreateRecord(parentEl, docLabel); if (!positiveTerms.length) { if (evaluateAST(ast, text)) { pushVirtualOnlyResult(parentEl, node, text, docLabel); } return; } const matches = getMatchesForNodeText(text, ast, positiveTerms); if (!matches.length) return; const frag = node.ownerDocument.createDocumentFragment(); let last = 0; for (const m of matches) { if (m.start > last) { frag.appendChild(node.ownerDocument.createTextNode(text.slice(last, m.start))); } const mark = node.ownerDocument.createElement('mark'); mark.className = MARK_CLASS; mark.dataset.term = m.token; mark.textContent = text.slice(m.start, m.end); if (state.options.highlight) { mark.style.backgroundColor = m.color; mark.style.color = '#000'; mark.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,.08)'; } else { mark.style.backgroundColor = 'rgba(0,0,0,.03)'; mark.style.color = 'inherit'; mark.style.boxShadow = 'inset 0 0 0 1px rgba(0,0,0,.22)'; } frag.appendChild(mark); record.marks.push(mark); record.matches.push({ el: mark, node: null, parentEl, doc: node.ownerDocument, docLabel, snippet: makeSnippet(text, m.start, m.end), term: m.token }); last = m.end; } if (last < text.length) { frag.appendChild(node.ownerDocument.createTextNode(text.slice(last))); } try { node.parentNode.replaceChild(frag, node); } catch (e) {} } // scroll=false 时仅添加高亮 class,不滚动、不抢文本选区焦点 function revealVirtualTextNode(match, scroll = true) { if (!match || !match.node || !match.node.parentElement) return; const parent = match.node.parentElement; if (scroll) { try { scrollElementIntoViewIfNeeded(parent, { behavior: 'smooth', block: 'center', inline: 'nearest' }); } catch (e) {} try { const sel = match.doc.defaultView.getSelection(); if (sel) { sel.removeAllRanges(); const range = match.doc.createRange(); const text = match.node.nodeValue || ''; const end = Math.min(text.length, 1); range.setStart(match.node, 0); range.setEnd(match.node, end); sel.addRange(range); } } catch (e) {} } try { // 清除上一个虚拟结果遗留在父元素上的高亮,避免多个父节点同时带红色轮廓 if (state.currentVirtualParent && state.currentVirtualParent !== parent && state.currentVirtualParent.isConnected) { state.currentVirtualParent.classList.remove(CURRENT_CLASS); } state.currentVirtualParent = parent; parent.classList.add(CURRENT_CLASS); setTimeout(() => { // 仅当该父节点仍是当前高亮的虚拟父节点时才移除,避免清除后续切换上的高亮 if (parent === state.currentVirtualParent && parent.isConnected) { parent.classList.remove(CURRENT_CLASS); if (state.currentVirtualParent === parent) state.currentVirtualParent = null; } }, 1200); } catch (e) {} } function findFrameElementByDocument(targetDoc, rootDoc = document) { let frames = []; try { frames = rootDoc.querySelectorAll('iframe, frame'); } catch (e) { return null; } for (const f of frames) { try { if (f.contentDocument === targetDoc) return f; if (f.contentDocument) { const nested = findFrameElementByDocument(targetDoc, f.contentDocument); if (nested) return nested; } } catch (e) {} } return null; } function renderResultsSidebar() { const wrap = state.sidebarWrap; if (!wrap) return; wrap.innerHTML = ''; if (!state.matches.length) { wrap.innerHTML = `
暂无结果
`; return; } state.matches.forEach((m, i) => { const item = document.createElement('div'); item.className = RESULT_ITEM_CLASS + (i === state.currentIndex ? ' active' : ''); item.innerHTML = `
${escapeHtml(m.snippet)}
${escapeHtml(m.term)} · ${escapeHtml(m.docLabel)}
`; item.addEventListener('click', () => setActiveResult(i)); wrap.appendChild(item); }); } // scroll=false 时只更新高亮/侧栏选中态,不抢占页面滚动焦点 // (用于增量刷新自动重排等场景,避免用户自己滚动后总被拽回焦点) function setActiveResult(index, opts = {}) { const { scroll = true } = opts; if (!state.matches.length) return; index = Math.max(0, Math.min(index, state.matches.length - 1)); runInternalUpdate(() => { const target = state.matches[index]; const targetIsVirtual = !!(target && !target.el); // 若切换到的目标不是虚拟结果,清理上一个虚拟父节点的残留高亮 if (!targetIsVirtual && state.currentVirtualParent && state.currentVirtualParent.isConnected) { state.currentVirtualParent.classList.remove(CURRENT_CLASS); state.currentVirtualParent = null; } state.matches.forEach((x, i) => { if (x.el && x.el.isConnected) { x.el.classList.toggle(CURRENT_CLASS, i === index); } }); const items = state.sidebarWrap ? state.sidebarWrap.querySelectorAll(`.${RESULT_ITEM_CLASS}`) : []; items.forEach((item, i) => item.classList.toggle('active', i === index)); state.currentIndex = index; updateResultInfo(); if (!target) return; // 不滚动时:虚拟结果只加高亮 class,不滚动、不抢文本选区焦点 if (!scroll) { if (targetIsVirtual) revealVirtualTextNode(target, false); return; } try { if (target.doc !== document) { const frameEl = findFrameElementByDocument(target.doc); if (frameEl) scrollElementIntoViewIfNeeded(frameEl, { behavior: 'smooth', block: 'center' }); } } catch (e) {} if (target.el) { try { scrollElementIntoViewIfNeeded(target.el, { behavior: 'smooth', block: 'center', inline: 'nearest' }); } catch (e) {} } else { revealVirtualTextNode(target); } }); } function gotoNext() { if (!state.matches.length) return; setActiveResult((state.currentIndex + 1) % state.matches.length); } function gotoPrev() { if (!state.matches.length) return; setActiveResult((state.currentIndex - 1 + state.matches.length) % state.matches.length); } /********************************************************* * 大页面提速 / 扫描 *********************************************************/ function getNearestRescanContainer(node, root) { if (!node) return root; let el = node.nodeType === 1 ? node : node.parentElement; if (!el) return root; if (el.closest && el.closest(`#${PANEL_ID}`)) return null; if (el.closest && el.closest(`.${MARK_CLASS}`)) { const m = el.closest(`.${MARK_CLASS}`); el = (m && m.parentElement) || el; } if (!root.contains(el)) return root; const found = el.closest(BLOCKISH_SELECTOR); if (found && root.contains(found)) return found; return root; } function collectScopeRootsMap() { const docs = collectSearchDocumentsCached(document); const map = new Map(); docs.forEach(doc => { map.set(doc, getSearchRootsForDoc(doc, state.scopeSelector)); }); return map; } async function scanSingleRoot(root, parsed, positiveTermObjects, jobId, progressLabel) { if (!root || !root.isConnected) return; if (jobId !== state.searchJobId) return; state.visibleCache = new WeakMap(); const docLabel = getFramePath(root.ownerDocument); const textNodes = collectTextNodes(root); const chunkSize = 350; for (let i = 0; i < textNodes.length; i += chunkSize) { if (jobId !== state.searchJobId) return; const chunk = textNodes.slice(i, i + chunkSize); for (const node of chunk) { if (!node.isConnected) continue; highlightTextNode(node, parsed.ast, positiveTermObjects, docLabel); } updateProgress(`${progressLabel} · ${Math.min(i + chunk.length, textNodes.length)}/${textNodes.length}`); await yieldToUI(); } } async function fullSearch(parsed) { const jobId = ++state.searchJobId; state.currentParsed = parsed; state.docsSnapshot = collectSearchDocumentsCached(document); runInternalUpdate(() => { clearAllRecords(); updateProgress('准备搜索…'); }); const positiveTermObjects = uniqueTermsByKey(collectPositiveTerms(parsed.ast)); const docs = state.docsSnapshot; let rootCount = 0; for (const doc of docs) { injectStyleToDoc(doc); const roots = getSearchRootsForDoc(doc, state.scopeSelector); rootCount += roots.length; } let rootIndex = 0; for (const doc of docs) { if (jobId !== state.searchJobId) return; const roots = getSearchRootsForDoc(doc, state.scopeSelector); const docLabel = getFramePath(doc); for (const root of roots) { if (jobId !== state.searchJobId) return; rootIndex++; await scanSingleRoot(root, parsed, positiveTermObjects, jobId, `搜索中 ${rootIndex}/${rootCount} · ${docLabel}`); } } if (jobId !== state.searchJobId) return; rebuildGlobalMatches(); renderResultsSidebar(); if (state.matches.length) { setActiveResult(0); } else { updateResultInfo(); } updateProgress(`完成 · ${state.matches.length} 条结果`); } async function incrementalRefresh() { if (!state.query || !state.currentParsed || !state.options.autoRefresh) return; if (state.isInternalUpdating) return; if (state.frameCache.dirty) { await fullSearch(state.currentParsed); startObservers(); return; } const dirtyEntries = Array.from(state.dirtyContainers.entries()).filter(([, set]) => set && set.size); state.dirtyContainers.clear(); if (!dirtyEntries.length) return; const jobId = ++state.searchJobId; state.docsSnapshot = collectSearchDocumentsCached(document); const positiveTermObjects = uniqueTermsByKey(collectPositiveTerms(state.currentParsed.ast)); const rootsMap = collectScopeRootsMap(); let total = 0; dirtyEntries.forEach(([, set]) => total += set.size); let idx = 0; for (const [doc, set] of dirtyEntries) { if (jobId !== state.searchJobId) return; const scopeRoots = rootsMap.get(doc) || []; if (!scopeRoots.length) continue; injectStyleToDoc(doc); const containers = Array.from(set).filter(Boolean); const normalized = []; for (const rawContainer of containers) { if (!rawContainer || !rawContainer.isConnected) continue; let acceptedRoot = null; for (const root of scopeRoots) { if (root === rawContainer || root.contains(rawContainer)) { acceptedRoot = root; break; } if (rawContainer.contains(root)) { acceptedRoot = root; } } const container = acceptedRoot ? getNearestRescanContainer(rawContainer, acceptedRoot) : null; if (!container || !container.isConnected) continue; const hasAncestor = normalized.some(x => x === container || x.contains(container)); if (hasAncestor) continue; for (let i = normalized.length - 1; i >= 0; i--) { const old = normalized[i]; if (container.contains(old)) normalized.splice(i, 1); } normalized.push(container); } for (const container of normalized) { if (jobId !== state.searchJobId) return; idx++; updateProgress(`增量刷新 ${idx}/${total} · ${getFramePath(doc)}`); runInternalUpdate(() => removeRecordsInSubtree(container)); await scanSingleRoot(container, state.currentParsed, positiveTermObjects, jobId, `增量扫描 ${idx}/${total}`); } } if (jobId !== state.searchJobId) return; rebuildGlobalMatches(); renderResultsSidebar(); if (state.matches.length) { if (state.currentIndex < 0) state.currentIndex = 0; // 增量刷新属于自动重排:只更新高亮/选中态,不抢占页面滚动焦点 setActiveResult(Math.max(0, Math.min(state.currentIndex, state.matches.length - 1)), { scroll: false }); } else { state.currentIndex = -1; updateResultInfo(); } updateProgress(`增量完成 · ${state.matches.length} 条结果`); } async function executeSearch() { if (!state.inputQuery || !state.inputScope) return; const query = state.inputQuery.value.trim(); const scopeSelector = state.inputScope.value.trim(); state.query = query; state.scopeSelector = scopeSelector; state.options.scopeSelector = scopeSelector; saveConfig(); if (!query) { ++state.searchJobId; runInternalUpdate(() => { clearAllRecords(); renderColorConfig([]); renderHistory(); updateProgress(''); }); return; } const parseOptions = { caseSensitive: state.options.caseSensitive, wholeWord: state.options.wholeWord }; let parsed; try { parsed = parseBooleanQuery(query, parseOptions); } catch (e) { if (state.sidebarWrap) { state.sidebarWrap.innerHTML = `
表达式错误:${escapeHtml(e.message || '解析失败')}
`; } updateResultInfo(); updateProgress('解析失败'); return; } ensureTermColors(parsed.termsForColor); renderColorConfig(parsed.termsForColor); saveHistory(query, scopeSelector); renderHistory(); await fullSearch(parsed); startObservers(); } const debouncedIncrementalRefresh = debounce(() => { incrementalRefresh(); }, 280); /********************************************************* * MutationObserver *********************************************************/ function stopObservers() { for (const ob of state.observers) { try { ob.disconnect(); } catch (e) {} } state.observers = []; for (const { frame, handler } of state.frameLoadHandlers) { try { frame.removeEventListener('load', handler, true); } catch (e) {} } state.frameLoadHandlers = []; } function mutationTouchesSearchArea(m) { const nodes = [ ...(m.addedNodes ? Array.from(m.addedNodes) : []), ...(m.removedNodes ? Array.from(m.removedNodes) : []) ]; if (m.target) nodes.push(m.target); for (const n of nodes) { if (!n) continue; if (n.nodeType === 1) { const el = n; if (el.closest && (el.closest(`#${PANEL_ID}`) || el.closest(`.${MARK_CLASS}`))) continue; return true; } if (n.nodeType === 3) { const p = n.parentElement; if (p && p.closest && (p.closest(`#${PANEL_ID}`) || p.closest(`.${MARK_CLASS}`))) continue; return true; } } return false; } function registerDirtyContainer(doc, node) { if (!doc || !node) return; let target = node.nodeType === 1 ? node : node.parentElement; if (!target) return; if (target.closest && target.closest(`#${PANEL_ID}`)) return; if (!state.dirtyContainers.has(doc)) { state.dirtyContainers.set(doc, new Set()); } state.dirtyContainers.get(doc).add(target); } function attachFrameLoadListeners(docs) { for (const doc of docs) { let frames = []; try { frames = Array.from(doc.querySelectorAll('iframe, frame')); } catch (e) {} for (const frame of frames) { const handler = () => { markFrameCacheDirty(); if (state.query && state.options.autoRefresh) { debouncedIncrementalRefresh(); } }; try { frame.addEventListener('load', handler, true); state.frameLoadHandlers.push({ frame, handler }); } catch (e) {} } } } function startObservers() { stopObservers(); if (!state.options.autoRefresh) return; const docs = collectSearchDocumentsCached(document); attachFrameLoadListeners(docs); for (const doc of docs) { try { const ob = new MutationObserver((mutations) => { if (state.isInternalUpdating) return; let touched = false; for (const m of mutations) { if (m.type !== 'childList' && m.type !== 'characterData') continue; if (!mutationTouchesSearchArea(m)) continue; touched = true; if (m.target && m.target.nodeType === 1) { const tag = m.target.tagName; if (tag === 'IFRAME' || tag === 'FRAME') markFrameCacheDirty(); } if (m.addedNodes) { for (const n of m.addedNodes) { if (n.nodeType === 1) { const el = n; if (el.matches && el.matches('iframe, frame')) markFrameCacheDirty(); registerDirtyContainer(doc, el); } else if (n.nodeType === 3) { registerDirtyContainer(doc, n); } } } if (m.removedNodes) { for (const n of m.removedNodes) { if (n.nodeType === 1) { const el = n; if (el.matches && el.matches('iframe, frame')) markFrameCacheDirty(); } } } registerDirtyContainer(doc, m.target); } if (touched && state.query) { debouncedIncrementalRefresh(); } }); if (doc.body) { ob.observe(doc.body, { subtree: true, childList: true, characterData: true }); state.observers.push(ob); } } catch (e) {} } } /********************************************************* * 历史 / 颜色 *********************************************************/ function renderColorConfig(terms) { const wrap = state.colorsWrap; if (!wrap) return; wrap.innerHTML = ''; if (!terms.length) { wrap.innerHTML = `
输入表达式后,这里会显示关键词/条件颜色
`; return; } const uniqTerms = unique(terms); uniqTerms.forEach((term, i) => { if (!state.options.termColorMap[term]) { state.options.termColorMap[term] = getDefaultColor(i); } const row = document.createElement('div'); row.className = 'tm-color-row'; const label = document.createElement('div'); label.className = 'tm-color-label'; label.textContent = term; const input = document.createElement('input'); input.type = 'color'; input.value = normalizeHexColor(state.options.termColorMap[term]); input.style.cssText = 'width:42px;height:24px;border:none;background:none;padding:0;cursor:pointer;'; input.addEventListener('input', () => { state.options.termColorMap[term] = input.value; saveConfig(); if (state.query) executeSearch(); }); row.appendChild(label); row.appendChild(input); wrap.appendChild(row); }); } function renderHistory() { const wrap = state.historyWrap; if (!wrap) return; wrap.innerHTML = ''; const list = loadHistory(); if (!list.length) { wrap.innerHTML = `
暂无历史
`; return; } list.forEach(item => { const div = document.createElement('div'); div.className = 'tm-history-item'; div.innerHTML = `
${escapeHtml(item.q)}
${escapeHtml(item.s || '整页范围')}
`; div.addEventListener('click', () => { state.inputQuery.value = item.q || ''; state.inputScope.value = item.s || ''; executeSearch(); }); wrap.appendChild(div); }); } /********************************************************* * UI *********************************************************/ function getPanelHTML() { return `
高级页面查找 V3.1.0 修复增强版
查找表达式
搜索范围(CSS 选择器,可选)
颜色设置
0 / 0
搜索历史
结果列表
语法说明
1. 普通关键词:error
2. 短语:"hello world"
3. 显式关键词:kw:errorkw:"hello world"
4. 数字比较:>10>=3.5<=20=42
5. 数字区间:10..100num:10..100
6. 类型:type:emailtype:urltype:datetype:zhtype:enwordtype:moneytype:percent
7. 裸类型也可用:emailurlzhenword
8. 正则:re:\\b[A-Z]{3}\\d{4}\\b
9. 布尔逻辑:(error OR warning) AND NOT success
10. 搜索范围:填写 CSS 选择器,如 .article#main.content p
说明:布尔表达式按“单个文本节点”判定。同域 iframe 会一起搜索,跨域 iframe 因浏览器限制无法访问。
纯 NOT 表达式会显示结果列表并支持跳转,但不会做关键词高亮。
已启用:增量 DOM 扫描、iframe 扫描缓存、大页面分块搜索、可折叠高级选项、拖拽面板与位置记忆。
快捷键:Ctrl+F / Cmd+F 打开;Enter 查找/下一个;Shift+Enter 上一个;F3 / Shift+F3 导航;Esc 关闭。
`; } function applyPanelPosition() { if (!state.panel) return; const pos = state.options.panelPos || {}; state.panel.style.right = 'auto'; const rect = state.panel.getBoundingClientRect(); const panelWidth = rect.width || 520; const panelHeight = rect.height || 500; let left; let top; if (typeof pos.left === 'number') { left = pos.left; } else { left = Math.max(8, window.innerWidth - panelWidth - 16); } if (typeof pos.top === 'number') { top = pos.top; } else { top = 16; } left = Math.max(0, Math.min(left, Math.max(0, window.innerWidth - panelWidth))); top = Math.max(0, Math.min(top, Math.max(0, window.innerHeight - panelHeight))); state.panel.style.left = `${left}px`; state.panel.style.top = `${top}px`; } function savePanelPosition(left, top) { state.options.panelPos = { left: Math.max(0, Math.round(left)), top: Math.max(0, Math.round(top)) }; saveConfig(); } function bindPanelDrag(head) { if (!head || !state.panel) return; let dragging = false; let startX = 0; let startY = 0; let startLeft = 0; let startTop = 0; head.addEventListener('mousedown', (e) => { const target = e.target; if (!target) return; if ( target.closest('button') || target.closest('input') || target.closest('label') || target.closest('textarea') || target.closest('select') ) { return; } dragging = true; const rect = state.panel.getBoundingClientRect(); startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; document.documentElement.classList.add('tm-dragging'); e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging || !state.panel) return; const dx = e.clientX - startX; const dy = e.clientY - startY; const panelRect = state.panel.getBoundingClientRect(); let left = startLeft + dx; let top = startTop + dy; const maxLeft = Math.max(0, window.innerWidth - panelRect.width); const maxTop = Math.max(0, window.innerHeight - panelRect.height); left = Math.max(0, Math.min(left, maxLeft)); top = Math.max(0, Math.min(top, maxTop)); state.panel.style.left = `${left}px`; state.panel.style.top = `${top}px`; state.panel.style.right = 'auto'; }); document.addEventListener('mouseup', () => { if (!dragging || !state.panel) return; dragging = false; document.documentElement.classList.remove('tm-dragging'); const rect = state.panel.getBoundingClientRect(); savePanelPosition(rect.left, rect.top); }); } function createPanel() { if (!document.body) return; injectStyleToDoc(document); const old = document.getElementById(PANEL_ID); if (old) old.remove(); const panel = document.createElement('div'); panel.id = PANEL_ID; panel.style.display = 'none'; panel.innerHTML = getPanelHTML(); document.body.appendChild(panel); state.panel = panel; state.inputQuery = panel.querySelector('#tm-query'); state.inputScope = panel.querySelector('#tm-scope'); state.resultInfo = panel.querySelector('#tm-result-info'); state.colorsWrap = panel.querySelector('#tm-colors'); state.historyWrap = panel.querySelector('#tm-history'); state.sidebarWrap = panel.querySelector('#tm-sidebar'); state.advancedWrap = panel.querySelector('#tm-advanced-wrap'); state.advancedToggle = panel.querySelector('#tm-advanced-toggle'); state.progressInfo = panel.querySelector('#tm-progress-info'); state.chkHighlight = panel.querySelector('#tm-highlight'); state.chkCaseSensitive = panel.querySelector('#tm-case'); state.chkWholeWord = panel.querySelector('#tm-whole'); state.chkAutoRefresh = panel.querySelector('#tm-auto'); state.chkVisibleOnly = panel.querySelector('#tm-visible'); state.chkIncludeCode = panel.querySelector('#tm-code'); state.btnSearch = panel.querySelector('#tm-search-btn'); state.btnPrev = panel.querySelector('#tm-prev-btn'); state.btnNext = panel.querySelector('#tm-next-btn'); state.btnClear = panel.querySelector('#tm-clear-btn'); state.btnClose = panel.querySelector('#tm-close-btn'); const required = [ state.inputQuery, state.inputScope, state.resultInfo, state.colorsWrap, state.historyWrap, state.sidebarWrap, state.advancedWrap, state.advancedToggle, state.progressInfo, state.chkHighlight, state.chkCaseSensitive, state.chkWholeWord, state.chkAutoRefresh, state.chkVisibleOnly, state.chkIncludeCode, state.btnSearch, state.btnPrev, state.btnNext, state.btnClear, state.btnClose ]; if (required.some(x => !x)) { console.error('高级页面查找 V3.1.0:面板初始化失败,缺少关键节点'); return; } state.chkHighlight.checked = state.options.highlight; state.chkCaseSensitive.checked = state.options.caseSensitive; state.chkWholeWord.checked = state.options.wholeWord; state.chkAutoRefresh.checked = state.options.autoRefresh; state.chkVisibleOnly.checked = state.options.visibleOnly; state.chkIncludeCode.checked = state.options.includeCode; state.inputScope.value = state.options.scopeSelector || ''; renderColorConfig([]); renderHistory(); renderResultsSidebar(); updateResultInfo(); updateProgress(''); state.btnSearch.addEventListener('click', () => executeSearch()); state.btnPrev.addEventListener('click', gotoPrev); state.btnNext.addEventListener('click', gotoNext); state.btnClose.addEventListener('click', hidePanel); state.btnClear.addEventListener('click', () => { ++state.searchJobId; state.inputQuery.value = ''; state.inputScope.value = ''; state.query = ''; state.scopeSelector = ''; state.currentParsed = null; runInternalUpdate(() => { clearAllRecords(); renderColorConfig([]); renderResultsSidebar(); updateResultInfo(); updateProgress(''); }); }); state.inputQuery.addEventListener('input', () => { try { const parsed = parseBooleanQuery(state.inputQuery.value.trim(), { caseSensitive: state.options.caseSensitive, wholeWord: state.options.wholeWord }); ensureTermColors(parsed.termsForColor); renderColorConfig(parsed.termsForColor); } catch (e) {} }); state.inputQuery.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); if (e.shiftKey) { gotoPrev(); } else { if ( state.query !== state.inputQuery.value.trim() || state.scopeSelector !== state.inputScope.value.trim() || !state.matches.length ) { executeSearch(); } else { gotoNext(); } } } else if (e.key === 'Escape') { hidePanel(); } }); state.inputScope.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); executeSearch(); } }); const syncOptionsAndMaybeSearch = () => { state.options.highlight = state.chkHighlight.checked; state.options.caseSensitive = state.chkCaseSensitive.checked; state.options.wholeWord = state.chkWholeWord.checked; state.options.autoRefresh = state.chkAutoRefresh.checked; state.options.visibleOnly = state.chkVisibleOnly.checked; state.options.includeCode = state.chkIncludeCode.checked; state.options.scopeSelector = state.inputScope.value.trim(); saveConfig(); startObservers(); if (state.inputQuery.value.trim()) executeSearch(); }; [ state.chkHighlight, state.chkCaseSensitive, state.chkWholeWord, state.chkAutoRefresh, state.chkVisibleOnly, state.chkIncludeCode ].forEach(el => el.addEventListener('change', syncOptionsAndMaybeSearch)); state.advancedToggle.addEventListener('click', () => { state.options.advancedCollapsed = !state.options.advancedCollapsed; state.advancedWrap.classList.toggle('collapsed', state.options.advancedCollapsed); const arrow = state.panel.querySelector('#tm-advanced-arrow'); if (arrow) arrow.textContent = state.options.advancedCollapsed ? '▸' : '▾'; saveConfig(); }); applyPanelPosition(); bindPanelDrag(panel.querySelector('#tm-head')); } function showPanel() { if (!state.panel) createPanel(); if (!state.panel || !state.inputQuery) return; state.panel.style.display = 'block'; state.panelVisible = true; applyPanelPosition(); safeFocus(state.inputQuery); try { state.inputQuery.select(); } catch (e) {} } function hidePanel() { if (!state.panel) return; state.panel.style.display = 'none'; state.panelVisible = false; } /********************************************************* * 快捷键 *********************************************************/ function bindShortcuts() { document.addEventListener('keydown', (e) => { const isFind = (e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'f'; if (isFind) { e.preventDefault(); e.stopPropagation(); showPanel(); return; } if (e.key === 'F3') { if (!state.matches.length) return; e.preventDefault(); if (e.shiftKey) gotoPrev(); else gotoNext(); return; } if (state.panelVisible && e.key === 'Escape') { hidePanel(); } }, true); } /********************************************************* * 初始化 *********************************************************/ function init() { loadConfig(); bindShortcuts(); whenDocumentReady(() => { injectStyleToDoc(document); createPanel(); if (typeof GM_registerMenuCommand === 'function') { GM_registerMenuCommand('查找...', () => { showPanel(); }); } startObservers(); window.addEventListener('resize', debounce(() => { if (!state.panel) return; const rect = state.panel.getBoundingClientRect(); const maxLeft = Math.max(0, window.innerWidth - rect.width); const maxTop = Math.max(0, window.innerHeight - rect.height); const left = Math.min(rect.left, maxLeft); const top = Math.min(rect.top, maxTop); state.panel.style.left = `${Math.max(0, left)}px`; state.panel.style.top = `${Math.max(0, top)}px`; savePanelPosition(left, top); }, 80)); }); } init(); })();