/* eslint-disable */ (function () { if (window.__ELEMENT_MARKER_INSTALLED__) return; window.__ELEMENT_MARKER_INSTALLED__ = true; const IS_MAIN = window === window.top; // ============================================================================ // Utility Functions // ============================================================================ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // ============================================================================ // Constants & Configuration // ============================================================================ const CONFIG = { DEFAULTS: { PREFS: { preferId: true, preferStableAttr: true, preferClass: true, }, SELECTOR_TYPE: 'css', LIST_MODE: false, }, Z_INDEX: { OVERLAY: 2147483646, HIGHLIGHTER: 2147483645, RECTS: 2147483644, }, COLORS: { PRIMARY: '#2563eb', SUCCESS: '#10b981', WARNING: '#f59e0b', DANGER: '#ef4444', HOVER: '#10b981', VERIFY: '#3b82f6', }, }; // ============================================================================ // Panel Host Module - Shadow DOM Management // ============================================================================ const PanelHost = (() => { let hostElement = null; let shadowRoot = null; const PANEL_STYLES = ` * { box-sizing: border-box; margin: 0; padding: 0; } .em-panel { width: 400px; background: #ffffff; border-radius: 12px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; padding: 20px; transition: opacity 150ms ease; } /* Header */ .em-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; user-select: none; } .em-title { font-size: 20px; font-weight: 500; color: #262626; } .em-header-actions { display: flex; gap: 4px; align-items: center; } .em-icon-btn { width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: #a3a3a3; cursor: pointer; transition: color 150ms ease; padding: 0; } .em-icon-btn:hover { color: #525252; } .em-icon-btn svg { width: 20px; height: 20px; stroke-width: 2; } /* Controls Row */ .em-controls { display: flex; gap: 8px; margin-bottom: 12px; } .em-select-wrapper { flex: 1; position: relative; } .em-select { width: 100%; height: 44px; padding: 0 40px 0 16px; background: #f5f5f5; color: #262626; font-size: 15px; border: none; border-radius: 10px; appearance: none; cursor: pointer; outline: none; font-family: inherit; font-weight: 400; } .em-select-wrapper::after { content: ''; position: absolute; right: 16px; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-left: 5px solid transparent; border-right: 5px solid transparent; border-top: 6px solid #737373; pointer-events: none; } .em-square-btn { width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: #f5f5f5; border: none; border-radius: 10px; cursor: pointer; transition: background 150ms ease; padding: 0; } .em-square-btn:hover { background: #e5e5e5; } .em-square-btn.active { background: #2563eb; } .em-square-btn.active svg { color: #ffffff; } .em-square-btn svg { width: 18px; height: 18px; color: #525252; stroke-width: 2; } /* Selector Display */ .em-selector-display { display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 12px 0 16px; background: #f5f5f5; border-radius: 10px; margin-bottom: 16px; } .em-selector-display svg { width: 18px; height: 18px; color: #a3a3a3; flex-shrink: 0; stroke-width: 2; } .em-selector-text { flex: 1; font-size: 14px; color: #525252; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: text; } .em-selector-nav { display: flex; gap: 2px; } .em-nav-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border: none; background: transparent; cursor: pointer; transition: background 150ms ease; border-radius: 6px; padding: 0; } .em-nav-btn:hover { background: #e5e5e5; } .em-nav-btn svg { width: 16px; height: 16px; color: #525252; stroke-width: 2; } /* Tabs */ .em-tabs { display: inline-flex; gap: 2px; padding: 2px; background: #f5f5f5; border-radius: 8px; margin-bottom: 16px; } .em-tab { padding: 6px 16px; font-size: 12px; font-weight: 500; color: #737373; background: transparent; border: none; border-radius: 6px; cursor: pointer; transition: all 150ms ease; } .em-tab:hover { color: #404040; } .em-tab.active { color: #262626; background: #ffffff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } /* Content */ .em-content { margin-bottom: 0; } #__em_tab_settings { max-height: min(60vh, 480px); overflow-y: auto; scrollbar-width: none; /* Firefox */ -ms-overflow-style: none; /* IE and Edge */ } #__em_tab_settings::-webkit-scrollbar { display: none; /* Chrome, Safari, Opera */ } .em-section-title { font-size: 13px; color: #737373; margin-bottom: 16px; font-weight: 400; } .em-attributes { display: flex; flex-direction: column; gap: 12px; } .em-attribute { display: flex; flex-direction: column; gap: 6px; } .em-attribute-label { font-size: 12px; color: #a3a3a3; font-weight: 400; } .em-attribute-value { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px 0 16px; background: #f5f5f5; border-radius: 10px; } .em-attribute-value.editable { padding: 0 16px; } .em-attribute-value svg { width: 18px; height: 18px; stroke-width: 2; cursor: pointer; transition: color 150ms ease; flex-shrink: 0; } .em-attribute-value svg.copy-icon { color: #a3a3a3; } .em-attribute-value svg.copy-icon:hover { color: #525252; } .em-attribute-value svg.copy-icon.disabled { color: #d4d4d4; cursor: default; } .em-attribute-text { flex: 1; font-size: 14px; color: #404040; user-select: text; } .em-attribute-text.empty { color: #a3a3a3; } .em-input { flex: 1; border: none; background: transparent; font-size: 14px; color: #404040; font-family: inherit; outline: none; padding: 0; height: 44px; } .em-input::placeholder { color: #a3a3a3; } /* Settings Panel */ .em-settings { display: flex; flex-direction: column; gap: 16px; } .em-settings-group { display: flex; flex-direction: column; gap: 8px; } .em-settings-label { font-size: 12px; font-weight: 500; color: #737373; } .em-checkbox-group { display: flex; flex-direction: column; gap: 10px; } .em-checkbox-label { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #404040; cursor: pointer; } .em-checkbox-label input[type="checkbox"] { width: 18px; height: 18px; cursor: pointer; margin: 0; } /* Action Buttons */ .em-actions { display: flex; gap: 8px; margin-top: 20px; } .em-btn { flex: 1; height: 40px; border: none; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 150ms ease; } .em-btn-primary { background: #2563eb; color: #ffffff; } .em-btn-primary:hover { background: #1d4ed8; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3); } .em-btn-success { background: #10b981; color: #ffffff; } .em-btn-success:hover { background: #059669; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); } .em-btn-ghost { background: #f5f5f5; color: #404040; } .em-btn-ghost:hover { background: #e5e5e5; } /* Footer */ .em-footer { font-size: 12px; color: #a3a3a3; text-align: center; margin-top: 16px; } .em-footer kbd { display: inline-block; padding: 2px 6px; background: #f5f5f5; border-radius: 4px; font-family: monospace; font-size: 11px; color: #737373; } /* Status */ .em-status { font-size: 13px; padding: 10px 12px; border-radius: 8px; margin-bottom: 12px; display: flex; align-items: center; gap: 6px; } .em-status.idle { display: none; } .em-status.running { background: rgba(37, 99, 235, 0.1); color: #2563eb; } .em-status.success { background: rgba(16, 185, 129, 0.1); color: #10b981; } .em-status.failure { background: rgba(239, 68, 68, 0.1); color: #ef4444; } /* Grid Layout */ .em-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; } .em-field { display: flex; flex-direction: column; gap: 6px; } .em-field-label { font-size: 12px; color: #a3a3a3; } .em-field-input { height: 40px; padding: 0 12px; background: #f5f5f5; border: none; border-radius: 8px; font-size: 14px; color: #404040; font-family: inherit; outline: none; } .em-field-input:focus { background: #e5e5e5; } /* Details/Accordion */ .em-details { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f5f5f5; } .em-details summary { cursor: pointer; font-size: 13px; font-weight: 600; color: #737373; padding: 8px 0; user-select: none; list-style: none; } .em-details summary::-webkit-details-marker { display: none; } .em-details summary:hover { color: #404040; } .em-details[open] summary { margin-bottom: 12px; } /* Dragging state */ body[data-em-dragging] { user-select: none !important; cursor: grabbing !important; } body[data-em-dragging] * { cursor: grabbing !important; } /* SVG Icons */ svg { fill: none; stroke: currentColor; } .em-drag-handle { cursor: grab; } .em-drag-handle:active { cursor: grabbing; } `; const PANEL_TEMPLATE = `

元素标注

Click an element to select

#1 Element

name
selector
-

Selector Preferences

`; function mount() { if (hostElement) return { host: hostElement, shadow: shadowRoot }; hostElement = document.createElement('div'); hostElement.id = '__element_marker_overlay'; Object.assign(hostElement.style, { position: 'fixed', top: '24px', right: '24px', zIndex: String(CONFIG.Z_INDEX.OVERLAY), pointerEvents: 'none', }); shadowRoot = hostElement.attachShadow({ mode: 'open' }); shadowRoot.innerHTML = `${PANEL_TEMPLATE}`; hostElement.querySelector = (...args) => shadowRoot.querySelector(...args); hostElement.querySelectorAll = (...args) => shadowRoot.querySelectorAll(...args); const panel = shadowRoot.querySelector('.em-panel'); if (panel) { panel.style.pointerEvents = 'auto'; } document.documentElement.appendChild(hostElement); return { host: hostElement, shadow: shadowRoot }; } function unmount() { if (hostElement?.parentNode) { hostElement.parentNode.removeChild(hostElement); } hostElement = null; shadowRoot = null; } function getHost() { return hostElement; } function getShadow() { return shadowRoot; } return { mount, unmount, getHost, getShadow, }; })(); // ============================================================================ // State Store Module - Centralized State Management // ============================================================================ const StateStore = (() => { const state = { selectorType: CONFIG.DEFAULTS.SELECTOR_TYPE, listMode: CONFIG.DEFAULTS.LIST_MODE, prefs: { ...CONFIG.DEFAULTS.PREFS }, activeTab: 'attributes', validation: { status: 'idle', message: '', }, validationHistory: [], // Last 5 validation results }; const listeners = new Set(); function init() { return state; } function get(key) { return key ? state[key] : state; } function set(partial) { const changed = {}; Object.keys(partial).forEach((key) => { if (JSON.stringify(state[key]) !== JSON.stringify(partial[key])) { changed[key] = true; state[key] = partial[key]; } }); if (Object.keys(changed).length === 0) return; if (changed.validation) { updateValidationUI(); } if (changed.activeTab) { updateTabUI(); } if (changed.listMode) { updateListModeUI(); } if (changed.validationHistory) { updateValidationHistoryUI(); } notifyListeners(); } function subscribe(callback) { listeners.add(callback); return () => listeners.delete(callback); } function notifyListeners() { listeners.forEach((cb) => { try { cb(state); } catch (err) { console.error('[StateStore] Listener error:', err); } }); } function updateValidationUI() { const statusEl = PanelHost.getShadow()?.getElementById('__em_status'); if (!statusEl) return; const { status, message } = state.validation; statusEl.className = `em-status ${status}`; statusEl.textContent = message; } function updateListModeUI() { const shadow = PanelHost.getShadow(); if (!shadow) return; const btn = shadow.getElementById('__em_toggle_list'); if (!btn) return; if (state.listMode) { btn.classList.add('active'); } else { btn.classList.remove('active'); } } function updateTabUI() { const shadow = PanelHost.getShadow(); if (!shadow) return; const tabs = shadow.querySelectorAll('.em-tab'); tabs.forEach((tab) => { if (tab.dataset.tab === state.activeTab) { tab.classList.add('active'); } else { tab.classList.remove('active'); } }); const attrContent = shadow.getElementById('__em_tab_attributes'); const executeContent = shadow.getElementById('__em_tab_execute'); if (attrContent) attrContent.style.display = state.activeTab === 'attributes' ? 'block' : 'none'; if (executeContent) executeContent.style.display = state.activeTab === 'execute' ? 'block' : 'none'; // Sync interaction mode when tab changes syncInteractionMode(); } function updateValidationHistoryUI() { const shadow = PanelHost.getShadow(); if (!shadow) return; const historyContainer = shadow.getElementById('__em_execution_history'); const historyList = shadow.getElementById('__em_history_list'); if (!historyContainer || !historyList) return; if (state.validationHistory.length === 0) { historyContainer.style.display = 'none'; return; } historyContainer.style.display = 'block'; historyList.innerHTML = state.validationHistory .slice(-5) .reverse() .map((entry) => { const icon = entry.success ? '✓' : '✗'; const color = entry.success ? '#10b981' : '#ef4444'; const timestamp = new Date(entry.timestamp).toLocaleTimeString(); return `
${icon} ${entry.action} ${timestamp}
`; }) .join(''); } return { init, get, set, subscribe, }; })(); // ============================================================================ // Drag Controller Module // ============================================================================ const DragController = (() => { let dragging = false; let startPos = { x: 0, y: 0 }; let startOffset = { top: 0, right: 0 }; function init(handleElement) { if (!handleElement) return; handleElement.addEventListener('mousedown', onDragStart); } function onDragStart(event) { event.preventDefault(); dragging = true; const host = PanelHost.getHost(); if (!host) return; startPos = { x: event.clientX, y: event.clientY }; startOffset = { top: parseInt(host.style.top) || 0, right: parseInt(host.style.right) || 0, }; document.addEventListener('mousemove', onDragMove, { capture: true, passive: false }); document.addEventListener('mouseup', onDragEnd, { capture: true, passive: false }); document.body.setAttribute('data-em-dragging', 'true'); } function onDragMove(event) { if (!dragging) return; event.preventDefault(); event.stopPropagation(); const host = PanelHost.getHost(); if (!host) return; const deltaX = event.clientX - startPos.x; const deltaY = event.clientY - startPos.y; const newTop = Math.max(8, startOffset.top + deltaY); const newRight = Math.max(8, startOffset.right - deltaX); host.style.top = `${newTop}px`; host.style.right = `${newRight}px`; } function onDragEnd(event) { if (!dragging) return; event.preventDefault(); event.stopPropagation(); dragging = false; document.removeEventListener('mousemove', onDragMove, { capture: true }); document.removeEventListener('mouseup', onDragEnd, { capture: true }); document.body.removeAttribute('data-em-dragging'); } function destroy() { if (dragging) { onDragEnd(new MouseEvent('mouseup')); } } return { init, destroy }; })(); // [继续下一部分...] // ============================================================================ // Selector Engine - Heuristic Selector Generation // ============================================================================ function generateSelector(el) { if (!(el instanceof Element)) return ''; const prefs = StateStore.get('prefs'); if (prefs.preferId && el.id) { const idSel = `#${CSS.escape(el.id)}`; if (isDeepSelectorUnique(idSel, el)) return idSel; } if (prefs.preferStableAttr) { const attrNames = [ 'data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy', 'name', 'title', 'alt', 'aria-label', ]; const tag = el.tagName.toLowerCase(); for (const attr of attrNames) { const v = el.getAttribute(attr); if (!v) continue; const attrSel = `[${attr}="${CSS.escape(v)}"]`; const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel; if (isDeepSelectorUnique(testSel, el)) return testSel; } } if (prefs.preferClass) { try { const classes = Array.from(el.classList || []).filter( (c) => c && /^[a-zA-Z0-9_-]+$/.test(c), ); const tag = el.tagName.toLowerCase(); for (const cls of classes) { const sel = `.${CSS.escape(cls)}`; if (isDeepSelectorUnique(sel, el)) return sel; } for (const cls of classes) { const sel = `${tag}.${CSS.escape(cls)}`; if (isDeepSelectorUnique(sel, el)) return sel; } for (let i = 0; i < Math.min(classes.length, 3); i++) { for (let j = i + 1; j < Math.min(classes.length, 3); j++) { const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`; if (isDeepSelectorUnique(sel, el)) return sel; } } } catch {} } if (prefs.preferStableAttr) { try { let cur = el; const anchorAttrs = [ 'id', 'data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy', 'name', ]; // Detect shadow DOM boundary const root = el.getRootNode(); const isShadowElement = root instanceof ShadowRoot; const boundary = isShadowElement ? root.host : document.body; while (cur && cur !== boundary) { if (cur.id) { const anchor = `#${CSS.escape(cur.id)}`; if (isDeepSelectorUnique(anchor, cur)) { const rel = buildPathFromAncestor(cur, el); const composed = rel ? `${anchor} ${rel}` : anchor; if (isDeepSelectorUnique(composed, el)) return composed; } } for (const attr of anchorAttrs) { const val = cur.getAttribute(attr); if (!val) continue; const aSel = `[${attr}="${CSS.escape(val)}"]`; if (isDeepSelectorUnique(aSel, cur)) { const rel = buildPathFromAncestor(cur, el); const composed = rel ? `${aSel} ${rel}` : aSel; if (isDeepSelectorUnique(composed, el)) return composed; } } cur = cur.parentElement; } } catch {} } return buildFullPath(el); } function buildPathFromAncestor(ancestor, target) { const segs = []; let cur = target; // Detect if we're inside shadow DOM const root = target.getRootNode(); const isShadowElement = root instanceof ShadowRoot; const boundary = isShadowElement ? root.host : document.body; while (cur && cur !== ancestor && cur !== boundary) { let seg = cur.tagName.toLowerCase(); const parent = cur.parentElement; if (parent) { const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName); if (siblings.length > 1) { seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`; } } segs.unshift(seg); cur = parent; // Stop if we've reached the shadow root host if (isShadowElement && cur === boundary) { break; } } return segs.join(' > '); } function buildFullPath(el) { let path = ''; let current = el; // Detect if the element is inside a shadow DOM const root = el.getRootNode(); const isShadowElement = root instanceof ShadowRoot; // Determine the boundary where we should stop traversing const boundary = isShadowElement ? root.host : document.body; while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) { let sel = current.tagName.toLowerCase(); const parent = current.parentElement; if (parent) { const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName); if (siblings.length > 1) { sel += `:nth-of-type(${siblings.indexOf(current) + 1})`; } } path = path ? `${sel} > ${path}` : sel; current = parent; // Stop if we've reached the shadow root host if (isShadowElement && current === boundary) { break; } } // For shadow DOM elements, don't prepend "body >" // The selector should be relative within the shadow tree if (isShadowElement) { return path || el.tagName.toLowerCase(); } // For light DOM elements, keep the original behavior return path ? `body > ${path}` : 'body'; } function generateXPath(el) { if (!(el instanceof Element)) return ''; if (el.id) return `//*[@id="${el.id}"]`; const segs = []; let cur = el; while (cur && cur.nodeType === 1 && cur !== document.documentElement) { const tag = cur.tagName.toLowerCase(); if (cur.id) { segs.unshift(`//*[@id="${cur.id}"]`); break; } let i = 1; let sib = cur; while ((sib = sib.previousElementSibling)) { if (sib.tagName.toLowerCase() === tag) i++; } segs.unshift(`${tag}[${i}]`); cur = cur.parentElement; } return segs[0]?.startsWith('//*') ? segs.join('/') : '//' + segs.join('/'); } function generateListSelector(target) { const list = computeElementList(target); const selected = list?.[0] || target; const parent = selected.parentElement; if (!parent) return generateSelector(target); const parentSel = generateSelector(parent); const childRel = generateSelectorWithinRoot(selected, parent); return parentSel && childRel ? `${parentSel} ${childRel}` : generateSelector(target); } function generateSelectorWithinRoot(el, root) { if (!(el instanceof Element)) return ''; const tag = el.tagName.toLowerCase(); // Use isDeepSelectorUnique for ID to support shadow DOM elements if (el.id) { const idSel = `#${CSS.escape(el.id)}`; if (isDeepSelectorUnique(idSel, el)) return idSel; } const attrNames = [ 'data-testid', 'data-testId', 'data-test', 'data-qa', 'data-cy', 'name', 'title', 'alt', 'aria-label', ]; // Use isDeepSelectorUnique for attributes to support shadow DOM elements for (const attr of attrNames) { const v = el.getAttribute(attr); if (!v) continue; const aSel = `[${attr}="${CSS.escape(v)}"]`; const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${aSel}` : aSel; if (isDeepSelectorUnique(testSel, el)) return testSel; } try { const classes = Array.from(el.classList || []).filter((c) => c && /^[a-zA-Z0-9_-]+$/.test(c)); // Use isDeepSelectorUnique for classes to support shadow DOM elements for (const cls of classes) { const sel = `.${CSS.escape(cls)}`; if (isDeepSelectorUnique(sel, el)) return sel; } for (const cls of classes) { const sel = `${tag}.${CSS.escape(cls)}`; if (isDeepSelectorUnique(sel, el)) return sel; } } catch {} return buildPathFromAncestor(root, el); } function getAccessibleName(el) { try { const labelledby = el.getAttribute('aria-labelledby'); if (labelledby) { const labelEl = document.getElementById(labelledby); if (labelEl) return (labelEl.textContent || '').trim(); } const ariaLabel = el.getAttribute('aria-label'); if (ariaLabel) return ariaLabel.trim(); if (el.id) { const label = document.querySelector(`label[for="${el.id}"]`); if (label) return (label.textContent || '').trim(); } const parentLabel = el.closest('label'); if (parentLabel) return (parentLabel.textContent || '').trim(); return ( el.getAttribute('placeholder') || el.getAttribute('value') || el.textContent || '' ).trim(); } catch { return ''; } } // ============================================================================ // List Mode Utilities // ============================================================================ function getAllSiblings(el, selector) { const siblings = [el]; const validate = (element) => { const isSameTag = el.tagName === element.tagName; let ok = isSameTag; if (selector) { try { ok = ok && !!element.querySelector(selector); } catch {} } return ok; }; let next = el; let prev = el; let elementIndex = 1; while ((prev = prev?.previousElementSibling)) { if (validate(prev)) { elementIndex += 1; siblings.unshift(prev); } } while ((next = next?.nextElementSibling)) { if (validate(next)) siblings.push(next); } return { elements: siblings, index: elementIndex }; } function getElementList(el, maxDepth = 50, paths = []) { if (maxDepth === 0 || !el || el.tagName === 'BODY') return null; let selector = el.tagName.toLowerCase(); const { elements, index } = getAllSiblings(el, paths.join(' > ')); let siblings = elements; if (index !== 1) selector += `:nth-of-type(${index})`; paths.unshift(selector); if (siblings.length === 1) { siblings = getElementList(el.parentElement, maxDepth - 1, paths); } return siblings; } function computeElementList(target) { try { return getElementList(target) || [target]; } catch { return [target]; } } // ============================================================================ // Deep Query (Shadow DOM Support) // ============================================================================ function* walkAllNodesDeep(root) { const stack = [root]; let count = 0; const MAX = 10000; while (stack.length) { const node = stack.pop(); if (!node || ++count > MAX) continue; // Skip overlay elements to prevent panel self-highlighting if (isOverlayElement(node)) { continue; } yield node; try { if (node.children) { const children = Array.from(node.children); for (let i = children.length - 1; i >= 0; i--) { stack.push(children[i]); } } if (node.shadowRoot?.children) { const srChildren = Array.from(node.shadowRoot.children); for (let i = srChildren.length - 1; i >= 0; i--) { stack.push(srChildren[i]); } } } catch {} } } function queryAllDeep(selector) { const results = []; for (const node of walkAllNodesDeep(document)) { if (!(node instanceof Element)) continue; try { if (node.matches(selector)) results.push(node); } catch {} } return results; } /** * Check if a selector uniquely identifies the target element across the entire DOM tree, * including shadow DOM boundaries. * * This function uses queryAllDeep to traverse both light DOM and shadow DOM, * ensuring that selectors work correctly for elements inside shadow roots. * * @param {string} selector - The CSS selector to test * @param {Element} target - The target element that should be uniquely identified * @returns {boolean} True if the selector matches exactly one element and it's the target */ function isDeepSelectorUnique(selector, target) { if (!selector || !(target instanceof Element)) return false; try { const matches = queryAllDeep(selector); return matches.length === 1 && matches[0] === target; } catch (error) { return false; } } function evaluateXPathAll(xpath) { try { const arr = []; const res = document.evaluate( xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null, ); for (let i = 0; i < res.snapshotLength; i++) { const n = res.snapshotItem(i); // Filter out overlay elements to prevent panel self-highlighting if (n?.nodeType === 1 && !isOverlayElement(n)) { arr.push(n); } } return arr; } catch { return []; } } // ============================================================================ // Highlighter & Rects Management // ============================================================================ const STATE = { active: false, hoverEl: null, selectedEl: null, box: null, highlighter: null, listenersAttached: false, rectsHost: null, hoveredList: [], verifyRectsActive: false, // Track if verify rects are showing // Performance optimization: rAF throttling for hover hoverRafId: null, lastHoverTarget: null, // DOM pooling for rect elements rectPool: [], rectPoolUsed: 0, }; function ensureHighlighter() { if (STATE.highlighter) return STATE.highlighter; const hl = document.createElement('div'); hl.id = '__element_marker_highlight'; Object.assign(hl.style, { position: 'fixed', zIndex: String(CONFIG.Z_INDEX.HIGHLIGHTER), pointerEvents: 'none', border: `2px solid ${CONFIG.COLORS.HOVER}`, borderRadius: '4px', boxShadow: `0 0 0 2px ${CONFIG.COLORS.HOVER}33`, transition: 'all 100ms ease-out', }); document.documentElement.appendChild(hl); STATE.highlighter = hl; return hl; } function ensureRectsHost() { if (STATE.rectsHost) return STATE.rectsHost; const host = document.createElement('div'); host.id = '__element_marker_rects'; Object.assign(host.style, { position: 'fixed', zIndex: String(CONFIG.Z_INDEX.RECTS), pointerEvents: 'none', inset: '0', }); document.documentElement.appendChild(host); STATE.rectsHost = host; return host; } function moveHighlighterTo(el) { const hl = ensureHighlighter(); const r = el.getBoundingClientRect(); hl.style.left = `${r.left}px`; hl.style.top = `${r.top}px`; hl.style.width = `${r.width}px`; hl.style.height = `${r.height}px`; hl.style.display = 'block'; } function clearHighlighter() { if (STATE.highlighter) STATE.highlighter.style.display = 'none'; // Only clear hover rects, not verify rects if (!STATE.verifyRectsActive) { clearRects(); } } function clearRects() { // Hide all pooled rect boxes instead of destroying them const used = STATE.rectPoolUsed || 0; for (let i = 0; i < used; i++) { const box = STATE.rectPool[i]; if (box) box.style.display = 'none'; } STATE.rectPoolUsed = 0; STATE.verifyRectsActive = false; // Invalidate lastHoverTarget so next hover will redraw even on same element STATE.lastHoverTarget = null; } /** * Get or create a rect box from the pool * @param {HTMLElement} host - The container element * @param {number} index - The pool index * @returns {HTMLDivElement} The rect box element */ function getOrCreateRectBox(host, index) { let box = STATE.rectPool[index]; if (!box) { box = document.createElement('div'); Object.assign(box.style, { position: 'fixed', pointerEvents: 'none', borderRadius: '4px', transition: 'all 100ms ease-out', display: 'none', }); STATE.rectPool[index] = box; } // Ensure the box is attached to the host if (!box.isConnected) { host.appendChild(box); } return box; } // Maximum rect pool size to prevent memory bloat const MAX_RECT_POOL_SIZE = 100; /** * Draw rect boxes with pooling optimization * @param {Array<{x: number, y: number, width: number, height: number}>} rects - Rect data * @param {Object} options - Drawing options * @param {boolean} options.isVerify - Whether this is a verify highlight (affects verifyRectsActive) */ function drawRectBoxes( rects, { color = CONFIG.COLORS.HOVER, dashed = true, offsetX = 0, offsetY = 0, isVerify = false } = {}, ) { const host = ensureRectsHost(); const prevUsed = STATE.rectPoolUsed || 0; // Limit rect count to prevent memory bloat const count = Math.min(Array.isArray(rects) ? rects.length : 0, MAX_RECT_POOL_SIZE); // Update or show rect boxes for (let i = 0; i < count; i++) { const r = rects[i]; if (!r) continue; const x = Number.isFinite(r.left) ? r.left : Number.isFinite(r.x) ? r.x : 0; const y = Number.isFinite(r.top) ? r.top : Number.isFinite(r.y) ? r.y : 0; const w = Number.isFinite(r.width) ? r.width : 0; const h = Number.isFinite(r.height) ? r.height : 0; const box = getOrCreateRectBox(host, i); Object.assign(box.style, { left: `${offsetX + x}px`, top: `${offsetY + y}px`, width: `${w}px`, height: `${h}px`, border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`, boxShadow: `0 0 0 2px ${color}22`, display: 'block', }); } // Hide excess boxes from previous render for (let i = count; i < prevUsed; i++) { const box = STATE.rectPool[i]; if (box) box.style.display = 'none'; } STATE.rectPoolUsed = count; // Reset verifyRectsActive for hover operations (so clearHighlighter works correctly) // Only set to true when isVerify is explicitly true STATE.verifyRectsActive = isVerify; } function drawRects(elements, color = CONFIG.COLORS.HOVER, dashed = true, isVerify = false) { const rects = elements.map((el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height }; }); drawRectBoxes(rects, { color, dashed, isVerify }); } // ============================================================================ // Interaction Logic // ============================================================================ function isInsidePanel(target) { const shadow = PanelHost.getShadow(); return !!shadow && target instanceof Node && shadow.contains(target); } /** * Check if a node belongs to the element marker overlay (panel host or its shadow DOM) * This is used to filter out overlay elements from query results to prevent self-highlighting * * @param {Node} node - The node to check * @returns {boolean} True if the node is part of the overlay */ function isOverlayElement(node) { if (!(node instanceof Node)) return false; const host = PanelHost.getHost(); if (!host) return false; // Check if node is the panel host itself if (node === host) return true; // Check if node is within the shadow DOM of the panel host const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null; return root instanceof ShadowRoot && root.host === host; } /** * Filter out overlay elements from an array of elements * This ensures that panel components are never included in highlight/verification results * * @param {Array} elements - Array of elements to filter * @returns {Array} Filtered array without overlay elements */ function filterOverlayElements(elements) { if (!Array.isArray(elements)) return []; return elements.filter((node) => !isOverlayElement(node)); } /** * Get the effective event target for page element selection, considering shadow DOM boundaries. * * This function resolves the real target element from a pointer event by walking the * composed path (if available) to find the innermost page element, skipping overlay elements. * * Background: * - When events bubble up from inside shadow DOM, they get "retargeted" at shadow boundaries * - By the time a window-level listener receives the event, ev.target points to the shadow host * - composedPath() exposes the original event path before retargeting * - This allows us to select elements inside shadow DOM (e.g., internals) * * IMPORTANT: This function should only be called AFTER verifying the event is not from * overlay UI (panel buttons, etc). Otherwise it will filter out overlay elements and break * panel interactions. * * @param {Event} ev - The pointer event (mousemove, click, etc.) * @returns {Element|null} The innermost non-overlay page element, or null if none found */ function getDeepPageTarget(ev) { if (!ev) return null; // Try to walk the composed path to find the innermost non-overlay element try { const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null; if (Array.isArray(path) && path.length > 0) { // Walk from innermost to outermost, find the first real page element for (const node of path) { if (node instanceof Element && !isOverlayElement(node)) { return node; } } } } catch (error) { // composedPath() may throw in some edge cases (e.g., detached nodes) // Fall through to use ev.target } // Fallback: use ev.target if composedPath is unavailable or all nodes were filtered const fallback = ev.target instanceof Element ? ev.target : null; // If fallback is overlay, return null (caller should handle this case) if (fallback && !isOverlayElement(fallback)) { return fallback; } return null; } // Store pending hover event for rAF processing let pendingHoverEvent = null; /** * Process mouse move event - the actual hover update logic * Separated from onMouseMove for rAF throttling */ function processMouseMove(ev) { if (!STATE.active) return; const rawTarget = ev?.target; if (!(rawTarget instanceof Element)) { STATE.hoverEl = null; STATE.lastHoverTarget = null; clearHighlighter(); return; } const host = PanelHost.getHost(); if ((host && rawTarget === host) || isInsidePanel(rawTarget)) { STATE.hoverEl = null; STATE.lastHoverTarget = null; clearHighlighter(); return; } const target = getDeepPageTarget(ev) || rawTarget; STATE.hoverEl = target; // Get current listMode let listMode = false; try { listMode = !!StateStore.get('listMode'); } catch {} // Skip update if target and mode haven't changed const last = STATE.lastHoverTarget; if (last && last.element === target && last.listMode === listMode) { return; } STATE.lastHoverTarget = { element: target, listMode }; if (!IS_MAIN) { try { const list = listMode ? computeElementList(target) || [target] : [target]; const rects = list.map((el) => { const r = el.getBoundingClientRect(); return { x: r.left, y: r.top, width: r.width, height: r.height }; }); // Performance: Don't generate selector on hover (defer to click) window.top.postMessage({ type: 'em_hover', rects }, '*'); } catch {} return; } if (listMode) { STATE.hoveredList = computeElementList(target) || [target]; drawRects(STATE.hoveredList); } else { moveHighlighterTo(target); } } /** * Mouse move handler with rAF throttling * Ensures hover updates are batched to animation frame rate */ function onMouseMove(ev) { if (!STATE.active) return; // Store the latest event pendingHoverEvent = ev; // Skip if already scheduled if (STATE.hoverRafId != null) return; // Schedule processing on next animation frame STATE.hoverRafId = requestAnimationFrame(() => { STATE.hoverRafId = null; const latest = pendingHoverEvent; pendingHoverEvent = null; if (!latest) return; processMouseMove(latest); }); } // ============================================================================ // Event Listeners Management // ============================================================================ function attachPointerListeners() { if (STATE.listenersAttached) return; window.addEventListener('mousemove', onMouseMove, true); window.addEventListener('click', onClick, true); STATE.listenersAttached = true; } function detachPointerListeners() { if (!STATE.listenersAttached) return; window.removeEventListener('mousemove', onMouseMove, true); window.removeEventListener('click', onClick, true); STATE.listenersAttached = false; } function attachKeyboardListener() { window.addEventListener('keydown', onKeyDown, true); } function detachKeyboardListener() { window.removeEventListener('keydown', onKeyDown, true); } function syncInteractionMode() { if (!STATE.active) return; const activeTab = StateStore.get('activeTab'); if (activeTab === 'execute') { // In execute mode, detach pointer listeners to allow real interactions // but keep keyboard listener for Esc key detachPointerListeners(); // Only clear the hover highlighter, not the verification rects if (STATE.highlighter) STATE.highlighter.style.display = 'none'; } else { // In attributes mode, attach all listeners for element selection attachPointerListeners(); } } // ============================================================================ // Event Handlers // ============================================================================ function onClick(ev) { if (!STATE.active) return; // First, use the raw ev.target to check for overlay UI // This ensures panel buttons and other UI elements remain interactive const rawTarget = ev.target; const host = PanelHost.getHost(); // Check if raw target is the panel host itself or inside the shadow DOM // IMPORTANT: Return early WITHOUT preventDefault to allow overlay button clicks if ((host && rawTarget === host) || isInsidePanel(rawTarget)) { return; } // Now we know it's a page element, prevent default and get deep target ev.preventDefault(); ev.stopPropagation(); if (!(rawTarget instanceof Element)) return; // Get the deep target (considering shadow DOM) after confirming it's not overlay const target = getDeepPageTarget(ev) || rawTarget; if (!IS_MAIN) { try { const selectorType = StateStore.get('selectorType'); const listMode = StateStore.get('listMode'); const sel = selectorType === 'xpath' ? generateXPath(target) : listMode ? generateListSelector(target) : generateSelector(target); window.top.postMessage({ type: 'em_click', innerSel: sel }, '*'); } catch {} return; } setSelection(target); } function onKeyDown(e) { if (!STATE.active) return; // Check if the focused element is inside the panel - if so, don't handle selection keys if (isInsidePanel(e.target)) { // Key event is from panel, don't interfere if (e.key !== 'Escape') return; // Still allow Escape to close } // In execute mode, only handle Escape to close - don't intercept other keys // This allows real page interactions (typing, scrolling, etc.) const activeTab = StateStore.get('activeTab'); if (activeTab === 'execute') { if (e.key === 'Escape') { e.preventDefault(); stop(); } return; // Don't intercept Space/Arrow keys in execute mode } if (e.key === 'Escape') { e.preventDefault(); stop(); } else if (e.key === ' ' || e.code === 'Space') { e.preventDefault(); const t = STATE.hoverEl || STATE.selectedEl; if (t) setSelection(t); } else if (e.key === 'ArrowUp') { e.preventDefault(); const base = STATE.selectedEl || STATE.hoverEl; if (base?.parentElement) setSelection(base.parentElement); } else if (e.key === 'ArrowDown') { e.preventDefault(); const base = STATE.selectedEl || STATE.hoverEl; if (base?.firstElementChild) setSelection(base.firstElementChild); } } function setSelection(el) { if (!(el instanceof Element)) return; STATE.selectedEl = el; const selectorType = StateStore.get('selectorType'); const listMode = StateStore.get('listMode'); const sel = selectorType === 'xpath' ? generateXPath(el) : listMode ? generateListSelector(el) : generateSelector(el); const name = getAccessibleName(el) || el.tagName.toLowerCase(); const selectorText = STATE.box?.querySelector('#__em_selector'); const inputName = STATE.box?.querySelector('#__em_name'); const selectorDisplay = STATE.box?.querySelector('#__em_selector_text'); if (selectorText) selectorText.textContent = sel; if (selectorDisplay) selectorDisplay.textContent = sel; if (inputName && !inputName.value) inputName.value = name; moveHighlighterTo(el); } // ============================================================================ // Validation Logic // ============================================================================ /** * Verify selector by highlighting only (non-destructive) */ async function verifyHighlightOnly() { try { const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); if (!selector) return; StateStore.set({ validation: { status: 'running', message: 'Verifying selector...' }, }); const selectorType = StateStore.get('selectorType'); const listMode = StateStore.get('listMode'); const effectiveType = listMode ? 'css' : selectorType; // Query for matches const matches = effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector); // Additional defense: filter out any overlay elements that might have slipped through const filteredMatches = filterOverlayElements(matches); if (!filteredMatches || filteredMatches.length === 0) { StateStore.set({ validation: { status: 'failure', message: 'No elements found' }, }); return; } // Scroll first match into view const primaryMatch = filteredMatches[0]; if (primaryMatch) { primaryMatch.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth', }); } await sleep(200); // Highlight matches with isVerify=true to prevent clearing on hover drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false, true); StateStore.set({ validation: { status: 'success', message: `Found ${filteredMatches.length} element${filteredMatches.length > 1 ? 's' : ''}`, }, }); // Auto-clear highlight after 2 seconds setTimeout(() => { clearRects(); StateStore.set({ validation: { status: 'idle', message: '' }, }); }, 2000); } catch (error) { console.error('[verifyHighlightOnly] error:', error); StateStore.set({ validation: { status: 'failure', message: error.message || 'Verification failed' }, }); } } /** * Execute action on selector (destructive) */ async function verifySelectorNow() { try { const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); if (!selector) return; StateStore.set({ validation: { status: 'running', message: 'Executing action...' }, }); const selectorType = StateStore.get('selectorType'); const listMode = StateStore.get('listMode'); const effectiveType = listMode ? 'css' : selectorType; const matches = effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector); // Additional defense: filter out any overlay elements that might have slipped through const filteredMatches = filterOverlayElements(matches); if (!filteredMatches || filteredMatches.length === 0) { StateStore.set({ validation: { status: 'failure', message: 'No elements found' }, }); return; } drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false); const action = STATE.box?.querySelector('#__em_action')?.value || 'hover'; const payload = { type: 'element_marker_validate', selector, selectorType: effectiveType, action, listMode, }; // Action-specific parameters with validation if (action === 'type_text') { const actionText = String( STATE.box?.querySelector('#__em_action_text')?.value || '', ).trim(); if (!actionText) { StateStore.set({ validation: { status: 'failure', message: 'Text is required for type_text' }, }); return; } payload.text = actionText; } if (action === 'press_keys') { const actionKeys = String( STATE.box?.querySelector('#__em_action_keys')?.value || '', ).trim(); if (!actionKeys) { StateStore.set({ validation: { status: 'failure', message: 'Keys are required for press_keys' }, }); return; } payload.keys = actionKeys; } if (action === 'scroll') { const direction = STATE.box?.querySelector('#__em_scroll_direction')?.value || 'down'; const rawAmount = Number(STATE.box?.querySelector('#__em_scroll_distance')?.value); // Clamp to 1-10 range (backend expects ticks, not pixels) const amount = Math.max( 1, Math.min(Math.round(Number.isFinite(rawAmount) ? rawAmount : 3), 10), ); payload.scrollDirection = direction; payload.scrollAmount = amount; } if (['left_click', 'double_click', 'right_click'].includes(action)) { payload.modifiers = { altKey: !!STATE.box?.querySelector('#__em_mod_alt')?.checked, ctrlKey: !!STATE.box?.querySelector('#__em_mod_ctrl')?.checked, metaKey: !!STATE.box?.querySelector('#__em_mod_meta')?.checked, shiftKey: !!STATE.box?.querySelector('#__em_mod_shift')?.checked, }; payload.button = STATE.box?.querySelector('#__em_btn')?.value || 'left'; payload.waitForNavigation = !!STATE.box?.querySelector('#__em_wait_nav')?.checked; payload.timeoutMs = Number(STATE.box?.querySelector('#__em_nav_timeout')?.value) || 3000; } const res = await chrome.runtime.sendMessage(payload); const success = !!res?.tool?.ok; const newEntry = { action, success, timestamp: Date.now(), matchCount: filteredMatches.length, }; const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5); if (res?.tool?.ok) { StateStore.set({ validation: { status: 'success', message: `✓ 验证成功 (匹配 ${filteredMatches.length} 个元素)`, }, validationHistory: history, }); } else { StateStore.set({ validation: { status: 'failure', message: res?.tool?.error || '验证失败', }, validationHistory: history, }); } } catch (err) { const newEntry = { action: STATE.box?.querySelector('#__em_action')?.value || 'hover', success: false, timestamp: Date.now(), matchCount: 0, }; const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5); StateStore.set({ validation: { status: 'failure', message: `错误: ${err.message}`, }, validationHistory: history, }); } } /** * Highlight selector from external request (popup/background) * Supports composite iframe selectors: "frameSelector |> innerSelector" */ async function highlightSelectorExternal({ selector, selectorType = 'css', listMode = false }) { const normalized = String(selector || '').trim(); if (!normalized) { return { success: false, error: 'selector is required' }; } try { // Handle composite iframe selector if (normalized.includes('|>')) { const parts = normalized .split('|>') .map((s) => s.trim()) .filter(Boolean); if (parts.length >= 2) { const frameSel = parts[0]; const innerSel = parts.slice(1).join(' |> '); // Find frame element let frameEl = null; try { frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel); } catch {} if ( !frameEl || !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement) ) { return { success: false, error: `Frame element not found: ${frameSel}` }; } const cw = frameEl.contentWindow; if (!cw) { return { success: false, error: 'Unable to access frame contentWindow' }; } // Forward highlight request to iframe return new Promise((resolve) => { const reqId = `em_highlight_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const listener = (ev) => { try { const data = ev?.data; if (!data || data.type !== 'em-highlight-result' || data.reqId !== reqId) return; window.removeEventListener('message', listener, true); resolve(data.result); } catch {} }; window.addEventListener('message', listener, true); setTimeout(() => { window.removeEventListener('message', listener, true); resolve({ success: false, error: 'Frame highlight timeout' }); }, 3000); cw.postMessage( { type: 'em-highlight-request', reqId, selector: innerSel, selectorType, listMode, }, '*', ); }); } } // Handle normal selector (non-iframe) const effectiveType = listMode ? 'css' : selectorType; const matches = effectiveType === 'xpath' ? evaluateXPathAll(normalized) : queryAllDeep(normalized); // Additional defense: filter out any overlay elements that might have slipped through const filteredMatches = filterOverlayElements(matches); if (!filteredMatches || filteredMatches.length === 0) { return { success: false, error: 'No elements found for selector' }; } // Scroll first match into view const primaryMatch = filteredMatches[0]; if (primaryMatch) { primaryMatch.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth', }); } await sleep(150); // Draw highlight rectangles drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false); // Auto-clear after 2 seconds setTimeout(() => { clearRects(); }, 2000); return { success: true, count: filteredMatches.length }; } catch (error) { return { success: false, error: error.message || String(error) }; } } function copySelectorNow() { try { const sel = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); if (!sel) return; navigator.clipboard?.writeText(sel).catch(() => {}); StateStore.set({ validation: { status: 'success', message: '✓ 已复制到剪贴板' }, }); setTimeout(() => { StateStore.set({ validation: { status: 'idle', message: '' } }); }, 2000); } catch {} } async function save() { try { const name = STATE.box?.querySelector('#__em_name')?.value?.trim(); const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim(); if (!selector) return; const url = location.href; let selectorType = StateStore.get('selectorType'); const listMode = StateStore.get('listMode'); if (listMode && selectorType === 'xpath') { selectorType = 'css'; } await chrome.runtime.sendMessage({ type: 'element_marker_save', marker: { url, name: name || selector, selector, selectorType, listMode, }, }); } catch {} stop(); } // ============================================================================ // Lifecycle Management // ============================================================================ function start() { if (STATE.active) return; STATE.active = true; if (IS_MAIN) { const { host } = PanelHost.mount(); STATE.box = host; StateStore.init(); bindControls(); } ensureHighlighter(); ensureRectsHost(); attachPointerListeners(); attachKeyboardListener(); syncInteractionMode(); } function stop() { STATE.active = false; detachPointerListeners(); detachKeyboardListener(); // Cancel pending rAF if (STATE.hoverRafId != null) { cancelAnimationFrame(STATE.hoverRafId); STATE.hoverRafId = null; } pendingHoverEvent = null; try { STATE.highlighter?.remove(); STATE.rectsHost?.remove(); PanelHost.unmount(); DragController.destroy(); } catch {} STATE.highlighter = null; STATE.rectsHost = null; STATE.box = null; STATE.hoveredList = []; STATE.hoverEl = null; STATE.selectedEl = null; STATE.lastHoverTarget = null; STATE.verifyRectsActive = false; // Clear rect pool to release DOM references STATE.rectPool.length = 0; STATE.rectPoolUsed = 0; } // ============================================================================ // Controls Binding // ============================================================================ function bindControls() { const host = STATE.box; if (!host) return; // Close/Cancel host.querySelector('#__em_close')?.addEventListener('click', stop); host.querySelector('#__em_cancel')?.addEventListener('click', stop); // Save host.querySelector('#__em_save')?.addEventListener('click', save); // Verify (highlight only) & Execute (real action) host.querySelector('#__em_verify')?.addEventListener('click', verifyHighlightOnly); host.querySelector('#__em_execute')?.addEventListener('click', verifySelectorNow); // Copy host.querySelector('#__em_copy')?.addEventListener('click', copySelectorNow); host.querySelector('#__em_copy_selector')?.addEventListener('click', copySelectorNow); // Action change handler - show/hide action-specific options host.querySelector('#__em_action')?.addEventListener('change', (e) => { updateActionSpecificUI(e.target.value); }); // Selector type host.querySelector('#__em_selector_type')?.addEventListener('change', (e) => { const newType = e.target.value; const listMode = StateStore.get('listMode'); // If switching to XPath while in list mode, disable list mode if (newType === 'xpath' && listMode) { StateStore.set({ selectorType: newType, listMode: false }); } else { StateStore.set({ selectorType: newType }); } // Regenerate selector for the currently selected element if (STATE.selectedEl) { setSelection(STATE.selectedEl); } // Note: If no selectedEl (e.g., iframe selections or manual input), // preserve existing selector text instead of clearing it }); // List mode toggle host.querySelector('#__em_toggle_list')?.addEventListener('click', (e) => { const listMode = StateStore.get('listMode'); const newListMode = !listMode; // If enabling list mode, force CSS selector type if (newListMode) { StateStore.set({ listMode: true, selectorType: 'css' }); const selectorTypeSelect = host.querySelector('#__em_selector_type'); if (selectorTypeSelect) selectorTypeSelect.value = 'css'; } else { StateStore.set({ listMode: false }); } // Update button active state const btn = e.currentTarget; if (btn) { if (newListMode) { btn.classList.add('active'); } else { btn.classList.remove('active'); } } // Regenerate selector for the currently selected element if (STATE.selectedEl) { setSelection(STATE.selectedEl); } clearHighlighter(); }); // Tab toggle (switch between Attributes and Execute) host.querySelector('#__em_toggle_tab')?.addEventListener('click', () => { const currentTab = StateStore.get('activeTab'); StateStore.set({ activeTab: currentTab === 'attributes' ? 'execute' : 'attributes' }); }); // Tab switching const tabs = host.querySelectorAll('.em-tab'); tabs.forEach((tab) => { tab.addEventListener('click', () => { StateStore.set({ activeTab: tab.dataset.tab }); }); }); // Navigation buttons host.querySelector('#__em_nav_up')?.addEventListener('click', () => { const base = STATE.selectedEl || STATE.hoverEl; if (base?.parentElement) setSelection(base.parentElement); }); host.querySelector('#__em_nav_down')?.addEventListener('click', () => { const base = STATE.selectedEl || STATE.hoverEl; if (base?.firstElementChild) setSelection(base.firstElementChild); }); // Preferences host.querySelector('#__em_pref_id')?.addEventListener('change', (e) => { const prefs = { ...StateStore.get('prefs'), preferId: !!e.target.checked }; StateStore.set({ prefs }); }); host.querySelector('#__em_pref_attr')?.addEventListener('change', (e) => { const prefs = { ...StateStore.get('prefs'), preferStableAttr: !!e.target.checked }; StateStore.set({ prefs }); }); host.querySelector('#__em_pref_class')?.addEventListener('change', (e) => { const prefs = { ...StateStore.get('prefs'), preferClass: !!e.target.checked }; StateStore.set({ prefs }); }); // Drag - use entire header as drag handle const dragHandle = host.querySelector('#__em_drag_handle'); if (dragHandle) { DragController.init(dragHandle); } syncUIWithState(); } function updateActionSpecificUI(action) { const host = STATE.box; if (!host) return; // Hide all action-specific groups const textGroup = host.querySelector('#__em_action_text_group'); const keysGroup = host.querySelector('#__em_action_keys_group'); const scrollOptions = host.querySelector('#__em_scroll_options'); const clickOptions = host.querySelector('#__em_click_options'); if (textGroup) textGroup.style.display = 'none'; if (keysGroup) keysGroup.style.display = 'none'; if (scrollOptions) scrollOptions.style.display = 'none'; if (clickOptions) clickOptions.style.display = 'none'; // Show relevant options based on action if (action === 'type_text') { if (textGroup) textGroup.style.display = 'block'; } else if (action === 'press_keys') { if (keysGroup) keysGroup.style.display = 'block'; } else if (action === 'scroll') { if (scrollOptions) scrollOptions.style.display = 'block'; } else if (['left_click', 'double_click', 'right_click'].includes(action)) { if (clickOptions) clickOptions.style.display = 'block'; // For right_click, button selector is not relevant (always 'right') // Hide the button field for right_click const buttonField = host.querySelector('#__em_btn')?.closest('.em-field'); if (buttonField) { buttonField.style.display = action === 'right_click' ? 'none' : 'block'; } } // hover: no extra options needed } function syncUIWithState() { const host = STATE.box; if (!host) return; const state = StateStore.get(); const typeSelect = host.querySelector('#__em_selector_type'); if (typeSelect) typeSelect.value = state.selectorType; // Initialize list mode button state const listModeBtn = host.querySelector('#__em_toggle_list'); if (listModeBtn) { if (state.listMode) { listModeBtn.classList.add('active'); } else { listModeBtn.classList.remove('active'); } } const prefId = host.querySelector('#__em_pref_id'); const prefAttr = host.querySelector('#__em_pref_attr'); const prefClass = host.querySelector('#__em_pref_class'); if (prefId) prefId.checked = state.prefs.preferId; if (prefAttr) prefAttr.checked = state.prefs.preferStableAttr; if (prefClass) prefClass.checked = state.prefs.preferClass; // Initialize action-specific UI const actionSelect = host.querySelector('#__em_action'); if (actionSelect) { updateActionSpecificUI(actionSelect.value); } } // ============================================================================ // Cross-Frame Bridge // ============================================================================ // Register window message listener in all frames (not just main) // to support cross-frame highlighting from popup validation window.addEventListener( 'message', (ev) => { try { const data = ev?.data; if (!data) return; // Handle iframe highlight request (works even when overlay is inactive) if (data.type === 'em-highlight-request') { highlightSelectorExternal({ selector: data.selector, selectorType: data.selectorType || 'css', listMode: !!data.listMode, }) .then((result) => { window.parent.postMessage( { type: 'em-highlight-result', reqId: data.reqId, result, }, '*', ); }) .catch((error) => { window.parent.postMessage( { type: 'em-highlight-result', reqId: data.reqId, result: { success: false, error: error?.message || String(error) }, }, '*', ); }); return; } // Following messages only relevant when overlay is active if (!STATE.active) return; // Only main frame handles these overlay-related messages if (!IS_MAIN) return; const iframes = Array.from(document.querySelectorAll('iframe')); const host = iframes.find((f) => { try { return f.contentWindow === ev.source; } catch { return false; } }); if (!host) return; const base = host.getBoundingClientRect(); if (data.type === 'em_hover' && Array.isArray(data.rects)) { // Use pooled rect boxes for better performance drawRectBoxes(data.rects, { offsetX: base.left, offsetY: base.top, color: CONFIG.COLORS.HOVER, dashed: true, }); } else if (data.type === 'em_click' && data.innerSel) { const frameSel = generateSelector(host); const composite = frameSel ? `${frameSel} |> ${data.innerSel}` : data.innerSel; const selectorText = STATE.box?.querySelector('#__em_selector'); const selectorDisplay = STATE.box?.querySelector('#__em_selector_text'); if (selectorText) selectorText.textContent = composite; if (selectorDisplay) selectorDisplay.textContent = composite; } } catch {} }, true, ); // ============================================================================ // Message Handlers // ============================================================================ chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => { if (request?.action === 'element_marker_start') { start(); sendResponse({ ok: true }); return true; } else if (request?.action === 'element_marker_ping') { sendResponse({ status: 'pong' }); return false; } else if (request?.action === 'element_marker_highlight') { highlightSelectorExternal({ selector: request.selector, selectorType: request.selectorType, listMode: !!request.listMode, }) .then((result) => sendResponse(result)) .catch((error) => sendResponse({ success: false, error: error?.message || String(error) })); return true; } return false; }); })();