2803 lines
83 KiB
JavaScript
2803 lines
83 KiB
JavaScript
/* 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 = `
|
|
<div class="em-panel" id="em_panel_root">
|
|
<!-- Header -->
|
|
<div class="em-header em-drag-handle" id="__em_drag_handle" title="Drag to move">
|
|
<h2 class="em-title">元素标注</h2>
|
|
<div class="em-header-actions">
|
|
<button class="em-icon-btn" id="__em_close" title="Close">
|
|
<svg viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="em-controls">
|
|
<div class="em-select-wrapper">
|
|
<select class="em-select" id="__em_selector_type">
|
|
<option value="css">CSS Selector</option>
|
|
<option value="xpath">XPath</option>
|
|
</select>
|
|
</div>
|
|
<button class="em-square-btn" id="__em_toggle_list" title="列表模式 - 批量标注相似元素 (仅支持CSS)">
|
|
<svg viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
|
|
</svg>
|
|
</button>
|
|
<button class="em-square-btn" id="__em_toggle_tab" title="Toggle Execute tab">
|
|
<svg viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Selector Display -->
|
|
<div class="em-selector-display">
|
|
<svg viewBox="0 0 24 24" id="__em_copy_selector" title="Copy selector">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
<span class="em-selector-text" id="__em_selector_text">Click an element to select</span>
|
|
<div class="em-selector-nav">
|
|
<button class="em-nav-btn" id="__em_nav_up" title="Select parent">
|
|
<svg viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
|
|
</svg>
|
|
</button>
|
|
<button class="em-nav-btn" id="__em_nav_down" title="Select child">
|
|
<svg viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="em-tabs">
|
|
<button class="em-tab active" data-tab="attributes">Attributes</button>
|
|
<button class="em-tab" data-tab="execute">Execute</button>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="em-status idle" id="__em_status"></div>
|
|
|
|
<!-- Content: Attributes Tab -->
|
|
<div class="em-content" id="__em_tab_attributes">
|
|
<h3 class="em-section-title">#1 Element</h3>
|
|
|
|
<div class="em-attributes">
|
|
<div class="em-attribute">
|
|
<div class="em-attribute-label">name</div>
|
|
<div class="em-attribute-value editable">
|
|
<input class="em-input" id="__em_name" placeholder="Element name" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="em-attribute">
|
|
<div class="em-attribute-label">selector</div>
|
|
<div class="em-attribute-value">
|
|
<svg class="copy-icon" viewBox="0 0 24 24" id="__em_copy" title="Copy">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
|
</svg>
|
|
<span class="em-attribute-text" id="__em_selector">-</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="em-section-title">Selector Preferences</h3>
|
|
<div class="em-settings">
|
|
<div class="em-checkbox-group">
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_pref_id" checked />
|
|
<span>Prefer ID</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_pref_attr" checked />
|
|
<span>Prefer stable attributes</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_pref_class" checked />
|
|
<span>Prefer class names</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="em-actions">
|
|
<button class="em-btn em-btn-primary" id="__em_verify">Verify (Highlight Only)</button>
|
|
</div>
|
|
|
|
<div class="em-actions">
|
|
<button class="em-btn em-btn-success" id="__em_save">Save</button>
|
|
<button class="em-btn em-btn-ghost" id="__em_cancel">Cancel</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content: Execute Tab -->
|
|
<div class="em-content" id="__em_tab_execute" style="display: none;">
|
|
<div class="em-settings">
|
|
<div class="em-settings-group">
|
|
<div class="em-settings-label">Action</div>
|
|
<div class="em-select-wrapper">
|
|
<select class="em-select" id="__em_action">
|
|
<option value="hover">Hover</option>
|
|
<option value="left_click">Left click</option>
|
|
<option value="double_click">Double click</option>
|
|
<option value="right_click">Right click</option>
|
|
<option value="scroll">Scroll</option>
|
|
<option value="type_text">Type text</option>
|
|
<option value="press_keys">Press keys</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Action-specific inputs (dynamically shown/hidden) -->
|
|
<div class="em-settings-group" id="__em_action_text_group" style="display: none;">
|
|
<div class="em-settings-label">Text</div>
|
|
<input class="em-field-input" id="__em_action_text" placeholder="Text to type" />
|
|
</div>
|
|
|
|
<div class="em-settings-group" id="__em_action_keys_group" style="display: none;">
|
|
<div class="em-settings-label">Keys</div>
|
|
<input class="em-field-input" id="__em_action_keys" placeholder="Keys to press (e.g., Enter, Ctrl+C)" />
|
|
</div>
|
|
|
|
<div class="em-settings-group" id="__em_scroll_options" style="display: none;">
|
|
<div class="em-settings-label">Scroll Direction</div>
|
|
<div class="em-select-wrapper">
|
|
<select class="em-select" id="__em_scroll_direction">
|
|
<option value="down">Down</option>
|
|
<option value="up">Up</option>
|
|
<option value="left">Left</option>
|
|
<option value="right">Right</option>
|
|
</select>
|
|
</div>
|
|
<div class="em-field" style="margin-top: 8px;">
|
|
<div class="em-field-label">Amount (1-10, ~100px each)</div>
|
|
<input class="em-field-input" id="__em_scroll_distance" type="number" min="1" max="10" step="1" value="3" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Click-specific options -->
|
|
<div id="__em_click_options" style="display: none;">
|
|
<div class="em-grid">
|
|
<div class="em-field">
|
|
<div class="em-field-label">Button</div>
|
|
<select class="em-select" id="__em_btn">
|
|
<option value="left">Left</option>
|
|
<option value="middle">Middle</option>
|
|
<option value="right">Right</option>
|
|
</select>
|
|
</div>
|
|
<div class="em-field">
|
|
<div class="em-field-label">Timeout (ms)</div>
|
|
<input class="em-field-input" id="__em_nav_timeout" type="number" value="3000" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="em-checkbox-group" style="margin-top: 12px;">
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_wait_nav" />
|
|
<span>Wait for navigation</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_mod_alt" />
|
|
<span>Alt key</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_mod_ctrl" />
|
|
<span>Ctrl key</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_mod_meta" />
|
|
<span>Meta key</span>
|
|
</label>
|
|
<label class="em-checkbox-label">
|
|
<input type="checkbox" id="__em_mod_shift" />
|
|
<span>Shift key</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="em-actions" style="margin-top: 16px;">
|
|
<button class="em-btn em-btn-primary" id="__em_execute">Execute</button>
|
|
</div>
|
|
|
|
<!-- Execution History -->
|
|
<div id="__em_execution_history" style="margin-top: 16px; display: none;">
|
|
<div class="em-settings-label">Recent Executions</div>
|
|
<div id="__em_history_list" style="font-size: 12px; color: #737373; margin-top: 8px;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="em-footer">
|
|
Click or press <kbd>Space</kbd> to select an element
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `<style>${PANEL_STYLES}</style>${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 `<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5;">
|
|
<span style="color: ${color}; font-weight: 600;">${icon}</span>
|
|
<span style="margin-left: 6px;">${entry.action}</span>
|
|
<span style="float: right; color: #a3a3a3; font-size: 11px;">${timestamp}</span>
|
|
</div>`;
|
|
})
|
|
.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., <td-header> 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;
|
|
});
|
|
})();
|