Element Selector Tool

Press Ctrl+E to get friendly CSS selectors for any element

目前為 2025-07-15 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Element Selector Tool
// @namespace    http://tampermonkey.net/
// @version      6.2
// @description  Press Ctrl+E to get friendly CSS selectors for any element
// @author       jamubc
// @match        *://*/*
// @grant        none
// @run-at       document-start
// @license      Apache 2.0
// @inject-into  content
// ==/UserScript==

(function() {
    'use strict';

    let active = false, overlay, tooltip, current;
    let initialized = false;

    function getSelector(el) {
        // Priority 1: ID (most reliable)
        if (el.id) return `#${el.id}`;

        // Priority 2: Data attributes (often used for testing/automation)
        const dataAttrs = Array.from(el.attributes).filter(a => a.name.startsWith('data-'));
        if (dataAttrs.length) {
            const key = dataAttrs.find(a => a.name.includes('test') || a.name.includes('id') || a.name.includes('name')) || dataAttrs[0];
            return `[${key.name}="${key.value}"]`;
        }

        // Priority 3: Unique class combination
        if (el.className && typeof el.className === 'string') {
            const classes = el.className.trim().split(/\s+/).filter(c => c && !c.match(/^(active|hover|focus|disabled)$/));
            if (classes.length) {
                const selector = `${el.tagName.toLowerCase()}.${classes.join('.')}`;
                // Check if selector is unique
                if (document.querySelectorAll(selector).length === 1) return selector;
            }
        }

        // Priority 4: Role or aria-label
        if (el.getAttribute('role')) return `[role="${el.getAttribute('role')}"]`;
        if (el.getAttribute('aria-label')) return `[aria-label="${el.getAttribute('aria-label')}"]`;

        // Priority 5: For common elements, use semantic approach
        const tag = el.tagName.toLowerCase();
        if (['button', 'input', 'select', 'textarea'].includes(tag)) {
            if (el.name) return `${tag}[name="${el.name}"]`;
            if (el.type) return `${tag}[type="${el.type}"]`;
        }

        // Last resort: Minimal path from nearest ID
        let path = [];
        let current = el;
        while (current && current !== document.body) {
            let selector = current.tagName.toLowerCase();
            if (current.id) {
                path.unshift(`#${current.id}`);
                break;
            }
            const parent = current.parentElement;
            if (parent) {
                const index = Array.from(parent.children).indexOf(current) + 1;
                selector += `:nth-child(${index})`;
            }
            path.unshift(selector);
            current = parent;
        }
        return path.join(' > ');
    }

    function highlight(el) {
        const rect = el.getBoundingClientRect();
        const selector = getSelector(el);

        // Thin precise border
        overlay.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;background:transparent;border:1px solid #0088ff;pointer-events:none;z-index:2147483647;display:block;box-sizing:border-box;outline:1px solid rgba(255,255,255,0.5);outline-offset:-2px`;

        // Build enhanced tree view
        let content = selector;

        // Always build tree view to show element hierarchy with attributes
        const buildTree = () => {
            const elements = [];
            let curr = el;

            // Collect elements up to root or nearest ID
            while (curr && curr !== document.body) {
                const attrs = [];

                // Build comprehensive element description
                const tag = curr.tagName.toLowerCase();
                const parts = [];
                
                // Always start with tag
                parts.push(`<span style="color:#6db3f2">${tag}</span>`);
                
                // Add ID if present
                if (curr.id) {
                    parts.push(`<span style="color:#86c1b9">#${curr.id}</span>`);
                }
                
                // Add classes (first 2-3 meaningful ones)
                if (curr.className && typeof curr.className === 'string') {
                    const classes = curr.className.trim().split(/\s+/)
                        .filter(c => c && !c.match(/^(active|hover|focus|disabled|selected|open|closed|ng-|css-)/))
                        .slice(0, 2);
                    if (classes.length > 0) {
                        parts.push(`<span style="color:#f0c674">.${classes.join('.')}</span>`);
                    }
                }
                
                // Add key attributes
                if (curr.getAttribute('role')) {
                    parts.push(`<span style="color:#cc99cc">[role=${curr.getAttribute('role')}]</span>`);
                }
                
                // Show actual data attributes
                const dataAttrs = Array.from(curr.attributes)
                    .filter(attr => attr.name.startsWith('data-'))
                    .slice(0, 2); // Show first 2 data attributes
                    
                dataAttrs.forEach(attr => {
                    let value = attr.value;
                    if (value.length > 12) value = value.substring(0, 10) + '..';
                    parts.push(`<span style="color:#cc99cc">[${attr.name}="${value}"]</span>`);
                });
                
                // Add text content for leaf nodes
                if (curr.textContent && curr.textContent.trim() && curr.children.length === 0) {
                    const text = curr.textContent.trim().substring(0, 15);
                    parts.push(`<span style="color:#b19cd9">"${text}${curr.textContent.trim().length > 15 ? '...' : ''}"</span>`);
                }
                
                attrs.push(parts.join(' '));

                elements.unshift({
                    tag: curr.tagName.toLowerCase(),
                    attrs: attrs,
                    element: curr
                });

                if (curr.id) break; // Stop at ID
                curr = curr.parentElement;
            }

            // Build tree display with proper tree characters
            return elements.map((item, i) => {
                const isTarget = i === elements.length - 1;
                const isRoot = i === 0;
                
                let line = '';
                
                // Build indent with vertical lines
                for (let j = 0; j < i; j++) {
                    line += j < i - 1 ? '│ ' : '';
                }
                
                // Add connector
                if (!isRoot) {
                    line += isTarget ? '└─ ' : '├─ ';
                }

                let display = item.attrs.join(' ') || item.tag;

                // Highlight target with better visual distinction
                if (isTarget) {
                    return `<span style="color:#666">${line}</span><span style="background:rgba(0,255,255,0.1);padding:1px 3px;border-radius:2px">${display}</span>`;
                }
                return `<span style="color:#666">${line}</span>${display}`;
            }).join('\n');
        };

        const tree = buildTree();

        // Truncate selector for display
        const displaySelector = selector.length > 60 ? selector.substring(0, 57) + '...' : selector;

        // Remove text preview - already shown in tree

        // Only show the selector if it's different from the last item in tree
        const lastTreeItem = tree.split('\n').pop();
        const lastTreeText = lastTreeItem.replace(/<[^>]*>/g, '').trim();
        const selectorDisplay = lastTreeText.includes(selector) || selector === lastTreeText.replace(/[└─\s]/g, '') 
            ? '' 
            : `\n<div style="margin-top:8px;padding-top:8px;border-top:1px solid #444"><div style="font-size:11px;color:#0ff">${displaySelector}</div></div>`;
        
        content = `${tree}${selectorDisplay}`;

        tooltip.innerHTML = content;
        tooltip.style.cssText = 'position:fixed;background:#000;color:#fff;padding:10px 12px;font:11px monospace;border-radius:4px;pointer-events:none;z-index:2147483647;display:block;max-width:500px;box-shadow:0 2px 8px rgba(0,0,0,0.4);line-height:1.5;white-space:pre-wrap';

        // Calculate tooltip dimensions after styling
        const tooltipRect = tooltip.getBoundingClientRect();
        const gap = 5;

        // Find best position (priority: top, bottom, right, left)
        let pos = null;

        // Try above
        if (rect.top - tooltipRect.height - gap > 0) {
            pos = {
                left: Math.min(Math.max(rect.left, gap), window.innerWidth - tooltipRect.width - gap),
                top: rect.top - tooltipRect.height - gap
            };
        }
        // Try below
        else if (rect.bottom + tooltipRect.height + gap < window.innerHeight) {
            pos = {
                left: Math.min(Math.max(rect.left, gap), window.innerWidth - tooltipRect.width - gap),
                top: rect.bottom + gap
            };
        }
        // Try right
        else if (rect.right + tooltipRect.width + gap < window.innerWidth) {
            pos = {
                left: rect.right + gap,
                top: Math.min(Math.max(rect.top, gap), window.innerHeight - tooltipRect.height - gap)
            };
        }
        // Try left
        else if (rect.left - tooltipRect.width - gap > 0) {
            pos = {
                left: rect.left - tooltipRect.width - gap,
                top: Math.min(Math.max(rect.top, gap), window.innerHeight - tooltipRect.height - gap)
            };
        }
        // Fallback: top-right corner of viewport
        else {
            pos = {
                left: window.innerWidth - tooltipRect.width - 20,
                top: 20
            };
        }

        tooltip.style.left = pos.left + 'px';
        tooltip.style.top = pos.top + 'px';
    }

    function toggle() {
        active = !active;
        document.body.style.cursor = active ? 'crosshair' : '';
        if (!active) {
            overlay.style.display = 'none';
            tooltip.style.display = 'none';
        }

        const notif = document.createElement('div');
        notif.textContent = active ? 'Selector mode ON (Press Escape to exit)' : 'Selector mode OFF';
        notif.style.cssText = 'position:fixed;top:20px;right:20px;background:#4CAF50;color:white;padding:10px 15px;border-radius:4px;z-index:2147483647;font-family:Arial';
        
        // Safari-safe notification appending
        try {
            document.body.appendChild(notif);
        } catch (e) {
            // Fallback for CSP issues
            document.documentElement.appendChild(notif);
        }
        setTimeout(() => {
            try {
                notif.remove();
            } catch (e) {
                // Fallback removal
                if (notif.parentNode) {
                    notif.parentNode.removeChild(notif);
                }
            }
        }, 2000);
    }

    // Safari-compatible initialization with CSP handling
    function safariCompatibleInit() {
        return new Promise((resolve) => {
            // Wait for body to be available
            const waitForBody = () => {
                if (document.body) {
                    resolve();
                } else {
                    setTimeout(waitForBody, 10);
                }
            };
            waitForBody();
        });
    }

    // Initialize function
    function init() {
        if (initialized) return;
        initialized = true;

        // Create elements with Safari-compatible approach
        overlay = document.createElement('div');
        tooltip = document.createElement('div');
        overlay.style.display = 'none';
        tooltip.style.display = 'none';
        
        // Use safer DOM manipulation for Safari
        try {
            document.body.appendChild(overlay);
            document.body.appendChild(tooltip);
        } catch (e) {
            // Fallback for CSP issues
            document.documentElement.appendChild(overlay);
            document.documentElement.appendChild(tooltip);
        }

        // Register shortcuts with native keyboard events
        document.addEventListener('keydown', (e) => {
            // Ctrl+E to toggle
            if (e.ctrlKey && e.key === 'e') {
                e.preventDefault();
                toggle();
            }
            // Escape to exit
            if (e.key === 'Escape' && active) {
                e.preventDefault();
                toggle();
            }
        });

        // Event listeners
        document.addEventListener('mouseover', e => {
            if (active && e.target !== overlay && e.target !== tooltip) {
                current = e.target;
                highlight(e.target);
            }
        });

        // Update position on scroll
        document.addEventListener('scroll', () => {
            if (active && current) {
                highlight(current);
            }
        }, true);

        // Update on window resize
        window.addEventListener('resize', () => {
            if (active && current) {
                highlight(current);
            }
        });

        document.addEventListener('click', e => {
            if (active && current) {
                e.preventDefault();
                e.stopPropagation();

                const selector = getSelector(current);
                
                // Try modern clipboard API first, fallback to execCommand
                const copyToClipboard = async (text) => {
                    try {
                        if (navigator.clipboard && navigator.clipboard.writeText) {
                            await navigator.clipboard.writeText(text);
                            return true;
                        }
                    } catch (err) {
                        // Fallback to execCommand
                    }
                    
                    // Fallback method for Safari and other browsers
                    const textarea = document.createElement('textarea');
                    textarea.value = text;
                    textarea.style.position = 'fixed';
                    textarea.style.opacity = '0';
                    textarea.style.pointerEvents = 'none';
                    document.body.appendChild(textarea);
                    textarea.select();
                    textarea.setSelectionRange(0, 99999);
                    
                    try {
                        const successful = document.execCommand('copy');
                        document.body.removeChild(textarea);
                        return successful;
                    } catch (err) {
                        document.body.removeChild(textarea);
                        return false;
                    }
                };
                
                copyToClipboard(selector).then(success => {
                    const notif = document.createElement('div');
                    notif.textContent = success ? 'Copied: ' + selector : 'Failed to copy: ' + selector;
                    notif.style.cssText = `position:fixed;top:20px;right:20px;background:${success ? '#4CAF50' : '#f44336'};color:white;padding:10px 15px;border-radius:4px;z-index:2147483647;font-family:Arial`;
                    
                    // Safari-safe notification appending
                    try {
                        document.body.appendChild(notif);
                    } catch (e) {
                        // Fallback for CSP issues
                        document.documentElement.appendChild(notif);
                    }
                    setTimeout(() => {
                        try {
                            notif.remove();
                        } catch (e) {
                            // Fallback removal
                            if (notif.parentNode) {
                                notif.parentNode.removeChild(notif);
                            }
                        }
                    }, 2000);
                });
            }
        });
    }

    // Safari-compatible initialization with better CSP handling
    function safariInit() {
        // For Safari, we need to be more careful about timing and CSP
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', () => {
                safariCompatibleInit().then(init);
            });
        } else {
            // DOM already loaded, but wait for body to be ready
            safariCompatibleInit().then(init);
        }
    }

    // Check if we're in Safari and use appropriate initialization
    const isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
    
    if (isSafari) {
        // Safari-specific initialization
        safariInit();
    } else {
        // Standard initialization for other browsers
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', init);
        } else {
            init();
        }
    }

    // Also reinitialize on navigation changes for SPAs
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            // Give the page time to render
            setTimeout(() => {
                if (!initialized || !document.body.contains(overlay)) {
                    initialized = false;
                    init();
                }
            }, 500);
        }
    }).observe(document, {subtree: true, childList: true});

    // Handle history navigation
    window.addEventListener('popstate', () => {
        setTimeout(() => {
            if (!initialized || !document.body.contains(overlay)) {
                initialized = false;
                init();
            }
        }, 500);
    });
})();