Quick-jump to any input field by typing to search

Jump to any input field/textarea/select/contenteditable by typing part of its placeholder/label/name/value/selected option. Use Enter to focus, Tab/Shift+Tab to cycle. Searches label text, input values, and dropdown selections.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Quick-jump to any input field by typing to search
// @namespace    http://tampermonkey.net/
// @version      2.3
// @license MIT
// @description  Jump to any input field/textarea/select/contenteditable by typing part of its placeholder/label/name/value/selected option. Use Enter to focus, Tab/Shift+Tab to cycle. Searches label text, input values, and dropdown selections.
// @author       Ophir Han
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const DEBUG = false; // Set to false to disable logging
    const log = (...args) => DEBUG && console.log('[JumpToInput]', ...args);

    let inputBox, counterDisplay, matchedInputs = [], selectedIndex = 0;
    let searchBoxActive = false;
    let autoFocusTimer = null;
    let scrollTimer = null;
    let userHasInteracted = false; // Track if user has cycled through matches

    // Listen for the hotkey: Ctrl + Shift + F
    document.addEventListener("keydown", function (event) {
        if (event.ctrlKey && event.shiftKey && event.key === "F") {
            log('Hotkey detected: Ctrl+Shift+F');
            event.preventDefault();
            event.stopPropagation();
            openSearchBox();
        }
    });

    function openSearchBox() {
        log('openSearchBox() called');
        
        if (searchBoxActive) {
            log('Search box already active, ignoring');
            return;
        }

        searchBoxActive = true;
        log('Setting searchBoxActive = true');

        // Create the floating search box container
        const container = document.createElement("div");
        container.id = "floatingSearchBoxContainer";
        container.style.position = "fixed";
        container.style.top = "10px";
        container.style.left = "50%";
        container.style.transform = "translateX(-50%)";
        container.style.zIndex = "9999";
        container.style.backgroundColor = "rgba(255, 255, 255, 0.95)";
        container.style.border = "2px solid blue";
        container.style.borderRadius = "5px";
        container.style.padding = "8px";
        container.style.boxShadow = "0 4px 6px rgba(0,0,0,0.1)";
        container.style.minWidth = "320px";
        container.style.maxWidth = "400px";

        // Create the input field
        inputBox = document.createElement("input");
        inputBox.type = "text";
        inputBox.placeholder = "Type to search...";
        inputBox.style.width = "100%";
        inputBox.style.padding = "5px";
        inputBox.style.border = "1px solid #ccc";
        inputBox.style.borderRadius = "3px";
        inputBox.style.outline = "none";
        inputBox.style.fontSize = "14px";
        inputBox.style.color = "black";
        inputBox.style.backgroundColor = "white";
        inputBox.id = "floatingSearchBox";

        // Create counter display
        counterDisplay = document.createElement("div");
        counterDisplay.style.marginTop = "5px";
        counterDisplay.style.fontSize = "12px";
        counterDisplay.style.color = "#666";
        counterDisplay.style.textAlign = "center";
        counterDisplay.textContent = "Press Ctrl+Shift+F to search";

        container.appendChild(inputBox);
        container.appendChild(counterDisplay);
        document.body.appendChild(container);
        
        log('Search box appended to DOM');

        // Use setTimeout to delay focus and event attachment to avoid immediate closure
        setTimeout(() => {
            log('Focusing input box and attaching events');
            inputBox.focus();
            
            inputBox.addEventListener("input", highlightMatches);
            inputBox.addEventListener("keydown", handleKeyPress);

            // Delay the outside click handler to prevent immediate closure
            setTimeout(() => {
                log('Attaching outsideClickHandler');
                document.addEventListener("click", outsideClickHandler, true);
            }, 100);

            // Immediately call highlightMatches() so that even an empty search will match all inputs
            highlightMatches();
        }, 50);
    }

    function highlightMatches() {
        const searchText = inputBox.value.toLowerCase();
        log('highlightMatches() called with search text:', searchText);
        
        // Clear any previous auto-focus timer
        if (autoFocusTimer) {
            clearTimeout(autoFocusTimer);
            autoFocusTimer = null;
        }
        
        // Clear any previous highlights
        matchedInputs.forEach(input => input.style.outline = "none");
        matchedInputs = [];
        selectedIndex = 0;

        // Get all searchable elements (excluding our floating search box)
        const elements = getAllSearchableElements();
        log('Found', elements.length, 'searchable elements');

        // If no text is entered, match all elements; otherwise filter by search text
        if (searchText.trim() === "") {
            matchedInputs = elements;
        } else {
            matchedInputs = elements.filter(element => {
                const searchableText = getSearchableText(element).toLowerCase();
                return searchableText.includes(searchText);
            });
        }

        log('Matched', matchedInputs.length, 'elements');

        if (matchedInputs.length > 0) {
            updateHighlight(false); // false = don't scroll yet
            updateCounter();

            // If there's only one match, wait for user to stop typing before auto-focusing
            if (matchedInputs.length === 1) {
                log('Only one match, waiting for user to stop typing...');
                autoFocusTimer = setTimeout(() => {
                    if (searchBoxActive && matchedInputs.length === 1) {
                        log('User stopped typing, auto-focusing now');
                        focusSelectedInput();
                        removeSearchBox();
                    }
                }, 800); // Wait 800ms after last keystroke
            }
        } else {
            updateCounter();
        }
    }

    function getAllSearchableElements() {
        const selectors = [
            'input[placeholder]',
            'input[aria-label]',
            'input[name]',
            'input[id]',
            'textarea',
            'select',
            '[contenteditable="true"]',
            '[contenteditable=""]'
        ];

        const allElements = [];
        const seen = new Set();

        selectors.forEach(selector => {
            document.querySelectorAll(selector).forEach(el => {
                // Skip our own search box
                if (el.id === 'floatingSearchBox' || el.closest('#floatingSearchBoxContainer')) {
                    return;
                }
                // Avoid duplicates
                if (!seen.has(el)) {
                    seen.add(el);
                    allElements.push(el);
                }
            });
        });

        return allElements;
    }

    function getSearchableText(element) {
        // Gather all searchable text from various attributes
        const texts = [];
        
        // Standard attributes
        if (element.placeholder) texts.push(element.placeholder);
        if (element.getAttribute('aria-label')) texts.push(element.getAttribute('aria-label'));
        if (element.name) texts.push(element.name);
        if (element.id) texts.push(element.id);
        if (element.title) texts.push(element.title);
        
        // Current value of the input (if it has content)
        if (element.value && element.value.trim() !== '') {
            texts.push(element.value);
        }
        
        // For SELECT elements, include the currently selected option's text
        if (element.tagName === 'SELECT') {
            const selectedOption = element.options[element.selectedIndex];
            if (selectedOption && selectedOption.text && selectedOption.text.trim() !== '') {
                texts.push(selectedOption.text);
                log('Select element', element.id || element.name || 'unnamed', 'has selected value:', selectedOption.text);
            }
        }
        
        // Look for associated label elements
        const labelText = findLabelText(element);
        if (labelText) texts.push(labelText);
        
        // For contenteditable, use a generic identifier
        if (element.getAttribute('contenteditable') !== null) {
            texts.push('contenteditable');
            // Try to get nearby label or context
            const nearbyText = getNearbyText(element);
            if (nearbyText) texts.push(nearbyText);
        }

        return texts.join(' ');
    }

    function findLabelText(element) {
        // Strategy 1: Look for label with matching 'for' attribute
        if (element.id) {
            const label = document.querySelector(`label[for="${element.id}"]`);
            if (label) {
                return label.textContent.trim();
            }
        }
        
        // Strategy 2: Look for label in parent/ancestor elements
        // Check up to 3 levels up for a label
        let currentNode = element.parentElement;
        let levelsToCheck = 3;
        
        while (currentNode && levelsToCheck > 0) {
            // Check if this parent contains a label
            const label = currentNode.querySelector('label');
            if (label) {
                return label.textContent.trim();
            }
            
            // Check if the parent itself is a label
            if (currentNode.tagName === 'LABEL') {
                return currentNode.textContent.trim();
            }
            
            currentNode = currentNode.parentElement;
            levelsToCheck--;
        }
        
        // Strategy 3: Look for nearby text in adjacent elements
        // Check previous sibling for labels or text
        if (element.previousElementSibling) {
            const prevLabel = element.previousElementSibling.querySelector('label');
            if (prevLabel) {
                return prevLabel.textContent.trim();
            }
            if (element.previousElementSibling.tagName === 'LABEL') {
                return element.previousElementSibling.textContent.trim();
            }
        }
        
        return null;
    }

    function getNearbyText(element) {
        // Try to find a label or nearby text for context
        const parent = element.parentElement;
        if (!parent) return '';
        
        const label = parent.querySelector('label');
        if (label) return label.textContent.trim();
        
        // Get first 50 chars of parent's text content
        return parent.textContent.trim().substring(0, 50);
    }

    function updateHighlight(shouldScroll = true) {
        log('updateHighlight() called, selectedIndex:', selectedIndex, 'shouldScroll:', shouldScroll);
        
        // Highlight all matched inputs
        matchedInputs.forEach((element, index) => {
            element.style.outline = (index === selectedIndex)
                ? "3px solid green" // selected
                : "2px solid red"; // not selected
            element.dataset.index = index;
        });

        // Only scroll if explicitly requested (Tab cycling or after debounce)
        if (shouldScroll && matchedInputs[selectedIndex]) {
            scrollIntoViewIfNeeded(matchedInputs[selectedIndex]);
        }

        // Check if the floating search box overlaps any matched element
        const container = document.getElementById('floatingSearchBoxContainer');
        if (!container) return;

        let boxRect = container.getBoundingClientRect();
        let newTop = null;
        
        matchedInputs.forEach(element => {
            let rect = element.getBoundingClientRect();
            // If there is any overlap, note the element's bottom
            if (boxRect.left < rect.right && boxRect.right > rect.left &&
                boxRect.top < rect.bottom && boxRect.bottom > rect.top) {
                let candidate = rect.bottom + 5; // 5px gap
                if (newTop === null || candidate > newTop) {
                    newTop = candidate;
                }
            }
        });
        
        if (newTop !== null) {
            container.style.top = newTop + "px";
            log('Repositioned search box to avoid overlap, new top:', newTop);
        }
    }

    function scrollIntoViewIfNeeded(element) {
        const rect = element.getBoundingClientRect();
        const isVisible = (
            rect.top >= 0 &&
            rect.left >= 0 &&
            rect.bottom <= window.innerHeight &&
            rect.right <= window.innerWidth
        );

        if (!isVisible) {
            log('Scrolling element into view');
            element.scrollIntoView({ behavior: 'smooth', block: 'center' });
        }
    }

    function updateCounter() {
        if (matchedInputs.length === 0) {
            counterDisplay.textContent = "No matches found";
            counterDisplay.style.color = "#999";
        } else {
            counterDisplay.textContent = `Match ${selectedIndex + 1} of ${matchedInputs.length} (Tab/Shift+Tab to cycle, Enter to focus)`;
            counterDisplay.style.color = "#666";
        }
    }

    function handleKeyPress(event) {
        log('Key pressed:', event.key);
        
        if (event.key === "Tab") {
            event.preventDefault();
            userHasInteracted = true; // User is explicitly cycling
            if (event.shiftKey) {
                // Cycle backwards
                selectedIndex = (selectedIndex - 1 + matchedInputs.length) % matchedInputs.length;
                log('Cycling backwards to index:', selectedIndex);
            } else {
                // Cycle forwards
                selectedIndex = (selectedIndex + 1) % matchedInputs.length;
                log('Cycling forwards to index:', selectedIndex);
            }
            updateHighlight(true); // true = scroll because user is actively cycling
            updateCounter();
        } else if (event.key === "Enter") {
            // Use Enter to focus on the selected input
            event.preventDefault();
            log('Enter pressed, focusing selected input');
            focusSelectedInput();
            removeSearchBox();
        } else if (event.key === "Escape") {
            log('Escape pressed, closing search box');
            removeSearchBox();
        } else {
            // User is typing - clear previous scroll timer and set new one
            if (scrollTimer) {
                clearTimeout(scrollTimer);
            }
            // Only scroll after user stops typing for 600ms
            scrollTimer = setTimeout(() => {
                if (matchedInputs.length > 0) {
                    log('User stopped typing, scrolling to current match');
                    scrollIntoViewIfNeeded(matchedInputs[selectedIndex]);
                }
            }, 600);
        }
    }

    function focusSelectedInput() {
        if (matchedInputs.length > 0) {
            const selectedElement = matchedInputs[selectedIndex];
            log('Focusing element:', selectedElement.tagName, selectedElement.id || selectedElement.name);
            selectedElement.focus();
            
            // Keep green outline for 2 seconds for visibility
            selectedElement.style.outline = "3px solid green";
            setTimeout(() => {
                if (document.activeElement === selectedElement) {
                    selectedElement.style.outline = "2px solid green";
                    setTimeout(() => {
                        selectedElement.style.outline = "";
                    }, 1500);
                }
            }, 500);
        }
    }

    function removeSearchBox() {
        log('removeSearchBox() called');
        
        if (!searchBoxActive) {
            log('Search box not active, ignoring');
            return;
        }

        // Clear any pending timers
        if (autoFocusTimer) {
            clearTimeout(autoFocusTimer);
            autoFocusTimer = null;
        }
        if (scrollTimer) {
            clearTimeout(scrollTimer);
            scrollTimer = null;
        }

        const container = document.getElementById('floatingSearchBoxContainer');
        if (container) {
            container.remove();
            log('Container removed from DOM');
        }
        
        matchedInputs.forEach(element => element.style.outline = "none");
        matchedInputs = [];
        userHasInteracted = false;
        
        document.removeEventListener("click", outsideClickHandler, true);
        log('Click handler removed');
        
        searchBoxActive = false;
        log('Setting searchBoxActive = false');
    }

    function outsideClickHandler(event) {
        log('Click detected, target:', event.target.tagName, event.target.id);
        
        const container = document.getElementById('floatingSearchBoxContainer');
        if (container && !container.contains(event.target)) {
            log('Click outside search box, closing');
            removeSearchBox();
        } else {
            log('Click inside search box, keeping open');
        }
    }

    log('Script loaded and ready. Press Ctrl+Shift+F to activate.');
})();