Last active
August 1, 2026 10:27
-
-
Save rf5860/7608533084ea972983515d979c67d786 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Regex Find in Page (Ctrl+/) | |
| // @namespace regex-find | |
| // @version 1.1 | |
| // @description Find text on the page using regular expressions. Ctrl+/ to open, Enter/Shift+Enter to navigate, Esc to close. | |
| // @author rjf89 | |
| // @updateURL https://gist.github.com/rf5860/7608533084ea972983515d979c67d786/raw/RegexpFind.user.js | |
| // @downloadURL https://gist.github.com/rf5860/7608533084ea972983515d979c67d786/raw/RegexpFind.user.js | |
| // @match *://*/* | |
| // @grant none | |
| // @run-at document-idle | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const HL_CLASS = 'gm-rgx-hl'; | |
| const CUR_CLASS = 'gm-rgx-hl-current'; | |
| const MAX_MATCHES = 5000; | |
| let ui = null, input, flagsInput, countEl; | |
| let marks = []; | |
| let current = -1; | |
| let debounceTimer = null; | |
| /* ---------- styles ---------- */ | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| #gm-rgx-bar { | |
| position: fixed; top: 10px; right: 10px; z-index: 2147483647; | |
| background: #2b2b2b; color: #eee; padding: 8px 10px; | |
| border-radius: 6px; box-shadow: 0 2px 10px rgba(0,0,0,.5); | |
| font: 13px/1.4 sans-serif; display: flex; align-items: center; gap: 6px; | |
| } | |
| #gm-rgx-bar input { | |
| background: #1c1c1c; color: #eee; border: 1px solid #555; | |
| border-radius: 3px; padding: 4px 6px; font: 13px monospace; | |
| } | |
| #gm-rgx-pattern { width: 260px; } | |
| #gm-rgx-flags { width: 40px; } | |
| #gm-rgx-bar input.gm-rgx-error { border-color: #e05555; outline: 1px solid #e05555; } | |
| #gm-rgx-bar button { | |
| background: #444; color: #eee; border: 1px solid #666; | |
| border-radius: 3px; padding: 3px 8px; cursor: pointer; | |
| } | |
| #gm-rgx-bar button:hover { background: #555; } | |
| #gm-rgx-count { min-width: 70px; text-align: center; color: #bbb; } | |
| .${HL_CLASS} { background: #ffe000; color: #000; } | |
| .${CUR_CLASS} { background: #ff8800; color: #000; } | |
| `; | |
| document.documentElement.appendChild(style); | |
| /* ---------- UI ---------- */ | |
| function buildUI() { | |
| ui = document.createElement('div'); | |
| ui.id = 'gm-rgx-bar'; | |
| ui.innerHTML = ` | |
| <input id="gm-rgx-pattern" type="text" placeholder="Regular expression…" spellcheck="false"> | |
| <input id="gm-rgx-flags" type="text" value="gi" title="Regex flags" spellcheck="false"> | |
| <span id="gm-rgx-count">0 / 0</span> | |
| <button id="gm-rgx-prev" title="Previous (Shift+Enter)">▲</button> | |
| <button id="gm-rgx-next" title="Next (Enter)">▼</button> | |
| <button id="gm-rgx-close" title="Close (Esc)">✕</button> | |
| `; | |
| document.body.appendChild(ui); | |
| input = ui.querySelector('#gm-rgx-pattern'); | |
| flagsInput = ui.querySelector('#gm-rgx-flags'); | |
| countEl = ui.querySelector('#gm-rgx-count'); | |
| input.addEventListener('input', scheduleSearch); | |
| flagsInput.addEventListener('input', scheduleSearch); | |
| ui.querySelector('#gm-rgx-next').addEventListener('click', () => move(1)); | |
| ui.querySelector('#gm-rgx-prev').addEventListener('click', () => move(-1)); | |
| ui.querySelector('#gm-rgx-close').addEventListener('click', closeUI); | |
| ui.addEventListener('keydown', e => { | |
| e.stopPropagation(); | |
| if (e.key === 'Enter') { e.preventDefault(); move(e.shiftKey ? -1 : 1); } | |
| if (e.key === 'Escape') { e.preventDefault(); closeUI(); } | |
| }); | |
| } | |
| function openUI() { | |
| if (!ui) buildUI(); | |
| ui.style.display = 'flex'; | |
| input.focus(); | |
| input.select(); | |
| } | |
| function closeUI() { | |
| if (ui) ui.style.display = 'none'; | |
| clearHighlights(); | |
| updateCount(); | |
| } | |
| /* ---------- searching ---------- */ | |
| function scheduleSearch() { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(runSearch, 250); | |
| } | |
| function runSearch() { | |
| clearHighlights(); | |
| input.classList.remove('gm-rgx-error'); | |
| const pattern = input.value; | |
| if (!pattern) { updateCount(); return; } | |
| let flags = flagsInput.value.replace(/[^dimsy]/g, ''); // strip u/v; we manage those | |
| let regex = null; | |
| // Prefer Unicode mode so quantifiers work on astral chars (emoji, etc.), | |
| // but fall back to non-Unicode mode for patterns that are only valid there. | |
| for (const extra of ['u', '']) { | |
| try { | |
| regex = new RegExp(pattern, 'g' + flags + extra); | |
| break; | |
| } catch (e) { /* try next */ } | |
| } | |
| if (!regex) { | |
| input.classList.add('gm-rgx-error'); | |
| countEl.textContent = 'bad regex'; | |
| return; | |
| } | |
| // Collect text nodes first (highlighting mutates the DOM) | |
| const walker = document.createTreeWalker( | |
| document.body, | |
| NodeFilter.SHOW_TEXT, | |
| { | |
| acceptNode(node) { | |
| const p = node.parentElement; | |
| if (!p) return NodeFilter.FILTER_REJECT; | |
| const tag = p.tagName; | |
| if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || | |
| tag === 'TEXTAREA' || p.closest('#gm-rgx-bar')) | |
| return NodeFilter.FILTER_REJECT; | |
| if (!/\S/.test(node.data)) return NodeFilter.FILTER_REJECT; | |
| return NodeFilter.FILTER_ACCEPT; | |
| } | |
| } | |
| ); | |
| const textNodes = []; | |
| while (walker.nextNode()) textNodes.push(walker.currentNode); | |
| outer: | |
| for (const node of textNodes) { | |
| const text = node.data; | |
| regex.lastIndex = 0; | |
| let m, last = 0, frag = null; | |
| while ((m = regex.exec(text)) !== null) { | |
| if (m[0].length === 0) { | |
| // Skip a full code point, not just a code unit, | |
| // so we don't split surrogate pairs | |
| const cp = text.codePointAt(regex.lastIndex); | |
| regex.lastIndex += cp > 0xFFFF ? 2 : 1; | |
| continue; | |
| } | |
| if (!frag) frag = document.createDocumentFragment(); | |
| if (m.index > last) | |
| frag.appendChild(document.createTextNode(text.slice(last, m.index))); | |
| const mark = document.createElement('mark'); | |
| mark.className = HL_CLASS; | |
| mark.textContent = m[0]; | |
| frag.appendChild(mark); | |
| marks.push(mark); | |
| last = m.index + m[0].length; | |
| if (marks.length >= MAX_MATCHES) break; | |
| } | |
| if (frag) { | |
| if (last < text.length) | |
| frag.appendChild(document.createTextNode(text.slice(last))); | |
| node.parentNode.replaceChild(frag, node); | |
| } | |
| if (marks.length >= MAX_MATCHES) break outer; | |
| } | |
| current = -1; | |
| if (marks.length) move(1); | |
| else updateCount(); | |
| } | |
| function clearHighlights() { | |
| for (const mark of marks) { | |
| const parent = mark.parentNode; | |
| if (!parent) continue; | |
| parent.replaceChild(document.createTextNode(mark.textContent), mark); | |
| parent.normalize(); | |
| } | |
| marks = []; | |
| current = -1; | |
| } | |
| /* ---------- navigation ---------- */ | |
| function move(dir) { | |
| if (!marks.length) return; | |
| if (current >= 0) marks[current].classList.remove(CUR_CLASS); | |
| current = (current + dir + marks.length) % marks.length; | |
| const mark = marks[current]; | |
| mark.classList.add(CUR_CLASS); | |
| mark.scrollIntoView({ block: 'center', behavior: 'smooth' }); | |
| updateCount(); | |
| } | |
| function updateCount() { | |
| if (!countEl) return; | |
| countEl.textContent = marks.length | |
| ? `${current + 1} / ${marks.length}${marks.length >= MAX_MATCHES ? '+' : ''}` | |
| : '0 / 0'; | |
| } | |
| /* ---------- hotkey ---------- */ | |
| document.addEventListener('keydown', e => { | |
| if (e.ctrlKey && !e.altKey && !e.metaKey && e.key === '/') { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| openUI(); | |
| } | |
| }, true); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment