// ==UserScript== // @name 页面关键词查找(与、或、排除) // @namespace local.page-find // @version 1.0.2 // @description Ctrl+Shift+F 搜索页面文字,支持逻辑条件、括号、转义和边缘拖动 // @match *://*/* // @run-at document-idle // @grant none // @noframes // ==/UserScript== (() => { 'use strict'; const ALL_NAME = 'tm-page-find-results'; const ACTIVE_NAME = 'tm-page-find-active'; let host = null; let input = null; let counter = null; let matches = []; let current = 0; // 0 表示尚未选中结果 let observer = null; let refreshTimer = null; let highlightStyleInstalled = false; const blockDisplays = new Set([ 'block', 'list-item', 'table-cell', 'table-caption', 'flex', 'grid', 'flow-root', 'inline-block', 'inline-flex', 'inline-grid', 'table' ]); function installHighlightStyle() { if (highlightStyleInstalled) return; const sheet = new CSSStyleSheet(); sheet.replaceSync(` ::highlight(${ALL_NAME}) { background-color: #ffe45b; color: #111; } ::highlight(${ACTIVE_NAME}) { background-color: #ff9632; color: #111; } `); document.adoptedStyleSheets = [ ...document.adoptedStyleSheets, sheet ]; highlightStyleInstalled = true; } function makeElement(tag, className, text) { const element = document.createElement(tag); if (className) element.className = className; if (text !== undefined) element.textContent = text; return element; } /* * 支持关键词、排除、并且、或者和括号。 * 优先级:括号 → 词首排除 → & → | / |。 */ function parseQuery(query) { const tokens = []; let buffer = ''; function flushWord() { const raw = buffer.trim(); buffer = ''; if (!raw) return; // - 必须位于词首,并且紧贴后面的关键词。 // \-苹果:普通关键词“-苹果” // - 苹果:普通关键词“- 苹果” const negative = raw.startsWith('-') && raw.length > 1 && !/\s/.test(raw[1]); const value = (negative ? raw.slice(1) : raw) .replace(/\\([&||()\\-])/g, '$1'); tokens.push({ type: 'term', value, negative }); } // 转义过的符号不作为运算符。 for (let i = 0; i < query.length; i++) { const ch = query[i]; if (ch === '\\' && i + 1 < query.length) { buffer += ch + query[++i]; continue; } if ('&||()'.includes(ch)) { flushWord(); tokens.push({ type: ch === '|' ? '|' : ch }); } else { buffer += ch; } } flushWord(); tokens.push({ type: 'end' }); let position = 0; const peek = () => tokens[position]; if (peek().type === 'end') return null; function readOr() { let node = readAnd(); while (peek().type === '|') { position++; node = { type: 'or', left: node, right: readAnd() }; } return node; } function readAnd() { let node = readPrimary(); while (peek().type === '&') { position++; node = { type: 'and', left: node, right: readPrimary() }; } return node; } function readPrimary() { const token = peek(); if (token.type === 'term') { position++; return token; } if (token.type === '(') { position++; if (peek().type === ')') { throw new Error('括号内不能为空'); } const node = readOr(); if (peek().type !== ')') { throw new Error( '缺少右括号 ),或括号内的条件之间缺少 &、|' ); } position++; return node; } if (token.type === ')') { throw new Error('右括号 ) 前缺少关键词'); } if (token.type === 'end') { throw new Error('条件不完整:缺少关键词或右括号'); } throw new Error(`运算符 ${token.type} 前缺少关键词`); } const result = readOr(); if (peek().type !== 'end') { if (peek().type === ')') { throw new Error('多余的右括号 )'); } throw new Error( '条件之间需要 & 或 |;不支持 -(...) 整组排除' ); } return result; } function termRegex(term) { const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return new RegExp(escaped, 'gi'); } /* * 将页面文字按最近的块级容器分组。 * 同一段里的多个 inline 元素可以一起搜索。 */ function collectBlocks() { const blocks = []; let block = null; const walker = document.createTreeWalker( document.body, NodeFilter.SHOW_TEXT ); function containerOf(node) { let element = node.parentElement; let nearestBlock = null; while (element) { if ( /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|TEXTAREA|SELECT|SVG|CANVAS)$/ .test(element.tagName) ) { return null; } if ( element.hidden || element.inert || element.getAttribute('aria-hidden') === 'true' ) { return null; } const style = getComputedStyle(element); if ( style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' ) { return null; } if (!nearestBlock && blockDisplays.has(style.display)) { nearestBlock = element; } if (element === document.body) break; element = element.parentElement; } return nearestBlock || document.body; } for ( let node = walker.nextNode(); node; node = walker.nextNode() ) { if (!node.nodeValue) continue; const container = containerOf(node); if (!container) continue; if (!block || block.container !== container) { block = { container, text: '', segments: [] }; blocks.push(block); } const start = block.text.length; block.text += node.nodeValue; block.segments.push({ node, start, end: block.text.length }); } return blocks.filter(item => item.text.trim()); } function rangeFromOffsets(block, start, end) { const first = block.segments.find( segment => start < segment.end ); const last = block.segments.find( segment => end <= segment.end ); if (!first || !last) return null; const range = document.createRange(); range.setStart(first.node, start - first.start); range.setEnd(last.node, end - last.start); return range; } function clearHighlights() { CSS.highlights.delete(ALL_NAME); CSS.highlights.delete(ACTIVE_NAME); } function updateCounter() { if (counter) { counter.textContent = `${current}/${matches.length}`; } } function rebuild(resetCurrent = false) { if (!host) return; if (resetCurrent) current = 0; clearHighlights(); matches = []; counter.title = ''; counter.style.color = ''; input.title = ''; input.removeAttribute('aria-invalid'); let expression; try { expression = parseQuery(input.value); } catch (error) { current = 0; counter.textContent = '条件有误'; counter.style.color = '#c33'; counter.title = error.message; input.title = error.message; input.setAttribute('aria-invalid', 'true'); return; } if (!expression) { current = 0; updateCounter(); return; } const regexCache = new Map(); function regexFor(value) { if (!regexCache.has(value)) { regexCache.set(value, termRegex(value)); } return regexCache.get(value); } /* * null:条件不成立。 * Set:条件成立,记录需要高亮的正向关键词。 * 空 Set 也表示成立,例如满足排除条件。 */ function evaluate(node, contains) { if (node.type === 'term') { const found = contains(node.value); if (node.negative) { return found ? null : new Set(); } return found ? new Set([node.value]) : null; } const left = evaluate(node.left, contains); const right = evaluate(node.right, contains); if (node.type === 'and') { if (left === null || right === null) { return null; } } else { if (left === null && right === null) { return null; } } return new Set([ ...(left || []), ...(right || []) ]); } for (const block of collectBlocks()) { const foundCache = new Map(); function contains(value) { if (!foundCache.has(value)) { const regex = regexFor(value); regex.lastIndex = 0; const found = regex.test(block.text); regex.lastIndex = 0; foundCache.set(value, found); } return foundCache.get(value); } const positiveTerms = evaluate(expression, contains); if (positiveTerms === null || positiveTerms.size === 0) { continue; } const positions = []; for (const term of positiveTerms) { const regex = regexFor(term); regex.lastIndex = 0; for (const match of block.text.matchAll(regex)) { positions.push({ start: match.index, end: match.index + match[0].length }); } } positions.sort( (a, b) => a.start - b.start || b.end - a.end ); let previousKey = null; for (const position of positions) { const key = `${position.start}:${position.end}`; if (key === previousKey) continue; previousKey = key; const range = rangeFromOffsets( block, position.start, position.end ); if (range) matches.push(range); } } const highlight = new Highlight(); for (const range of matches) { highlight.add(range); } CSS.highlights.set(ALL_NAME, highlight); if (current > matches.length) { current = matches.length; } if (current > 0) { markActive(false); } updateCounter(); } function scrollToRange(range) { const rect = range.getBoundingClientRect(); if (!rect.width && !rect.height) return; let element = range.startContainer.parentElement; while (element && element !== document.body) { const style = getComputedStyle(element); if ( /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight + 1 ) { const viewport = element.getBoundingClientRect(); element.scrollBy({ top: rect.top - (viewport.top + viewport.height / 2), behavior: 'smooth' }); return; } element = element.parentElement; } window.scrollBy({ top: rect.top - window.innerHeight / 2, behavior: 'smooth' }); } function markActive(shouldScroll = true) { CSS.highlights.delete(ACTIVE_NAME); if (!current || !matches[current - 1]) return; const range = matches[current - 1]; const active = new Highlight(range); active.priority = 1; CSS.highlights.set(ACTIVE_NAME, active); if (shouldScroll) scrollToRange(range); } function move(direction) { if (!matches.length) return; current = current === 0 ? (direction > 0 ? 1 : matches.length) : ( (current - 1 + direction + matches.length) % matches.length ) + 1; markActive(); updateCounter(); } /* * 新增:拖动四周边缘,移动整个搜索框。 * 不调整宽高,不占用输入框和按钮的主体区域。 */ function enableEdgeDragging(panel, shadow) { let drag = null; for (const side of ['top', 'bottom', 'left', 'right']) { const edge = makeElement( 'div', `drag-edge drag-edge-${side}` ); edge.title = '按住拖动搜索框'; edge.setAttribute('aria-hidden', 'true'); shadow.append(edge); edge.addEventListener('pointerdown', event => { if ( event.button !== 0 || !event.isPrimary || drag ) { return; } const rect = panel.getBoundingClientRect(); drag = { pointerId: event.pointerId, edge, mouseX: event.clientX, mouseY: event.clientY, left: rect.left, top: rect.top }; // 切换为左上角定位,不改变宽高。 panel.style.left = `${rect.left}px`; panel.style.top = `${rect.top}px`; panel.style.right = 'auto'; panel.style.bottom = 'auto'; // 鼠标移出边缘后仍可继续拖动。 edge.setPointerCapture(event.pointerId); event.preventDefault(); event.stopPropagation(); }); edge.addEventListener('pointermove', event => { if ( !drag || drag.edge !== edge || event.pointerId !== drag.pointerId ) { return; } const rect = panel.getBoundingClientRect(); const maxLeft = Math.max( 0, document.documentElement.clientWidth - rect.width ); const maxTop = Math.max( 0, document.documentElement.clientHeight - rect.height ); const left = Math.max( 0, Math.min( maxLeft, drag.left + event.clientX - drag.mouseX ) ); const top = Math.max( 0, Math.min( maxTop, drag.top + event.clientY - drag.mouseY ) ); panel.style.left = `${left}px`; panel.style.top = `${top}px`; event.preventDefault(); event.stopPropagation(); }); function stopDragging(event) { if ( !drag || drag.edge !== edge || event.pointerId !== drag.pointerId ) { return; } drag = null; if (edge.hasPointerCapture(event.pointerId)) { edge.releasePointerCapture(event.pointerId); } } edge.addEventListener('pointerup', stopDragging); edge.addEventListener('pointercancel', stopDragging); edge.addEventListener('lostpointercapture', stopDragging); // 避免拖动结束的点击传给页面。 edge.addEventListener('click', event => { event.preventDefault(); event.stopPropagation(); }); } } function close() { if (!host) return; observer?.disconnect(); observer = null; clearTimeout(refreshTimer); clearHighlights(); matches = []; current = 0; host.remove(); host = input = counter = null; } function open() { if (host) { input.focus(); input.select(); return; } if ( !globalThis.CSS?.highlights || typeof Highlight === 'undefined' ) { alert( '当前浏览器不支持 CSS Custom Highlight API。请使用较新的 Chrome。' ); return; } installHighlightStyle(); host = makeElement('div'); host.id = 'tm-page-find-host'; const shadow = host.attachShadow({ mode: 'closed' }); const uiStyle = new CSSStyleSheet(); uiStyle.replaceSync(` :host { position: fixed; top: 12px; right: 12px; z-index: 2147483647; width: min(520px, calc(100vw - 24px)); color-scheme: light; font: 16px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } * { box-sizing: border-box; } .bar { display: flex; align-items: center; height: 54px; padding: 0 8px 0 16px; background: #fff; color: #333; border: 1px solid #ddd; border-radius: 12px; box-shadow: 0 6px 24px #0003; } .query { flex: 1; min-width: 0; height: 100%; border: 0; outline: 0; background: transparent; color: #222; font: inherit; } .query::placeholder { color: #888; } .counter { flex: none; min-width: 60px; text-align: center; color: #555; font-variant-numeric: tabular-nums; } .divider { width: 1px; height: 32px; margin: 0 7px; background: #ead5c8; } button { flex: none; width: 38px; height: 38px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: #606060; font: 26px/1 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; } button:hover { background: #f1f1f1; } button:focus-visible { outline: 2px solid #568cff; } /* 四周 6px 的透明区域用于拖动,不改变原有外观。 */ .drag-edge { position: absolute; z-index: 10; cursor: move; touch-action: none; user-select: none; background: transparent; } .drag-edge-top, .drag-edge-bottom { left: 0; right: 0; height: 6px; } .drag-edge-top { top: 0; } .drag-edge-bottom { bottom: 0; } .drag-edge-left, .drag-edge-right { top: 6px; bottom: 6px; width: 6px; } .drag-edge-left { left: 0; } .drag-edge-right { right: 0; } `); shadow.adoptedStyleSheets = [uiStyle]; const bar = makeElement('div', 'bar'); input = makeElement('input', 'query'); input.type = 'text'; input.placeholder = '关键词:&且 |或 -排除'; input.setAttribute('aria-label', '搜索页面关键词'); counter = makeElement('span', 'counter', '0/0'); counter.setAttribute('aria-live', 'polite'); const divider = makeElement('span', 'divider'); function button(text, label, onClick) { const element = makeElement('button', '', text); element.type = 'button'; element.title = label; element.setAttribute('aria-label', label); element.addEventListener('click', onClick); return element; } const previous = button('↑', '上一个', () => move(-1)); const next = button('↓', '下一个', () => move(1)); const dismiss = button('×', '关闭', close); // 只调整箭头字号,按钮仍然是 38 × 38px。 previous.style.fontSize = '20px'; next.style.fontSize = '20px'; bar.append( input, counter, divider, previous, next, dismiss ); shadow.append(bar); document.body.append(host); // 新增:启用边缘拖动。 enableEdgeDragging(host, shadow); input.addEventListener('input', () => rebuild(true)); input.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); move(event.shiftKey ? -1 : 1); } }); // 页面动态更新时重新建立 Range。 observer = new MutationObserver(() => { clearTimeout(refreshTimer); refreshTimer = setTimeout(() => rebuild(false), 200); }); observer.observe(document.body, { subtree: true, childList: true, characterData: true }); input.focus(); } document.addEventListener('keydown', event => { if ( event.ctrlKey && event.shiftKey && !event.altKey && !event.metaKey && event.code === 'KeyF' ) { event.preventDefault(); event.stopPropagation(); open(); } else if (event.key === 'Escape' && host) { event.preventDefault(); event.stopPropagation(); close(); } }, true); })();