This is page 56 of 60. Use http://codebase.md/hangwin/mcp-chrome?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .gitattributes
├── .github
│ └── workflows
│ └── build-release.yml
├── .gitignore
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
│ └── extensions.json
├── app
│ ├── chrome-extension
│ │ ├── _locales
│ │ │ ├── de
│ │ │ │ └── messages.json
│ │ │ ├── en
│ │ │ │ └── messages.json
│ │ │ ├── ja
│ │ │ │ └── messages.json
│ │ │ ├── ko
│ │ │ │ └── messages.json
│ │ │ ├── zh_CN
│ │ │ │ └── messages.json
│ │ │ └── zh_TW
│ │ │ └── messages.json
│ │ ├── .env.example
│ │ ├── assets
│ │ │ └── vue.svg
│ │ ├── common
│ │ │ ├── agent-models.ts
│ │ │ ├── constants.ts
│ │ │ ├── element-marker-types.ts
│ │ │ ├── message-types.ts
│ │ │ ├── node-types.ts
│ │ │ ├── rr-v3-keepalive-protocol.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tool-handler.ts
│ │ │ └── web-editor-types.ts
│ │ ├── entrypoints
│ │ │ ├── background
│ │ │ │ ├── element-marker
│ │ │ │ │ ├── element-marker-storage.ts
│ │ │ │ │ └── index.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── keepalive-manager.ts
│ │ │ │ ├── native-host.ts
│ │ │ │ ├── quick-panel
│ │ │ │ │ ├── agent-handler.ts
│ │ │ │ │ ├── commands.ts
│ │ │ │ │ └── tabs-handler.ts
│ │ │ │ ├── record-replay
│ │ │ │ │ ├── actions
│ │ │ │ │ │ ├── adapter.ts
│ │ │ │ │ │ ├── handlers
│ │ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ │ ├── control-flow.ts
│ │ │ │ │ │ │ ├── delay.ts
│ │ │ │ │ │ │ ├── dom.ts
│ │ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── constants.ts
│ │ │ │ │ │ ├── execution-mode.ts
│ │ │ │ │ │ ├── logging
│ │ │ │ │ │ │ └── run-logger.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── breakpoint.ts
│ │ │ │ │ │ │ ├── manager.ts
│ │ │ │ │ │ │ └── types.ts
│ │ │ │ │ │ ├── policies
│ │ │ │ │ │ │ ├── retry.ts
│ │ │ │ │ │ │ └── wait.ts
│ │ │ │ │ │ ├── runners
│ │ │ │ │ │ │ ├── after-script-queue.ts
│ │ │ │ │ │ │ ├── control-flow-runner.ts
│ │ │ │ │ │ │ ├── step-executor.ts
│ │ │ │ │ │ │ ├── step-runner.ts
│ │ │ │ │ │ │ └── subflow-runner.ts
│ │ │ │ │ │ ├── scheduler.ts
│ │ │ │ │ │ ├── state-manager.ts
│ │ │ │ │ │ └── utils
│ │ │ │ │ │ └── expression.ts
│ │ │ │ │ ├── flow-runner.ts
│ │ │ │ │ ├── flow-store.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── legacy-types.ts
│ │ │ │ │ ├── nodes
│ │ │ │ │ │ ├── assert.ts
│ │ │ │ │ │ ├── click.ts
│ │ │ │ │ │ ├── conditional.ts
│ │ │ │ │ │ ├── download-screenshot-attr-event-frame-loop.ts
│ │ │ │ │ │ ├── drag.ts
│ │ │ │ │ │ ├── execute-flow.ts
│ │ │ │ │ │ ├── extract.ts
│ │ │ │ │ │ ├── fill.ts
│ │ │ │ │ │ ├── http.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── key.ts
│ │ │ │ │ │ ├── loops.ts
│ │ │ │ │ │ ├── navigate.ts
│ │ │ │ │ │ ├── script.ts
│ │ │ │ │ │ ├── scroll.ts
│ │ │ │ │ │ ├── tabs.ts
│ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ └── wait.ts
│ │ │ │ │ ├── recording
│ │ │ │ │ │ ├── browser-event-listener.ts
│ │ │ │ │ │ ├── content-injection.ts
│ │ │ │ │ │ ├── content-message-handler.ts
│ │ │ │ │ │ ├── flow-builder.ts
│ │ │ │ │ │ ├── recorder-manager.ts
│ │ │ │ │ │ └── session-manager.ts
│ │ │ │ │ ├── rr-utils.ts
│ │ │ │ │ ├── selector-engine.ts
│ │ │ │ │ ├── storage
│ │ │ │ │ │ └── indexeddb-manager.ts
│ │ │ │ │ ├── trigger-store.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── record-replay-v3
│ │ │ │ │ ├── bootstrap.ts
│ │ │ │ │ ├── domain
│ │ │ │ │ │ ├── debug.ts
│ │ │ │ │ │ ├── errors.ts
│ │ │ │ │ │ ├── events.ts
│ │ │ │ │ │ ├── flow.ts
│ │ │ │ │ │ ├── ids.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── json.ts
│ │ │ │ │ │ ├── policy.ts
│ │ │ │ │ │ ├── triggers.ts
│ │ │ │ │ │ └── variables.ts
│ │ │ │ │ ├── engine
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── keepalive
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── offscreen-keepalive.ts
│ │ │ │ │ │ ├── kernel
│ │ │ │ │ │ │ ├── artifacts.ts
│ │ │ │ │ │ │ ├── breakpoints.ts
│ │ │ │ │ │ │ ├── debug-controller.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── kernel.ts
│ │ │ │ │ │ │ ├── recovery-kernel.ts
│ │ │ │ │ │ │ ├── runner.ts
│ │ │ │ │ │ │ └── traversal.ts
│ │ │ │ │ │ ├── plugins
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── register-v2-replay-nodes.ts
│ │ │ │ │ │ │ ├── registry.ts
│ │ │ │ │ │ │ ├── types.ts
│ │ │ │ │ │ │ └── v2-action-adapter.ts
│ │ │ │ │ │ ├── queue
│ │ │ │ │ │ │ ├── enqueue-run.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── leasing.ts
│ │ │ │ │ │ │ ├── queue.ts
│ │ │ │ │ │ │ └── scheduler.ts
│ │ │ │ │ │ ├── recovery
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── recovery-coordinator.ts
│ │ │ │ │ │ ├── storage
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ └── storage-port.ts
│ │ │ │ │ │ ├── transport
│ │ │ │ │ │ │ ├── events-bus.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── rpc-server.ts
│ │ │ │ │ │ │ └── rpc.ts
│ │ │ │ │ │ └── triggers
│ │ │ │ │ │ ├── command-trigger.ts
│ │ │ │ │ │ ├── context-menu-trigger.ts
│ │ │ │ │ │ ├── cron-trigger.ts
│ │ │ │ │ │ ├── dom-trigger.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── interval-trigger.ts
│ │ │ │ │ │ ├── manual-trigger.ts
│ │ │ │ │ │ ├── once-trigger.ts
│ │ │ │ │ │ ├── trigger-handler.ts
│ │ │ │ │ │ ├── trigger-manager.ts
│ │ │ │ │ │ └── url-trigger.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── storage
│ │ │ │ │ ├── db.ts
│ │ │ │ │ ├── events.ts
│ │ │ │ │ ├── flows.ts
│ │ │ │ │ ├── import
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── v2-reader.ts
│ │ │ │ │ │ └── v2-to-v3.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── persistent-vars.ts
│ │ │ │ │ ├── queue.ts
│ │ │ │ │ ├── runs.ts
│ │ │ │ │ └── triggers.ts
│ │ │ │ ├── semantic-similarity.ts
│ │ │ │ ├── storage-manager.ts
│ │ │ │ ├── tools
│ │ │ │ │ ├── base-browser.ts
│ │ │ │ │ ├── browser
│ │ │ │ │ │ ├── bookmark.ts
│ │ │ │ │ │ ├── common.ts
│ │ │ │ │ │ ├── computer.ts
│ │ │ │ │ │ ├── console-buffer.ts
│ │ │ │ │ │ ├── console.ts
│ │ │ │ │ │ ├── dialog.ts
│ │ │ │ │ │ ├── download.ts
│ │ │ │ │ │ ├── element-picker.ts
│ │ │ │ │ │ ├── file-upload.ts
│ │ │ │ │ │ ├── gif-auto-capture.ts
│ │ │ │ │ │ ├── gif-enhanced-renderer.ts
│ │ │ │ │ │ ├── gif-recorder.ts
│ │ │ │ │ │ ├── history.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── inject-script.ts
│ │ │ │ │ │ ├── interaction.ts
│ │ │ │ │ │ ├── javascript.ts
│ │ │ │ │ │ ├── keyboard.ts
│ │ │ │ │ │ ├── network-capture-debugger.ts
│ │ │ │ │ │ ├── network-capture-web-request.ts
│ │ │ │ │ │ ├── network-capture.ts
│ │ │ │ │ │ ├── network-request.ts
│ │ │ │ │ │ ├── performance.ts
│ │ │ │ │ │ ├── read-page.ts
│ │ │ │ │ │ ├── screenshot.ts
│ │ │ │ │ │ ├── userscript.ts
│ │ │ │ │ │ ├── vector-search.ts
│ │ │ │ │ │ ├── web-fetcher.ts
│ │ │ │ │ │ └── window.ts
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── record-replay.ts
│ │ │ │ ├── utils
│ │ │ │ │ └── sidepanel.ts
│ │ │ │ └── web-editor
│ │ │ │ └── index.ts
│ │ │ ├── builder
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── content.ts
│ │ │ ├── element-picker.content.ts
│ │ │ ├── offscreen
│ │ │ │ ├── gif-encoder.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── rr-keepalive.ts
│ │ │ ├── options
│ │ │ │ ├── App.vue
│ │ │ │ ├── index.html
│ │ │ │ └── main.ts
│ │ │ ├── popup
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── builder
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── Canvas.vue
│ │ │ │ │ │ │ ├── EdgePropertyPanel.vue
│ │ │ │ │ │ │ ├── KeyValueEditor.vue
│ │ │ │ │ │ │ ├── nodes
│ │ │ │ │ │ │ │ ├── node-util.ts
│ │ │ │ │ │ │ │ ├── NodeCard.vue
│ │ │ │ │ │ │ │ └── NodeIf.vue
│ │ │ │ │ │ │ ├── properties
│ │ │ │ │ │ │ │ ├── PropertyAssert.vue
│ │ │ │ │ │ │ │ ├── PropertyClick.vue
│ │ │ │ │ │ │ │ ├── PropertyCloseTab.vue
│ │ │ │ │ │ │ │ ├── PropertyDelay.vue
│ │ │ │ │ │ │ │ ├── PropertyDrag.vue
│ │ │ │ │ │ │ │ ├── PropertyExecuteFlow.vue
│ │ │ │ │ │ │ │ ├── PropertyExtract.vue
│ │ │ │ │ │ │ │ ├── PropertyFill.vue
│ │ │ │ │ │ │ │ ├── PropertyForeach.vue
│ │ │ │ │ │ │ │ ├── PropertyFormRenderer.vue
│ │ │ │ │ │ │ │ ├── PropertyFromSpec.vue
│ │ │ │ │ │ │ │ ├── PropertyHandleDownload.vue
│ │ │ │ │ │ │ │ ├── PropertyHttp.vue
│ │ │ │ │ │ │ │ ├── PropertyIf.vue
│ │ │ │ │ │ │ │ ├── PropertyKey.vue
│ │ │ │ │ │ │ │ ├── PropertyLoopElements.vue
│ │ │ │ │ │ │ │ ├── PropertyNavigate.vue
│ │ │ │ │ │ │ │ ├── PropertyOpenTab.vue
│ │ │ │ │ │ │ │ ├── PropertyScreenshot.vue
│ │ │ │ │ │ │ │ ├── PropertyScript.vue
│ │ │ │ │ │ │ │ ├── PropertyScroll.vue
│ │ │ │ │ │ │ │ ├── PropertySetAttribute.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchFrame.vue
│ │ │ │ │ │ │ │ ├── PropertySwitchTab.vue
│ │ │ │ │ │ │ │ ├── PropertyTrigger.vue
│ │ │ │ │ │ │ │ ├── PropertyTriggerEvent.vue
│ │ │ │ │ │ │ │ ├── PropertyWait.vue
│ │ │ │ │ │ │ │ ├── PropertyWhile.vue
│ │ │ │ │ │ │ │ └── SelectorEditor.vue
│ │ │ │ │ │ │ ├── PropertyPanel.vue
│ │ │ │ │ │ │ ├── Sidebar.vue
│ │ │ │ │ │ │ └── TriggerPanel.vue
│ │ │ │ │ │ ├── model
│ │ │ │ │ │ │ ├── form-widget-registry.ts
│ │ │ │ │ │ │ ├── node-spec-registry.ts
│ │ │ │ │ │ │ ├── node-spec.ts
│ │ │ │ │ │ │ ├── node-specs-builtin.ts
│ │ │ │ │ │ │ ├── toast.ts
│ │ │ │ │ │ │ ├── transforms.ts
│ │ │ │ │ │ │ ├── ui-nodes.ts
│ │ │ │ │ │ │ ├── validation.ts
│ │ │ │ │ │ │ └── variables.ts
│ │ │ │ │ │ ├── store
│ │ │ │ │ │ │ └── useBuilderStore.ts
│ │ │ │ │ │ └── widgets
│ │ │ │ │ │ ├── FieldCode.vue
│ │ │ │ │ │ ├── FieldDuration.vue
│ │ │ │ │ │ ├── FieldExpression.vue
│ │ │ │ │ │ ├── FieldKeySequence.vue
│ │ │ │ │ │ ├── FieldSelector.vue
│ │ │ │ │ │ ├── FieldTargetLocator.vue
│ │ │ │ │ │ └── VarInput.vue
│ │ │ │ │ ├── ConfirmDialog.vue
│ │ │ │ │ ├── ElementMarkerManagement.vue
│ │ │ │ │ ├── icons
│ │ │ │ │ │ ├── BoltIcon.vue
│ │ │ │ │ │ ├── CheckIcon.vue
│ │ │ │ │ │ ├── DatabaseIcon.vue
│ │ │ │ │ │ ├── DocumentIcon.vue
│ │ │ │ │ │ ├── EditIcon.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MarkerIcon.vue
│ │ │ │ │ │ ├── RecordIcon.vue
│ │ │ │ │ │ ├── RefreshIcon.vue
│ │ │ │ │ │ ├── StopIcon.vue
│ │ │ │ │ │ ├── TabIcon.vue
│ │ │ │ │ │ ├── TrashIcon.vue
│ │ │ │ │ │ ├── VectorIcon.vue
│ │ │ │ │ │ └── WorkflowIcon.vue
│ │ │ │ │ ├── LocalModelPage.vue
│ │ │ │ │ ├── ModelCacheManagement.vue
│ │ │ │ │ ├── ProgressIndicator.vue
│ │ │ │ │ └── ScheduleDialog.vue
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ └── style.css
│ │ │ ├── quick-panel.content.ts
│ │ │ ├── shared
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── useRRV3Rpc.ts
│ │ │ │ └── utils
│ │ │ │ ├── index.ts
│ │ │ │ └── rr-flow-convert.ts
│ │ │ ├── sidepanel
│ │ │ │ ├── App.vue
│ │ │ │ ├── components
│ │ │ │ │ ├── agent
│ │ │ │ │ │ ├── AttachmentPreview.vue
│ │ │ │ │ │ ├── ChatInput.vue
│ │ │ │ │ │ ├── CliSettings.vue
│ │ │ │ │ │ ├── ConnectionStatus.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── MessageItem.vue
│ │ │ │ │ │ ├── MessageList.vue
│ │ │ │ │ │ ├── ProjectCreateForm.vue
│ │ │ │ │ │ └── ProjectSelector.vue
│ │ │ │ │ ├── agent-chat
│ │ │ │ │ │ ├── AgentChatShell.vue
│ │ │ │ │ │ ├── AgentComposer.vue
│ │ │ │ │ │ ├── AgentConversation.vue
│ │ │ │ │ │ ├── AgentOpenProjectMenu.vue
│ │ │ │ │ │ ├── AgentProjectMenu.vue
│ │ │ │ │ │ ├── AgentRequestThread.vue
│ │ │ │ │ │ ├── AgentSessionListItem.vue
│ │ │ │ │ │ ├── AgentSessionMenu.vue
│ │ │ │ │ │ ├── AgentSessionSettingsPanel.vue
│ │ │ │ │ │ ├── AgentSessionsView.vue
│ │ │ │ │ │ ├── AgentSettingsMenu.vue
│ │ │ │ │ │ ├── AgentTimeline.vue
│ │ │ │ │ │ ├── AgentTimelineItem.vue
│ │ │ │ │ │ ├── AgentTopBar.vue
│ │ │ │ │ │ ├── ApplyMessageChip.vue
│ │ │ │ │ │ ├── AttachmentCachePanel.vue
│ │ │ │ │ │ ├── ComposerDrawer.vue
│ │ │ │ │ │ ├── ElementChip.vue
│ │ │ │ │ │ ├── FakeCaretOverlay.vue
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── SelectionChip.vue
│ │ │ │ │ │ ├── timeline
│ │ │ │ │ │ │ ├── markstream-thinking.ts
│ │ │ │ │ │ │ ├── ThinkingNode.vue
│ │ │ │ │ │ │ ├── TimelineNarrativeStep.vue
│ │ │ │ │ │ │ ├── TimelineStatusStep.vue
│ │ │ │ │ │ │ ├── TimelineToolCallStep.vue
│ │ │ │ │ │ │ ├── TimelineToolResultCardStep.vue
│ │ │ │ │ │ │ └── TimelineUserPromptStep.vue
│ │ │ │ │ │ └── WebEditorChanges.vue
│ │ │ │ │ ├── AgentChat.vue
│ │ │ │ │ ├── rr-v3
│ │ │ │ │ │ └── DebuggerPanel.vue
│ │ │ │ │ ├── SidepanelNavigator.vue
│ │ │ │ │ └── workflows
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── WorkflowListItem.vue
│ │ │ │ │ └── WorkflowsView.vue
│ │ │ │ ├── composables
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── useAgentChat.ts
│ │ │ │ │ ├── useAgentChatViewRoute.ts
│ │ │ │ │ ├── useAgentInputPreferences.ts
│ │ │ │ │ ├── useAgentProjects.ts
│ │ │ │ │ ├── useAgentServer.ts
│ │ │ │ │ ├── useAgentSessions.ts
│ │ │ │ │ ├── useAgentTheme.ts
│ │ │ │ │ ├── useAgentThreads.ts
│ │ │ │ │ ├── useAttachments.ts
│ │ │ │ │ ├── useFakeCaret.ts
│ │ │ │ │ ├── useFloatingDrag.ts
│ │ │ │ │ ├── useOpenProjectPreference.ts
│ │ │ │ │ ├── useRRV3Debugger.ts
│ │ │ │ │ ├── useRRV3Rpc.ts
│ │ │ │ │ ├── useTextareaAutoResize.ts
│ │ │ │ │ ├── useWebEditorTxState.ts
│ │ │ │ │ └── useWorkflowsV3.ts
│ │ │ │ ├── index.html
│ │ │ │ ├── main.ts
│ │ │ │ ├── styles
│ │ │ │ │ └── agent-chat.css
│ │ │ │ └── utils
│ │ │ │ └── loading-texts.ts
│ │ │ ├── styles
│ │ │ │ └── tailwind.css
│ │ │ ├── web-editor-v2
│ │ │ │ ├── attr-ui-refactor.md
│ │ │ │ ├── constants.ts
│ │ │ │ ├── core
│ │ │ │ │ ├── css-compare.ts
│ │ │ │ │ ├── cssom-styles-collector.ts
│ │ │ │ │ ├── debug-source.ts
│ │ │ │ │ ├── design-tokens
│ │ │ │ │ │ ├── design-tokens-service.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── token-detector.ts
│ │ │ │ │ │ ├── token-resolver.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── editor.ts
│ │ │ │ │ ├── element-key.ts
│ │ │ │ │ ├── event-controller.ts
│ │ │ │ │ ├── execution-tracker.ts
│ │ │ │ │ ├── hmr-consistency.ts
│ │ │ │ │ ├── locator.ts
│ │ │ │ │ ├── message-listener.ts
│ │ │ │ │ ├── payload-builder.ts
│ │ │ │ │ ├── perf-monitor.ts
│ │ │ │ │ ├── position-tracker.ts
│ │ │ │ │ ├── props-bridge.ts
│ │ │ │ │ ├── snap-engine.ts
│ │ │ │ │ ├── transaction-aggregator.ts
│ │ │ │ │ └── transaction-manager.ts
│ │ │ │ ├── drag
│ │ │ │ │ └── drag-reorder-controller.ts
│ │ │ │ ├── overlay
│ │ │ │ │ ├── canvas-overlay.ts
│ │ │ │ │ └── handles-controller.ts
│ │ │ │ ├── selection
│ │ │ │ │ └── selection-engine.ts
│ │ │ │ ├── ui
│ │ │ │ │ ├── breadcrumbs.ts
│ │ │ │ │ ├── floating-drag.ts
│ │ │ │ │ ├── icons.ts
│ │ │ │ │ ├── property-panel
│ │ │ │ │ │ ├── class-editor.ts
│ │ │ │ │ │ ├── components
│ │ │ │ │ │ │ ├── alignment-grid.ts
│ │ │ │ │ │ │ ├── icon-button-group.ts
│ │ │ │ │ │ │ ├── input-container.ts
│ │ │ │ │ │ │ ├── slider-input.ts
│ │ │ │ │ │ │ └── token-pill.ts
│ │ │ │ │ │ ├── components-tree.ts
│ │ │ │ │ │ ├── controls
│ │ │ │ │ │ │ ├── appearance-control.ts
│ │ │ │ │ │ │ ├── background-control.ts
│ │ │ │ │ │ │ ├── border-control.ts
│ │ │ │ │ │ │ ├── color-field.ts
│ │ │ │ │ │ │ ├── css-helpers.ts
│ │ │ │ │ │ │ ├── effects-control.ts
│ │ │ │ │ │ │ ├── gradient-control.ts
│ │ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ │ ├── layout-control.ts
│ │ │ │ │ │ │ ├── number-stepping.ts
│ │ │ │ │ │ │ ├── position-control.ts
│ │ │ │ │ │ │ ├── size-control.ts
│ │ │ │ │ │ │ ├── spacing-control.ts
│ │ │ │ │ │ │ ├── token-picker.ts
│ │ │ │ │ │ │ └── typography-control.ts
│ │ │ │ │ │ ├── css-defaults.ts
│ │ │ │ │ │ ├── css-panel.ts
│ │ │ │ │ │ ├── index.ts
│ │ │ │ │ │ ├── property-panel.ts
│ │ │ │ │ │ ├── props-panel.ts
│ │ │ │ │ │ └── types.ts
│ │ │ │ │ ├── shadow-host.ts
│ │ │ │ │ └── toolbar.ts
│ │ │ │ └── utils
│ │ │ │ └── disposables.ts
│ │ │ ├── web-editor-v2.ts
│ │ │ └── welcome
│ │ │ ├── App.vue
│ │ │ ├── index.html
│ │ │ └── main.ts
│ │ ├── env.d.ts
│ │ ├── eslint.config.js
│ │ ├── inject-scripts
│ │ │ ├── accessibility-tree-helper.js
│ │ │ ├── click-helper.js
│ │ │ ├── dom-observer.js
│ │ │ ├── element-marker.js
│ │ │ ├── element-picker.js
│ │ │ ├── fill-helper.js
│ │ │ ├── inject-bridge.js
│ │ │ ├── interactive-elements-helper.js
│ │ │ ├── keyboard-helper.js
│ │ │ ├── network-helper.js
│ │ │ ├── props-agent.js
│ │ │ ├── recorder.js
│ │ │ ├── screenshot-helper.js
│ │ │ ├── wait-helper.js
│ │ │ ├── web-editor.js
│ │ │ └── web-fetcher-helper.js
│ │ ├── LICENSE
│ │ ├── package.json
│ │ ├── public
│ │ │ ├── icon
│ │ │ │ ├── 128.png
│ │ │ │ ├── 16.png
│ │ │ │ ├── 32.png
│ │ │ │ ├── 48.png
│ │ │ │ └── 96.png
│ │ │ ├── libs
│ │ │ │ └── ort.min.js
│ │ │ └── wxt.svg
│ │ ├── README.md
│ │ ├── shared
│ │ │ ├── element-picker
│ │ │ │ ├── controller.ts
│ │ │ │ └── index.ts
│ │ │ ├── quick-panel
│ │ │ │ ├── core
│ │ │ │ │ ├── agent-bridge.ts
│ │ │ │ │ ├── search-engine.ts
│ │ │ │ │ └── types.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── providers
│ │ │ │ │ ├── index.ts
│ │ │ │ │ └── tabs-provider.ts
│ │ │ │ └── ui
│ │ │ │ ├── ai-chat-panel.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── markdown-renderer.ts
│ │ │ │ ├── message-renderer.ts
│ │ │ │ ├── panel-shell.ts
│ │ │ │ ├── quick-entries.ts
│ │ │ │ ├── search-input.ts
│ │ │ │ ├── shadow-host.ts
│ │ │ │ └── styles.ts
│ │ │ └── selector
│ │ │ ├── dom-path.ts
│ │ │ ├── fingerprint.ts
│ │ │ ├── generator.ts
│ │ │ ├── index.ts
│ │ │ ├── locator.ts
│ │ │ ├── shadow-dom.ts
│ │ │ ├── stability.ts
│ │ │ ├── strategies
│ │ │ │ ├── anchor-relpath.ts
│ │ │ │ ├── aria.ts
│ │ │ │ ├── css-path.ts
│ │ │ │ ├── css-unique.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── testid.ts
│ │ │ │ └── text.ts
│ │ │ └── types.ts
│ │ ├── tailwind.config.ts
│ │ ├── tests
│ │ │ ├── __mocks__
│ │ │ │ └── hnswlib-wasm-static.ts
│ │ │ ├── record-replay
│ │ │ │ ├── _test-helpers.ts
│ │ │ │ ├── adapter-policy.contract.test.ts
│ │ │ │ ├── flow-store-strip-steps.contract.test.ts
│ │ │ │ ├── high-risk-actions.integration.test.ts
│ │ │ │ ├── hybrid-actions.integration.test.ts
│ │ │ │ ├── script-control-flow.integration.test.ts
│ │ │ │ ├── session-dag-sync.contract.test.ts
│ │ │ │ ├── step-executor.contract.test.ts
│ │ │ │ └── tab-cursor.integration.test.ts
│ │ │ ├── record-replay-v3
│ │ │ │ ├── command-trigger.test.ts
│ │ │ │ ├── context-menu-trigger.test.ts
│ │ │ │ ├── cron-trigger.test.ts
│ │ │ │ ├── debugger.contract.test.ts
│ │ │ │ ├── dom-trigger.test.ts
│ │ │ │ ├── e2e.integration.test.ts
│ │ │ │ ├── events.contract.test.ts
│ │ │ │ ├── interval-trigger.test.ts
│ │ │ │ ├── manual-trigger.test.ts
│ │ │ │ ├── once-trigger.test.ts
│ │ │ │ ├── queue.contract.test.ts
│ │ │ │ ├── recovery.test.ts
│ │ │ │ ├── rpc-api.test.ts
│ │ │ │ ├── runner.onError.contract.test.ts
│ │ │ │ ├── scheduler-integration.test.ts
│ │ │ │ ├── scheduler.test.ts
│ │ │ │ ├── spec-smoke.test.ts
│ │ │ │ ├── trigger-manager.test.ts
│ │ │ │ ├── triggers.test.ts
│ │ │ │ ├── url-trigger.test.ts
│ │ │ │ ├── v2-action-adapter.test.ts
│ │ │ │ ├── v2-adapter-integration.test.ts
│ │ │ │ ├── v2-to-v3-conversion.test.ts
│ │ │ │ └── v3-e2e-harness.ts
│ │ │ ├── vitest.setup.ts
│ │ │ └── web-editor-v2
│ │ │ ├── design-tokens.test.ts
│ │ │ ├── drag-reorder-controller.test.ts
│ │ │ ├── event-controller.test.ts
│ │ │ ├── locator.test.ts
│ │ │ ├── property-panel-live-sync.test.ts
│ │ │ ├── selection-engine.test.ts
│ │ │ ├── snap-engine.test.ts
│ │ │ └── test-utils
│ │ │ └── dom.ts
│ │ ├── tsconfig.json
│ │ ├── types
│ │ │ ├── gifenc.d.ts
│ │ │ └── icons.d.ts
│ │ ├── utils
│ │ │ ├── cdp-session-manager.ts
│ │ │ ├── content-indexer.ts
│ │ │ ├── i18n.ts
│ │ │ ├── image-utils.ts
│ │ │ ├── indexeddb-client.ts
│ │ │ ├── lru-cache.ts
│ │ │ ├── model-cache-manager.ts
│ │ │ ├── offscreen-manager.ts
│ │ │ ├── output-sanitizer.ts
│ │ │ ├── screenshot-context.ts
│ │ │ ├── semantic-similarity-engine.ts
│ │ │ ├── simd-math-engine.ts
│ │ │ ├── text-chunker.ts
│ │ │ └── vector-database.ts
│ │ ├── vitest.config.ts
│ │ ├── workers
│ │ │ ├── ort-wasm-simd-threaded.jsep.mjs
│ │ │ ├── ort-wasm-simd-threaded.jsep.wasm
│ │ │ ├── ort-wasm-simd-threaded.mjs
│ │ │ ├── ort-wasm-simd-threaded.wasm
│ │ │ ├── simd_math_bg.wasm
│ │ │ ├── simd_math.js
│ │ │ └── similarity.worker.js
│ │ └── wxt.config.ts
│ └── native-server
│ ├── .npmignore
│ ├── debug.sh
│ ├── install.md
│ ├── jest.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── agent
│ │ │ ├── attachment-service.ts
│ │ │ ├── ccr-detector.ts
│ │ │ ├── chat-service.ts
│ │ │ ├── db
│ │ │ │ ├── client.ts
│ │ │ │ ├── index.ts
│ │ │ │ └── schema.ts
│ │ │ ├── directory-picker.ts
│ │ │ ├── engines
│ │ │ │ ├── claude.ts
│ │ │ │ ├── codex.ts
│ │ │ │ └── types.ts
│ │ │ ├── message-service.ts
│ │ │ ├── open-project.ts
│ │ │ ├── project-service.ts
│ │ │ ├── project-types.ts
│ │ │ ├── session-service.ts
│ │ │ ├── storage.ts
│ │ │ ├── stream-manager.ts
│ │ │ ├── tool-bridge.ts
│ │ │ └── types.ts
│ │ ├── cli.ts
│ │ ├── constant
│ │ │ └── index.ts
│ │ ├── file-handler.ts
│ │ ├── index.ts
│ │ ├── mcp
│ │ │ ├── mcp-server-stdio.ts
│ │ │ ├── mcp-server.ts
│ │ │ ├── register-tools.ts
│ │ │ └── stdio-config.json
│ │ ├── native-messaging-host.ts
│ │ ├── scripts
│ │ │ ├── browser-config.ts
│ │ │ ├── build.ts
│ │ │ ├── constant.ts
│ │ │ ├── doctor.ts
│ │ │ ├── postinstall.ts
│ │ │ ├── register-dev.ts
│ │ │ ├── register.ts
│ │ │ ├── report.ts
│ │ │ ├── run_host.bat
│ │ │ ├── run_host.sh
│ │ │ └── utils.ts
│ │ ├── server
│ │ │ ├── index.ts
│ │ │ ├── routes
│ │ │ │ ├── agent.ts
│ │ │ │ └── index.ts
│ │ │ └── server.test.ts
│ │ ├── shims
│ │ │ └── devtools.d.ts
│ │ ├── trace-analyzer.ts
│ │ ├── types
│ │ │ └── devtools-frontend.d.ts
│ │ └── util
│ │ └── logger.ts
│ └── tsconfig.json
├── commitlint.config.cjs
├── docs
│ ├── ARCHITECTURE_zh.md
│ ├── ARCHITECTURE.md
│ ├── CHANGELOG.md
│ ├── CONTRIBUTING_zh.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE.md
│ ├── mcp-cli-config.md
│ ├── TOOLS_zh.md
│ ├── TOOLS.md
│ ├── TROUBLESHOOTING_zh.md
│ ├── TROUBLESHOOTING.md
│ ├── VisualEditor_zh.md
│ ├── VisualEditor.md
│ └── WINDOWS_INSTALL_zh.md
├── eslint.config.js
├── LICENSE
├── package.json
├── packages
│ ├── shared
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── agent-types.ts
│ │ │ ├── constants.ts
│ │ │ ├── index.ts
│ │ │ ├── labels.ts
│ │ │ ├── node-spec-registry.ts
│ │ │ ├── node-spec.ts
│ │ │ ├── node-specs-builtin.ts
│ │ │ ├── rr-graph.ts
│ │ │ ├── step-types.ts
│ │ │ ├── tools.ts
│ │ │ └── types.ts
│ │ └── tsconfig.json
│ └── wasm-simd
│ ├── .gitignore
│ ├── BUILD.md
│ ├── Cargo.toml
│ ├── package.json
│ ├── README.md
│ └── src
│ └── lib.rs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── prompt
│ ├── content-analize.md
│ ├── excalidraw-prompt.md
│ └── modify-web.md
├── README_zh.md
├── README.md
└── releases
├── chrome-extension
│ └── latest
│ └── chrome-mcp-server-lastest.zip
└── README.md
```
# Files
--------------------------------------------------------------------------------
/app/chrome-extension/inject-scripts/element-marker.js:
--------------------------------------------------------------------------------
```javascript
1 | /* eslint-disable */
2 | (function () {
3 | if (window.__ELEMENT_MARKER_INSTALLED__) return;
4 | window.__ELEMENT_MARKER_INSTALLED__ = true;
5 |
6 | const IS_MAIN = window === window.top;
7 |
8 | // ============================================================================
9 | // Utility Functions
10 | // ============================================================================
11 |
12 | function sleep(ms) {
13 | return new Promise((resolve) => setTimeout(resolve, ms));
14 | }
15 |
16 | // ============================================================================
17 | // Constants & Configuration
18 | // ============================================================================
19 |
20 | const CONFIG = {
21 | DEFAULTS: {
22 | PREFS: {
23 | preferId: true,
24 | preferStableAttr: true,
25 | preferClass: true,
26 | },
27 | SELECTOR_TYPE: 'css',
28 | LIST_MODE: false,
29 | },
30 | Z_INDEX: {
31 | OVERLAY: 2147483646,
32 | HIGHLIGHTER: 2147483645,
33 | RECTS: 2147483644,
34 | },
35 | COLORS: {
36 | PRIMARY: '#2563eb',
37 | SUCCESS: '#10b981',
38 | WARNING: '#f59e0b',
39 | DANGER: '#ef4444',
40 | HOVER: '#10b981',
41 | VERIFY: '#3b82f6',
42 | },
43 | };
44 |
45 | // ============================================================================
46 | // Panel Host Module - Shadow DOM Management
47 | // ============================================================================
48 |
49 | const PanelHost = (() => {
50 | let hostElement = null;
51 | let shadowRoot = null;
52 |
53 | const PANEL_STYLES = `
54 | * {
55 | box-sizing: border-box;
56 | margin: 0;
57 | padding: 0;
58 | }
59 |
60 | .em-panel {
61 | width: 400px;
62 | background: #ffffff;
63 | border-radius: 12px;
64 | box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
65 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
66 | padding: 20px;
67 | transition: opacity 150ms ease;
68 | }
69 |
70 |
71 | /* Header */
72 | .em-header {
73 | display: flex;
74 | align-items: center;
75 | justify-content: space-between;
76 | margin-bottom: 20px;
77 | user-select: none;
78 | }
79 |
80 | .em-title {
81 | font-size: 20px;
82 | font-weight: 500;
83 | color: #262626;
84 | }
85 |
86 | .em-header-actions {
87 | display: flex;
88 | gap: 4px;
89 | align-items: center;
90 | }
91 |
92 | .em-icon-btn {
93 | width: 32px;
94 | height: 32px;
95 | display: flex;
96 | align-items: center;
97 | justify-content: center;
98 | border: none;
99 | background: transparent;
100 | color: #a3a3a3;
101 | cursor: pointer;
102 | transition: color 150ms ease;
103 | padding: 0;
104 | }
105 |
106 | .em-icon-btn:hover {
107 | color: #525252;
108 | }
109 |
110 | .em-icon-btn svg {
111 | width: 20px;
112 | height: 20px;
113 | stroke-width: 2;
114 | }
115 |
116 | /* Controls Row */
117 | .em-controls {
118 | display: flex;
119 | gap: 8px;
120 | margin-bottom: 12px;
121 | }
122 |
123 | .em-select-wrapper {
124 | flex: 1;
125 | position: relative;
126 | }
127 |
128 | .em-select {
129 | width: 100%;
130 | height: 44px;
131 | padding: 0 40px 0 16px;
132 | background: #f5f5f5;
133 | color: #262626;
134 | font-size: 15px;
135 | border: none;
136 | border-radius: 10px;
137 | appearance: none;
138 | cursor: pointer;
139 | outline: none;
140 | font-family: inherit;
141 | font-weight: 400;
142 | }
143 |
144 | .em-select-wrapper::after {
145 | content: '';
146 | position: absolute;
147 | right: 16px;
148 | top: 50%;
149 | transform: translateY(-50%);
150 | width: 0;
151 | height: 0;
152 | border-left: 5px solid transparent;
153 | border-right: 5px solid transparent;
154 | border-top: 6px solid #737373;
155 | pointer-events: none;
156 | }
157 |
158 | .em-square-btn {
159 | width: 44px;
160 | height: 44px;
161 | display: flex;
162 | align-items: center;
163 | justify-content: center;
164 | background: #f5f5f5;
165 | border: none;
166 | border-radius: 10px;
167 | cursor: pointer;
168 | transition: background 150ms ease;
169 | padding: 0;
170 | }
171 |
172 | .em-square-btn:hover {
173 | background: #e5e5e5;
174 | }
175 |
176 | .em-square-btn.active {
177 | background: #2563eb;
178 | }
179 |
180 | .em-square-btn.active svg {
181 | color: #ffffff;
182 | }
183 |
184 | .em-square-btn svg {
185 | width: 18px;
186 | height: 18px;
187 | color: #525252;
188 | stroke-width: 2;
189 | }
190 |
191 | /* Selector Display */
192 | .em-selector-display {
193 | display: flex;
194 | align-items: center;
195 | gap: 10px;
196 | height: 44px;
197 | padding: 0 12px 0 16px;
198 | background: #f5f5f5;
199 | border-radius: 10px;
200 | margin-bottom: 16px;
201 | }
202 |
203 | .em-selector-display svg {
204 | width: 18px;
205 | height: 18px;
206 | color: #a3a3a3;
207 | flex-shrink: 0;
208 | stroke-width: 2;
209 | }
210 |
211 | .em-selector-text {
212 | flex: 1;
213 | font-size: 14px;
214 | color: #525252;
215 | overflow: hidden;
216 | text-overflow: ellipsis;
217 | white-space: nowrap;
218 | user-select: text;
219 | }
220 |
221 | .em-selector-nav {
222 | display: flex;
223 | gap: 2px;
224 | }
225 |
226 | .em-nav-btn {
227 | width: 28px;
228 | height: 28px;
229 | display: flex;
230 | align-items: center;
231 | justify-content: center;
232 | border: none;
233 | background: transparent;
234 | cursor: pointer;
235 | transition: background 150ms ease;
236 | border-radius: 6px;
237 | padding: 0;
238 | }
239 |
240 | .em-nav-btn:hover {
241 | background: #e5e5e5;
242 | }
243 |
244 | .em-nav-btn svg {
245 | width: 16px;
246 | height: 16px;
247 | color: #525252;
248 | stroke-width: 2;
249 | }
250 |
251 | /* Tabs */
252 | .em-tabs {
253 | display: inline-flex;
254 | gap: 2px;
255 | padding: 2px;
256 | background: #f5f5f5;
257 | border-radius: 8px;
258 | margin-bottom: 16px;
259 | }
260 |
261 | .em-tab {
262 | padding: 6px 16px;
263 | font-size: 12px;
264 | font-weight: 500;
265 | color: #737373;
266 | background: transparent;
267 | border: none;
268 | border-radius: 6px;
269 | cursor: pointer;
270 | transition: all 150ms ease;
271 | }
272 |
273 | .em-tab:hover {
274 | color: #404040;
275 | }
276 |
277 | .em-tab.active {
278 | color: #262626;
279 | background: #ffffff;
280 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
281 | }
282 |
283 | /* Content */
284 | .em-content {
285 | margin-bottom: 0;
286 | }
287 |
288 | #__em_tab_settings {
289 | max-height: min(60vh, 480px);
290 | overflow-y: auto;
291 | scrollbar-width: none; /* Firefox */
292 | -ms-overflow-style: none; /* IE and Edge */
293 | }
294 |
295 | #__em_tab_settings::-webkit-scrollbar {
296 | display: none; /* Chrome, Safari, Opera */
297 | }
298 |
299 | .em-section-title {
300 | font-size: 13px;
301 | color: #737373;
302 | margin-bottom: 16px;
303 | font-weight: 400;
304 | }
305 |
306 | .em-attributes {
307 | display: flex;
308 | flex-direction: column;
309 | gap: 12px;
310 | }
311 |
312 | .em-attribute {
313 | display: flex;
314 | flex-direction: column;
315 | gap: 6px;
316 | }
317 |
318 | .em-attribute-label {
319 | font-size: 12px;
320 | color: #a3a3a3;
321 | font-weight: 400;
322 | }
323 |
324 | .em-attribute-value {
325 | display: flex;
326 | align-items: center;
327 | gap: 10px;
328 | min-height: 44px;
329 | padding: 0 12px 0 16px;
330 | background: #f5f5f5;
331 | border-radius: 10px;
332 | }
333 |
334 | .em-attribute-value.editable {
335 | padding: 0 16px;
336 | }
337 |
338 | .em-attribute-value svg {
339 | width: 18px;
340 | height: 18px;
341 | stroke-width: 2;
342 | cursor: pointer;
343 | transition: color 150ms ease;
344 | flex-shrink: 0;
345 | }
346 |
347 | .em-attribute-value svg.copy-icon {
348 | color: #a3a3a3;
349 | }
350 |
351 | .em-attribute-value svg.copy-icon:hover {
352 | color: #525252;
353 | }
354 |
355 | .em-attribute-value svg.copy-icon.disabled {
356 | color: #d4d4d4;
357 | cursor: default;
358 | }
359 |
360 | .em-attribute-text {
361 | flex: 1;
362 | font-size: 14px;
363 | color: #404040;
364 | user-select: text;
365 | }
366 |
367 | .em-attribute-text.empty {
368 | color: #a3a3a3;
369 | }
370 |
371 | .em-input {
372 | flex: 1;
373 | border: none;
374 | background: transparent;
375 | font-size: 14px;
376 | color: #404040;
377 | font-family: inherit;
378 | outline: none;
379 | padding: 0;
380 | height: 44px;
381 | }
382 |
383 | .em-input::placeholder {
384 | color: #a3a3a3;
385 | }
386 |
387 | /* Settings Panel */
388 | .em-settings {
389 | display: flex;
390 | flex-direction: column;
391 | gap: 16px;
392 | }
393 |
394 | .em-settings-group {
395 | display: flex;
396 | flex-direction: column;
397 | gap: 8px;
398 | }
399 |
400 | .em-settings-label {
401 | font-size: 12px;
402 | font-weight: 500;
403 | color: #737373;
404 | }
405 |
406 | .em-checkbox-group {
407 | display: flex;
408 | flex-direction: column;
409 | gap: 10px;
410 | }
411 |
412 | .em-checkbox-label {
413 | display: flex;
414 | align-items: center;
415 | gap: 8px;
416 | font-size: 14px;
417 | color: #404040;
418 | cursor: pointer;
419 | }
420 |
421 | .em-checkbox-label input[type="checkbox"] {
422 | width: 18px;
423 | height: 18px;
424 | cursor: pointer;
425 | margin: 0;
426 | }
427 |
428 | /* Action Buttons */
429 | .em-actions {
430 | display: flex;
431 | gap: 8px;
432 | margin-top: 20px;
433 | }
434 |
435 | .em-btn {
436 | flex: 1;
437 | height: 40px;
438 | border: none;
439 | border-radius: 8px;
440 | font-size: 14px;
441 | font-weight: 600;
442 | cursor: pointer;
443 | transition: all 150ms ease;
444 | }
445 |
446 | .em-btn-primary {
447 | background: #2563eb;
448 | color: #ffffff;
449 | }
450 |
451 | .em-btn-primary:hover {
452 | background: #1d4ed8;
453 | transform: translateY(-1px);
454 | box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
455 | }
456 |
457 | .em-btn-success {
458 | background: #10b981;
459 | color: #ffffff;
460 | }
461 |
462 | .em-btn-success:hover {
463 | background: #059669;
464 | transform: translateY(-1px);
465 | box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
466 | }
467 |
468 | .em-btn-ghost {
469 | background: #f5f5f5;
470 | color: #404040;
471 | }
472 |
473 | .em-btn-ghost:hover {
474 | background: #e5e5e5;
475 | }
476 |
477 | /* Footer */
478 | .em-footer {
479 | font-size: 12px;
480 | color: #a3a3a3;
481 | text-align: center;
482 | margin-top: 16px;
483 | }
484 |
485 | .em-footer kbd {
486 | display: inline-block;
487 | padding: 2px 6px;
488 | background: #f5f5f5;
489 | border-radius: 4px;
490 | font-family: monospace;
491 | font-size: 11px;
492 | color: #737373;
493 | }
494 |
495 | /* Status */
496 | .em-status {
497 | font-size: 13px;
498 | padding: 10px 12px;
499 | border-radius: 8px;
500 | margin-bottom: 12px;
501 | display: flex;
502 | align-items: center;
503 | gap: 6px;
504 | }
505 |
506 | .em-status.idle {
507 | display: none;
508 | }
509 |
510 | .em-status.running {
511 | background: rgba(37, 99, 235, 0.1);
512 | color: #2563eb;
513 | }
514 |
515 | .em-status.success {
516 | background: rgba(16, 185, 129, 0.1);
517 | color: #10b981;
518 | }
519 |
520 | .em-status.failure {
521 | background: rgba(239, 68, 68, 0.1);
522 | color: #ef4444;
523 | }
524 |
525 | /* Grid Layout */
526 | .em-grid {
527 | display: grid;
528 | grid-template-columns: repeat(2, 1fr);
529 | gap: 12px;
530 | }
531 |
532 | .em-field {
533 | display: flex;
534 | flex-direction: column;
535 | gap: 6px;
536 | }
537 |
538 | .em-field-label {
539 | font-size: 12px;
540 | color: #a3a3a3;
541 | }
542 |
543 | .em-field-input {
544 | height: 40px;
545 | padding: 0 12px;
546 | background: #f5f5f5;
547 | border: none;
548 | border-radius: 8px;
549 | font-size: 14px;
550 | color: #404040;
551 | font-family: inherit;
552 | outline: none;
553 | }
554 |
555 | .em-field-input:focus {
556 | background: #e5e5e5;
557 | }
558 |
559 | /* Details/Accordion */
560 | .em-details {
561 | margin-top: 12px;
562 | padding-top: 12px;
563 | border-top: 1px solid #f5f5f5;
564 | }
565 |
566 | .em-details summary {
567 | cursor: pointer;
568 | font-size: 13px;
569 | font-weight: 600;
570 | color: #737373;
571 | padding: 8px 0;
572 | user-select: none;
573 | list-style: none;
574 | }
575 |
576 | .em-details summary::-webkit-details-marker {
577 | display: none;
578 | }
579 |
580 | .em-details summary:hover {
581 | color: #404040;
582 | }
583 |
584 | .em-details[open] summary {
585 | margin-bottom: 12px;
586 | }
587 |
588 | /* Dragging state */
589 | body[data-em-dragging] {
590 | user-select: none !important;
591 | cursor: grabbing !important;
592 | }
593 |
594 | body[data-em-dragging] * {
595 | cursor: grabbing !important;
596 | }
597 |
598 | /* SVG Icons */
599 | svg {
600 | fill: none;
601 | stroke: currentColor;
602 | }
603 |
604 | .em-drag-handle {
605 | cursor: grab;
606 | }
607 |
608 | .em-drag-handle:active {
609 | cursor: grabbing;
610 | }
611 | `;
612 |
613 | const PANEL_TEMPLATE = `
614 | <div class="em-panel" id="em_panel_root">
615 | <!-- Header -->
616 | <div class="em-header em-drag-handle" id="__em_drag_handle" title="Drag to move">
617 | <h2 class="em-title">元素标注</h2>
618 | <div class="em-header-actions">
619 | <button class="em-icon-btn" id="__em_close" title="Close">
620 | <svg viewBox="0 0 24 24">
621 | <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
622 | </svg>
623 | </button>
624 | </div>
625 | </div>
626 |
627 | <!-- Controls -->
628 | <div class="em-controls">
629 | <div class="em-select-wrapper">
630 | <select class="em-select" id="__em_selector_type">
631 | <option value="css">CSS Selector</option>
632 | <option value="xpath">XPath</option>
633 | </select>
634 | </div>
635 | <button class="em-square-btn" id="__em_toggle_list" title="列表模式 - 批量标注相似元素 (仅支持CSS)">
636 | <svg viewBox="0 0 24 24">
637 | <path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
638 | </svg>
639 | </button>
640 | <button class="em-square-btn" id="__em_toggle_tab" title="Toggle Execute tab">
641 | <svg viewBox="0 0 24 24">
642 | <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"/>
643 | <path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
644 | </svg>
645 | </button>
646 | </div>
647 |
648 | <!-- Selector Display -->
649 | <div class="em-selector-display">
650 | <svg viewBox="0 0 24 24" id="__em_copy_selector" title="Copy selector">
651 | <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"/>
652 | </svg>
653 | <span class="em-selector-text" id="__em_selector_text">Click an element to select</span>
654 | <div class="em-selector-nav">
655 | <button class="em-nav-btn" id="__em_nav_up" title="Select parent">
656 | <svg viewBox="0 0 24 24">
657 | <path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
658 | </svg>
659 | </button>
660 | <button class="em-nav-btn" id="__em_nav_down" title="Select child">
661 | <svg viewBox="0 0 24 24">
662 | <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
663 | </svg>
664 | </button>
665 | </div>
666 | </div>
667 |
668 | <!-- Tabs -->
669 | <div class="em-tabs">
670 | <button class="em-tab active" data-tab="attributes">Attributes</button>
671 | <button class="em-tab" data-tab="execute">Execute</button>
672 | </div>
673 |
674 | <!-- Status -->
675 | <div class="em-status idle" id="__em_status"></div>
676 |
677 | <!-- Content: Attributes Tab -->
678 | <div class="em-content" id="__em_tab_attributes">
679 | <h3 class="em-section-title">#1 Element</h3>
680 |
681 | <div class="em-attributes">
682 | <div class="em-attribute">
683 | <div class="em-attribute-label">name</div>
684 | <div class="em-attribute-value editable">
685 | <input class="em-input" id="__em_name" placeholder="Element name" />
686 | </div>
687 | </div>
688 |
689 | <div class="em-attribute">
690 | <div class="em-attribute-label">selector</div>
691 | <div class="em-attribute-value">
692 | <svg class="copy-icon" viewBox="0 0 24 24" id="__em_copy" title="Copy">
693 | <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"/>
694 | </svg>
695 | <span class="em-attribute-text" id="__em_selector">-</span>
696 | </div>
697 | </div>
698 | </div>
699 |
700 | <h3 class="em-section-title">Selector Preferences</h3>
701 | <div class="em-settings">
702 | <div class="em-checkbox-group">
703 | <label class="em-checkbox-label">
704 | <input type="checkbox" id="__em_pref_id" checked />
705 | <span>Prefer ID</span>
706 | </label>
707 | <label class="em-checkbox-label">
708 | <input type="checkbox" id="__em_pref_attr" checked />
709 | <span>Prefer stable attributes</span>
710 | </label>
711 | <label class="em-checkbox-label">
712 | <input type="checkbox" id="__em_pref_class" checked />
713 | <span>Prefer class names</span>
714 | </label>
715 | </div>
716 | </div>
717 |
718 | <div class="em-actions">
719 | <button class="em-btn em-btn-primary" id="__em_verify">Verify (Highlight Only)</button>
720 | </div>
721 |
722 | <div class="em-actions">
723 | <button class="em-btn em-btn-success" id="__em_save">Save</button>
724 | <button class="em-btn em-btn-ghost" id="__em_cancel">Cancel</button>
725 | </div>
726 | </div>
727 |
728 | <!-- Content: Execute Tab -->
729 | <div class="em-content" id="__em_tab_execute" style="display: none;">
730 | <div class="em-settings">
731 | <div class="em-settings-group">
732 | <div class="em-settings-label">Action</div>
733 | <div class="em-select-wrapper">
734 | <select class="em-select" id="__em_action">
735 | <option value="hover">Hover</option>
736 | <option value="left_click">Left click</option>
737 | <option value="double_click">Double click</option>
738 | <option value="right_click">Right click</option>
739 | <option value="scroll">Scroll</option>
740 | <option value="type_text">Type text</option>
741 | <option value="press_keys">Press keys</option>
742 | </select>
743 | </div>
744 | </div>
745 |
746 | <!-- Action-specific inputs (dynamically shown/hidden) -->
747 | <div class="em-settings-group" id="__em_action_text_group" style="display: none;">
748 | <div class="em-settings-label">Text</div>
749 | <input class="em-field-input" id="__em_action_text" placeholder="Text to type" />
750 | </div>
751 |
752 | <div class="em-settings-group" id="__em_action_keys_group" style="display: none;">
753 | <div class="em-settings-label">Keys</div>
754 | <input class="em-field-input" id="__em_action_keys" placeholder="Keys to press (e.g., Enter, Ctrl+C)" />
755 | </div>
756 |
757 | <div class="em-settings-group" id="__em_scroll_options" style="display: none;">
758 | <div class="em-settings-label">Scroll Direction</div>
759 | <div class="em-select-wrapper">
760 | <select class="em-select" id="__em_scroll_direction">
761 | <option value="down">Down</option>
762 | <option value="up">Up</option>
763 | <option value="left">Left</option>
764 | <option value="right">Right</option>
765 | </select>
766 | </div>
767 | <div class="em-field" style="margin-top: 8px;">
768 | <div class="em-field-label">Amount (1-10, ~100px each)</div>
769 | <input class="em-field-input" id="__em_scroll_distance" type="number" min="1" max="10" step="1" value="3" />
770 | </div>
771 | </div>
772 |
773 | <!-- Click-specific options -->
774 | <div id="__em_click_options" style="display: none;">
775 | <div class="em-grid">
776 | <div class="em-field">
777 | <div class="em-field-label">Button</div>
778 | <select class="em-select" id="__em_btn">
779 | <option value="left">Left</option>
780 | <option value="middle">Middle</option>
781 | <option value="right">Right</option>
782 | </select>
783 | </div>
784 | <div class="em-field">
785 | <div class="em-field-label">Timeout (ms)</div>
786 | <input class="em-field-input" id="__em_nav_timeout" type="number" value="3000" />
787 | </div>
788 | </div>
789 |
790 | <div class="em-checkbox-group" style="margin-top: 12px;">
791 | <label class="em-checkbox-label">
792 | <input type="checkbox" id="__em_wait_nav" />
793 | <span>Wait for navigation</span>
794 | </label>
795 | <label class="em-checkbox-label">
796 | <input type="checkbox" id="__em_mod_alt" />
797 | <span>Alt key</span>
798 | </label>
799 | <label class="em-checkbox-label">
800 | <input type="checkbox" id="__em_mod_ctrl" />
801 | <span>Ctrl key</span>
802 | </label>
803 | <label class="em-checkbox-label">
804 | <input type="checkbox" id="__em_mod_meta" />
805 | <span>Meta key</span>
806 | </label>
807 | <label class="em-checkbox-label">
808 | <input type="checkbox" id="__em_mod_shift" />
809 | <span>Shift key</span>
810 | </label>
811 | </div>
812 | </div>
813 |
814 | <div class="em-actions" style="margin-top: 16px;">
815 | <button class="em-btn em-btn-primary" id="__em_execute">Execute</button>
816 | </div>
817 |
818 | <!-- Execution History -->
819 | <div id="__em_execution_history" style="margin-top: 16px; display: none;">
820 | <div class="em-settings-label">Recent Executions</div>
821 | <div id="__em_history_list" style="font-size: 12px; color: #737373; margin-top: 8px;"></div>
822 | </div>
823 | </div>
824 | </div>
825 |
826 | <!-- Footer -->
827 | <div class="em-footer">
828 | Click or press <kbd>Space</kbd> to select an element
829 | </div>
830 | </div>
831 | `;
832 |
833 | function mount() {
834 | if (hostElement) return { host: hostElement, shadow: shadowRoot };
835 |
836 | hostElement = document.createElement('div');
837 | hostElement.id = '__element_marker_overlay';
838 | Object.assign(hostElement.style, {
839 | position: 'fixed',
840 | top: '24px',
841 | right: '24px',
842 | zIndex: String(CONFIG.Z_INDEX.OVERLAY),
843 | pointerEvents: 'none',
844 | });
845 |
846 | shadowRoot = hostElement.attachShadow({ mode: 'open' });
847 | shadowRoot.innerHTML = `<style>${PANEL_STYLES}</style>${PANEL_TEMPLATE}`;
848 |
849 | hostElement.querySelector = (...args) => shadowRoot.querySelector(...args);
850 | hostElement.querySelectorAll = (...args) => shadowRoot.querySelectorAll(...args);
851 |
852 | const panel = shadowRoot.querySelector('.em-panel');
853 | if (panel) {
854 | panel.style.pointerEvents = 'auto';
855 | }
856 |
857 | document.documentElement.appendChild(hostElement);
858 | return { host: hostElement, shadow: shadowRoot };
859 | }
860 |
861 | function unmount() {
862 | if (hostElement?.parentNode) {
863 | hostElement.parentNode.removeChild(hostElement);
864 | }
865 | hostElement = null;
866 | shadowRoot = null;
867 | }
868 |
869 | function getHost() {
870 | return hostElement;
871 | }
872 |
873 | function getShadow() {
874 | return shadowRoot;
875 | }
876 |
877 | return {
878 | mount,
879 | unmount,
880 | getHost,
881 | getShadow,
882 | };
883 | })();
884 |
885 | // ============================================================================
886 | // State Store Module - Centralized State Management
887 | // ============================================================================
888 |
889 | const StateStore = (() => {
890 | const state = {
891 | selectorType: CONFIG.DEFAULTS.SELECTOR_TYPE,
892 | listMode: CONFIG.DEFAULTS.LIST_MODE,
893 | prefs: { ...CONFIG.DEFAULTS.PREFS },
894 | activeTab: 'attributes',
895 | validation: {
896 | status: 'idle',
897 | message: '',
898 | },
899 | validationHistory: [], // Last 5 validation results
900 | };
901 |
902 | const listeners = new Set();
903 |
904 | function init() {
905 | return state;
906 | }
907 |
908 | function get(key) {
909 | return key ? state[key] : state;
910 | }
911 |
912 | function set(partial) {
913 | const changed = {};
914 |
915 | Object.keys(partial).forEach((key) => {
916 | if (JSON.stringify(state[key]) !== JSON.stringify(partial[key])) {
917 | changed[key] = true;
918 | state[key] = partial[key];
919 | }
920 | });
921 |
922 | if (Object.keys(changed).length === 0) return;
923 |
924 | if (changed.validation) {
925 | updateValidationUI();
926 | }
927 | if (changed.activeTab) {
928 | updateTabUI();
929 | }
930 | if (changed.listMode) {
931 | updateListModeUI();
932 | }
933 | if (changed.validationHistory) {
934 | updateValidationHistoryUI();
935 | }
936 |
937 | notifyListeners();
938 | }
939 |
940 | function subscribe(callback) {
941 | listeners.add(callback);
942 | return () => listeners.delete(callback);
943 | }
944 |
945 | function notifyListeners() {
946 | listeners.forEach((cb) => {
947 | try {
948 | cb(state);
949 | } catch (err) {
950 | console.error('[StateStore] Listener error:', err);
951 | }
952 | });
953 | }
954 |
955 | function updateValidationUI() {
956 | const statusEl = PanelHost.getShadow()?.getElementById('__em_status');
957 | if (!statusEl) return;
958 |
959 | const { status, message } = state.validation;
960 | statusEl.className = `em-status ${status}`;
961 | statusEl.textContent = message;
962 | }
963 |
964 | function updateListModeUI() {
965 | const shadow = PanelHost.getShadow();
966 | if (!shadow) return;
967 |
968 | const btn = shadow.getElementById('__em_toggle_list');
969 | if (!btn) return;
970 |
971 | if (state.listMode) {
972 | btn.classList.add('active');
973 | } else {
974 | btn.classList.remove('active');
975 | }
976 | }
977 |
978 | function updateTabUI() {
979 | const shadow = PanelHost.getShadow();
980 | if (!shadow) return;
981 |
982 | const tabs = shadow.querySelectorAll('.em-tab');
983 | tabs.forEach((tab) => {
984 | if (tab.dataset.tab === state.activeTab) {
985 | tab.classList.add('active');
986 | } else {
987 | tab.classList.remove('active');
988 | }
989 | });
990 |
991 | const attrContent = shadow.getElementById('__em_tab_attributes');
992 | const executeContent = shadow.getElementById('__em_tab_execute');
993 |
994 | if (attrContent)
995 | attrContent.style.display = state.activeTab === 'attributes' ? 'block' : 'none';
996 | if (executeContent)
997 | executeContent.style.display = state.activeTab === 'execute' ? 'block' : 'none';
998 |
999 | // Sync interaction mode when tab changes
1000 | syncInteractionMode();
1001 | }
1002 |
1003 | function updateValidationHistoryUI() {
1004 | const shadow = PanelHost.getShadow();
1005 | if (!shadow) return;
1006 |
1007 | const historyContainer = shadow.getElementById('__em_execution_history');
1008 | const historyList = shadow.getElementById('__em_history_list');
1009 | if (!historyContainer || !historyList) return;
1010 |
1011 | if (state.validationHistory.length === 0) {
1012 | historyContainer.style.display = 'none';
1013 | return;
1014 | }
1015 |
1016 | historyContainer.style.display = 'block';
1017 | historyList.innerHTML = state.validationHistory
1018 | .slice(-5)
1019 | .reverse()
1020 | .map((entry) => {
1021 | const icon = entry.success ? '✓' : '✗';
1022 | const color = entry.success ? '#10b981' : '#ef4444';
1023 | const timestamp = new Date(entry.timestamp).toLocaleTimeString();
1024 | return `<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5;">
1025 | <span style="color: ${color}; font-weight: 600;">${icon}</span>
1026 | <span style="margin-left: 6px;">${entry.action}</span>
1027 | <span style="float: right; color: #a3a3a3; font-size: 11px;">${timestamp}</span>
1028 | </div>`;
1029 | })
1030 | .join('');
1031 | }
1032 |
1033 | return {
1034 | init,
1035 | get,
1036 | set,
1037 | subscribe,
1038 | };
1039 | })();
1040 |
1041 | // ============================================================================
1042 | // Drag Controller Module
1043 | // ============================================================================
1044 |
1045 | const DragController = (() => {
1046 | let dragging = false;
1047 | let startPos = { x: 0, y: 0 };
1048 | let startOffset = { top: 0, right: 0 };
1049 |
1050 | function init(handleElement) {
1051 | if (!handleElement) return;
1052 | handleElement.addEventListener('mousedown', onDragStart);
1053 | }
1054 |
1055 | function onDragStart(event) {
1056 | event.preventDefault();
1057 | dragging = true;
1058 |
1059 | const host = PanelHost.getHost();
1060 | if (!host) return;
1061 |
1062 | startPos = { x: event.clientX, y: event.clientY };
1063 | startOffset = {
1064 | top: parseInt(host.style.top) || 0,
1065 | right: parseInt(host.style.right) || 0,
1066 | };
1067 |
1068 | document.addEventListener('mousemove', onDragMove, { capture: true, passive: false });
1069 | document.addEventListener('mouseup', onDragEnd, { capture: true, passive: false });
1070 | document.body.setAttribute('data-em-dragging', 'true');
1071 | }
1072 |
1073 | function onDragMove(event) {
1074 | if (!dragging) return;
1075 | event.preventDefault();
1076 | event.stopPropagation();
1077 |
1078 | const host = PanelHost.getHost();
1079 | if (!host) return;
1080 |
1081 | const deltaX = event.clientX - startPos.x;
1082 | const deltaY = event.clientY - startPos.y;
1083 |
1084 | const newTop = Math.max(8, startOffset.top + deltaY);
1085 | const newRight = Math.max(8, startOffset.right - deltaX);
1086 |
1087 | host.style.top = `${newTop}px`;
1088 | host.style.right = `${newRight}px`;
1089 | }
1090 |
1091 | function onDragEnd(event) {
1092 | if (!dragging) return;
1093 | event.preventDefault();
1094 | event.stopPropagation();
1095 |
1096 | dragging = false;
1097 | document.removeEventListener('mousemove', onDragMove, { capture: true });
1098 | document.removeEventListener('mouseup', onDragEnd, { capture: true });
1099 | document.body.removeAttribute('data-em-dragging');
1100 | }
1101 |
1102 | function destroy() {
1103 | if (dragging) {
1104 | onDragEnd(new MouseEvent('mouseup'));
1105 | }
1106 | }
1107 |
1108 | return { init, destroy };
1109 | })();
1110 |
1111 | // [继续下一部分...]
1112 | // ============================================================================
1113 | // Selector Engine - Heuristic Selector Generation
1114 | // ============================================================================
1115 |
1116 | function generateSelector(el) {
1117 | if (!(el instanceof Element)) return '';
1118 |
1119 | const prefs = StateStore.get('prefs');
1120 |
1121 | if (prefs.preferId && el.id) {
1122 | const idSel = `#${CSS.escape(el.id)}`;
1123 | if (isDeepSelectorUnique(idSel, el)) return idSel;
1124 | }
1125 |
1126 | if (prefs.preferStableAttr) {
1127 | const attrNames = [
1128 | 'data-testid',
1129 | 'data-testId',
1130 | 'data-test',
1131 | 'data-qa',
1132 | 'data-cy',
1133 | 'name',
1134 | 'title',
1135 | 'alt',
1136 | 'aria-label',
1137 | ];
1138 | const tag = el.tagName.toLowerCase();
1139 |
1140 | for (const attr of attrNames) {
1141 | const v = el.getAttribute(attr);
1142 | if (!v) continue;
1143 | const attrSel = `[${attr}="${CSS.escape(v)}"]`;
1144 | const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;
1145 | if (isDeepSelectorUnique(testSel, el)) return testSel;
1146 | }
1147 | }
1148 |
1149 | if (prefs.preferClass) {
1150 | try {
1151 | const classes = Array.from(el.classList || []).filter(
1152 | (c) => c && /^[a-zA-Z0-9_-]+$/.test(c),
1153 | );
1154 | const tag = el.tagName.toLowerCase();
1155 |
1156 | for (const cls of classes) {
1157 | const sel = `.${CSS.escape(cls)}`;
1158 | if (isDeepSelectorUnique(sel, el)) return sel;
1159 | }
1160 |
1161 | for (const cls of classes) {
1162 | const sel = `${tag}.${CSS.escape(cls)}`;
1163 | if (isDeepSelectorUnique(sel, el)) return sel;
1164 | }
1165 |
1166 | for (let i = 0; i < Math.min(classes.length, 3); i++) {
1167 | for (let j = i + 1; j < Math.min(classes.length, 3); j++) {
1168 | const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;
1169 | if (isDeepSelectorUnique(sel, el)) return sel;
1170 | }
1171 | }
1172 | } catch {}
1173 | }
1174 |
1175 | if (prefs.preferStableAttr) {
1176 | try {
1177 | let cur = el;
1178 | const anchorAttrs = [
1179 | 'id',
1180 | 'data-testid',
1181 | 'data-testId',
1182 | 'data-test',
1183 | 'data-qa',
1184 | 'data-cy',
1185 | 'name',
1186 | ];
1187 |
1188 | // Detect shadow DOM boundary
1189 | const root = el.getRootNode();
1190 | const isShadowElement = root instanceof ShadowRoot;
1191 | const boundary = isShadowElement ? root.host : document.body;
1192 |
1193 | while (cur && cur !== boundary) {
1194 | if (cur.id) {
1195 | const anchor = `#${CSS.escape(cur.id)}`;
1196 | if (isDeepSelectorUnique(anchor, cur)) {
1197 | const rel = buildPathFromAncestor(cur, el);
1198 | const composed = rel ? `${anchor} ${rel}` : anchor;
1199 | if (isDeepSelectorUnique(composed, el)) return composed;
1200 | }
1201 | }
1202 |
1203 | for (const attr of anchorAttrs) {
1204 | const val = cur.getAttribute(attr);
1205 | if (!val) continue;
1206 | const aSel = `[${attr}="${CSS.escape(val)}"]`;
1207 | if (isDeepSelectorUnique(aSel, cur)) {
1208 | const rel = buildPathFromAncestor(cur, el);
1209 | const composed = rel ? `${aSel} ${rel}` : aSel;
1210 | if (isDeepSelectorUnique(composed, el)) return composed;
1211 | }
1212 | }
1213 | cur = cur.parentElement;
1214 | }
1215 | } catch {}
1216 | }
1217 |
1218 | return buildFullPath(el);
1219 | }
1220 |
1221 | function buildPathFromAncestor(ancestor, target) {
1222 | const segs = [];
1223 | let cur = target;
1224 |
1225 | // Detect if we're inside shadow DOM
1226 | const root = target.getRootNode();
1227 | const isShadowElement = root instanceof ShadowRoot;
1228 | const boundary = isShadowElement ? root.host : document.body;
1229 |
1230 | while (cur && cur !== ancestor && cur !== boundary) {
1231 | let seg = cur.tagName.toLowerCase();
1232 | const parent = cur.parentElement;
1233 |
1234 | if (parent) {
1235 | const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
1236 | if (siblings.length > 1) {
1237 | seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
1238 | }
1239 | }
1240 |
1241 | segs.unshift(seg);
1242 | cur = parent;
1243 |
1244 | // Stop if we've reached the shadow root host
1245 | if (isShadowElement && cur === boundary) {
1246 | break;
1247 | }
1248 | }
1249 |
1250 | return segs.join(' > ');
1251 | }
1252 |
1253 | function buildFullPath(el) {
1254 | let path = '';
1255 | let current = el;
1256 |
1257 | // Detect if the element is inside a shadow DOM
1258 | const root = el.getRootNode();
1259 | const isShadowElement = root instanceof ShadowRoot;
1260 |
1261 | // Determine the boundary where we should stop traversing
1262 | const boundary = isShadowElement ? root.host : document.body;
1263 |
1264 | while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {
1265 | let sel = current.tagName.toLowerCase();
1266 | const parent = current.parentElement;
1267 |
1268 | if (parent) {
1269 | const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
1270 | if (siblings.length > 1) {
1271 | sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;
1272 | }
1273 | }
1274 |
1275 | path = path ? `${sel} > ${path}` : sel;
1276 | current = parent;
1277 |
1278 | // Stop if we've reached the shadow root host
1279 | if (isShadowElement && current === boundary) {
1280 | break;
1281 | }
1282 | }
1283 |
1284 | // For shadow DOM elements, don't prepend "body >"
1285 | // The selector should be relative within the shadow tree
1286 | if (isShadowElement) {
1287 | return path || el.tagName.toLowerCase();
1288 | }
1289 |
1290 | // For light DOM elements, keep the original behavior
1291 | return path ? `body > ${path}` : 'body';
1292 | }
1293 |
1294 | function generateXPath(el) {
1295 | if (!(el instanceof Element)) return '';
1296 | if (el.id) return `//*[@id="${el.id}"]`;
1297 |
1298 | const segs = [];
1299 | let cur = el;
1300 |
1301 | while (cur && cur.nodeType === 1 && cur !== document.documentElement) {
1302 | const tag = cur.tagName.toLowerCase();
1303 |
1304 | if (cur.id) {
1305 | segs.unshift(`//*[@id="${cur.id}"]`);
1306 | break;
1307 | }
1308 |
1309 | let i = 1;
1310 | let sib = cur;
1311 | while ((sib = sib.previousElementSibling)) {
1312 | if (sib.tagName.toLowerCase() === tag) i++;
1313 | }
1314 |
1315 | segs.unshift(`${tag}[${i}]`);
1316 | cur = cur.parentElement;
1317 | }
1318 |
1319 | return segs[0]?.startsWith('//*') ? segs.join('/') : '//' + segs.join('/');
1320 | }
1321 |
1322 | function generateListSelector(target) {
1323 | const list = computeElementList(target);
1324 | const selected = list?.[0] || target;
1325 | const parent = selected.parentElement;
1326 |
1327 | if (!parent) return generateSelector(target);
1328 |
1329 | const parentSel = generateSelector(parent);
1330 | const childRel = generateSelectorWithinRoot(selected, parent);
1331 |
1332 | return parentSel && childRel ? `${parentSel} ${childRel}` : generateSelector(target);
1333 | }
1334 |
1335 | function generateSelectorWithinRoot(el, root) {
1336 | if (!(el instanceof Element)) return '';
1337 |
1338 | const tag = el.tagName.toLowerCase();
1339 |
1340 | // Use isDeepSelectorUnique for ID to support shadow DOM elements
1341 | if (el.id) {
1342 | const idSel = `#${CSS.escape(el.id)}`;
1343 | if (isDeepSelectorUnique(idSel, el)) return idSel;
1344 | }
1345 |
1346 | const attrNames = [
1347 | 'data-testid',
1348 | 'data-testId',
1349 | 'data-test',
1350 | 'data-qa',
1351 | 'data-cy',
1352 | 'name',
1353 | 'title',
1354 | 'alt',
1355 | 'aria-label',
1356 | ];
1357 |
1358 | // Use isDeepSelectorUnique for attributes to support shadow DOM elements
1359 | for (const attr of attrNames) {
1360 | const v = el.getAttribute(attr);
1361 | if (!v) continue;
1362 | const aSel = `[${attr}="${CSS.escape(v)}"]`;
1363 | const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${aSel}` : aSel;
1364 | if (isDeepSelectorUnique(testSel, el)) return testSel;
1365 | }
1366 |
1367 | try {
1368 | const classes = Array.from(el.classList || []).filter((c) => c && /^[a-zA-Z0-9_-]+$/.test(c));
1369 |
1370 | // Use isDeepSelectorUnique for classes to support shadow DOM elements
1371 | for (const cls of classes) {
1372 | const sel = `.${CSS.escape(cls)}`;
1373 | if (isDeepSelectorUnique(sel, el)) return sel;
1374 | }
1375 |
1376 | for (const cls of classes) {
1377 | const sel = `${tag}.${CSS.escape(cls)}`;
1378 | if (isDeepSelectorUnique(sel, el)) return sel;
1379 | }
1380 | } catch {}
1381 |
1382 | return buildPathFromAncestor(root, el);
1383 | }
1384 |
1385 | function getAccessibleName(el) {
1386 | try {
1387 | const labelledby = el.getAttribute('aria-labelledby');
1388 | if (labelledby) {
1389 | const labelEl = document.getElementById(labelledby);
1390 | if (labelEl) return (labelEl.textContent || '').trim();
1391 | }
1392 |
1393 | const ariaLabel = el.getAttribute('aria-label');
1394 | if (ariaLabel) return ariaLabel.trim();
1395 |
1396 | if (el.id) {
1397 | const label = document.querySelector(`label[for="${el.id}"]`);
1398 | if (label) return (label.textContent || '').trim();
1399 | }
1400 |
1401 | const parentLabel = el.closest('label');
1402 | if (parentLabel) return (parentLabel.textContent || '').trim();
1403 |
1404 | return (
1405 | el.getAttribute('placeholder') ||
1406 | el.getAttribute('value') ||
1407 | el.textContent ||
1408 | ''
1409 | ).trim();
1410 | } catch {
1411 | return '';
1412 | }
1413 | }
1414 |
1415 | // ============================================================================
1416 | // List Mode Utilities
1417 | // ============================================================================
1418 |
1419 | function getAllSiblings(el, selector) {
1420 | const siblings = [el];
1421 | const validate = (element) => {
1422 | const isSameTag = el.tagName === element.tagName;
1423 | let ok = isSameTag;
1424 | if (selector) {
1425 | try {
1426 | ok = ok && !!element.querySelector(selector);
1427 | } catch {}
1428 | }
1429 | return ok;
1430 | };
1431 |
1432 | let next = el;
1433 | let prev = el;
1434 | let elementIndex = 1;
1435 |
1436 | while ((prev = prev?.previousElementSibling)) {
1437 | if (validate(prev)) {
1438 | elementIndex += 1;
1439 | siblings.unshift(prev);
1440 | }
1441 | }
1442 |
1443 | while ((next = next?.nextElementSibling)) {
1444 | if (validate(next)) siblings.push(next);
1445 | }
1446 |
1447 | return { elements: siblings, index: elementIndex };
1448 | }
1449 |
1450 | function getElementList(el, maxDepth = 50, paths = []) {
1451 | if (maxDepth === 0 || !el || el.tagName === 'BODY') return null;
1452 |
1453 | let selector = el.tagName.toLowerCase();
1454 | const { elements, index } = getAllSiblings(el, paths.join(' > '));
1455 | let siblings = elements;
1456 |
1457 | if (index !== 1) selector += `:nth-of-type(${index})`;
1458 | paths.unshift(selector);
1459 |
1460 | if (siblings.length === 1) {
1461 | siblings = getElementList(el.parentElement, maxDepth - 1, paths);
1462 | }
1463 |
1464 | return siblings;
1465 | }
1466 |
1467 | function computeElementList(target) {
1468 | try {
1469 | return getElementList(target) || [target];
1470 | } catch {
1471 | return [target];
1472 | }
1473 | }
1474 |
1475 | // ============================================================================
1476 | // Deep Query (Shadow DOM Support)
1477 | // ============================================================================
1478 |
1479 | function* walkAllNodesDeep(root) {
1480 | const stack = [root];
1481 | let count = 0;
1482 | const MAX = 10000;
1483 |
1484 | while (stack.length) {
1485 | const node = stack.pop();
1486 | if (!node || ++count > MAX) continue;
1487 |
1488 | // Skip overlay elements to prevent panel self-highlighting
1489 | if (isOverlayElement(node)) {
1490 | continue;
1491 | }
1492 |
1493 | yield node;
1494 |
1495 | try {
1496 | if (node.children) {
1497 | const children = Array.from(node.children);
1498 | for (let i = children.length - 1; i >= 0; i--) {
1499 | stack.push(children[i]);
1500 | }
1501 | }
1502 |
1503 | if (node.shadowRoot?.children) {
1504 | const srChildren = Array.from(node.shadowRoot.children);
1505 | for (let i = srChildren.length - 1; i >= 0; i--) {
1506 | stack.push(srChildren[i]);
1507 | }
1508 | }
1509 | } catch {}
1510 | }
1511 | }
1512 |
1513 | function queryAllDeep(selector) {
1514 | const results = [];
1515 | for (const node of walkAllNodesDeep(document)) {
1516 | if (!(node instanceof Element)) continue;
1517 | try {
1518 | if (node.matches(selector)) results.push(node);
1519 | } catch {}
1520 | }
1521 | return results;
1522 | }
1523 |
1524 | /**
1525 | * Check if a selector uniquely identifies the target element across the entire DOM tree,
1526 | * including shadow DOM boundaries.
1527 | *
1528 | * This function uses queryAllDeep to traverse both light DOM and shadow DOM,
1529 | * ensuring that selectors work correctly for elements inside shadow roots.
1530 | *
1531 | * @param {string} selector - The CSS selector to test
1532 | * @param {Element} target - The target element that should be uniquely identified
1533 | * @returns {boolean} True if the selector matches exactly one element and it's the target
1534 | */
1535 | function isDeepSelectorUnique(selector, target) {
1536 | if (!selector || !(target instanceof Element)) return false;
1537 | try {
1538 | const matches = queryAllDeep(selector);
1539 | return matches.length === 1 && matches[0] === target;
1540 | } catch (error) {
1541 | return false;
1542 | }
1543 | }
1544 |
1545 | function evaluateXPathAll(xpath) {
1546 | try {
1547 | const arr = [];
1548 | const res = document.evaluate(
1549 | xpath,
1550 | document,
1551 | null,
1552 | XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
1553 | null,
1554 | );
1555 |
1556 | for (let i = 0; i < res.snapshotLength; i++) {
1557 | const n = res.snapshotItem(i);
1558 | // Filter out overlay elements to prevent panel self-highlighting
1559 | if (n?.nodeType === 1 && !isOverlayElement(n)) {
1560 | arr.push(n);
1561 | }
1562 | }
1563 | return arr;
1564 | } catch {
1565 | return [];
1566 | }
1567 | }
1568 |
1569 | // ============================================================================
1570 | // Highlighter & Rects Management
1571 | // ============================================================================
1572 |
1573 | const STATE = {
1574 | active: false,
1575 | hoverEl: null,
1576 | selectedEl: null,
1577 | box: null,
1578 | highlighter: null,
1579 | listenersAttached: false,
1580 | rectsHost: null,
1581 | hoveredList: [],
1582 | verifyRectsActive: false, // Track if verify rects are showing
1583 | // Performance optimization: rAF throttling for hover
1584 | hoverRafId: null,
1585 | lastHoverTarget: null,
1586 | // DOM pooling for rect elements
1587 | rectPool: [],
1588 | rectPoolUsed: 0,
1589 | };
1590 |
1591 | function ensureHighlighter() {
1592 | if (STATE.highlighter) return STATE.highlighter;
1593 |
1594 | const hl = document.createElement('div');
1595 | hl.id = '__element_marker_highlight';
1596 | Object.assign(hl.style, {
1597 | position: 'fixed',
1598 | zIndex: String(CONFIG.Z_INDEX.HIGHLIGHTER),
1599 | pointerEvents: 'none',
1600 | border: `2px solid ${CONFIG.COLORS.HOVER}`,
1601 | borderRadius: '4px',
1602 | boxShadow: `0 0 0 2px ${CONFIG.COLORS.HOVER}33`,
1603 | transition: 'all 100ms ease-out',
1604 | });
1605 |
1606 | document.documentElement.appendChild(hl);
1607 | STATE.highlighter = hl;
1608 | return hl;
1609 | }
1610 |
1611 | function ensureRectsHost() {
1612 | if (STATE.rectsHost) return STATE.rectsHost;
1613 |
1614 | const host = document.createElement('div');
1615 | host.id = '__element_marker_rects';
1616 | Object.assign(host.style, {
1617 | position: 'fixed',
1618 | zIndex: String(CONFIG.Z_INDEX.RECTS),
1619 | pointerEvents: 'none',
1620 | inset: '0',
1621 | });
1622 |
1623 | document.documentElement.appendChild(host);
1624 | STATE.rectsHost = host;
1625 | return host;
1626 | }
1627 |
1628 | function moveHighlighterTo(el) {
1629 | const hl = ensureHighlighter();
1630 | const r = el.getBoundingClientRect();
1631 | hl.style.left = `${r.left}px`;
1632 | hl.style.top = `${r.top}px`;
1633 | hl.style.width = `${r.width}px`;
1634 | hl.style.height = `${r.height}px`;
1635 | hl.style.display = 'block';
1636 | }
1637 |
1638 | function clearHighlighter() {
1639 | if (STATE.highlighter) STATE.highlighter.style.display = 'none';
1640 | // Only clear hover rects, not verify rects
1641 | if (!STATE.verifyRectsActive) {
1642 | clearRects();
1643 | }
1644 | }
1645 |
1646 | function clearRects() {
1647 | // Hide all pooled rect boxes instead of destroying them
1648 | const used = STATE.rectPoolUsed || 0;
1649 | for (let i = 0; i < used; i++) {
1650 | const box = STATE.rectPool[i];
1651 | if (box) box.style.display = 'none';
1652 | }
1653 | STATE.rectPoolUsed = 0;
1654 | STATE.verifyRectsActive = false;
1655 | // Invalidate lastHoverTarget so next hover will redraw even on same element
1656 | STATE.lastHoverTarget = null;
1657 | }
1658 |
1659 | /**
1660 | * Get or create a rect box from the pool
1661 | * @param {HTMLElement} host - The container element
1662 | * @param {number} index - The pool index
1663 | * @returns {HTMLDivElement} The rect box element
1664 | */
1665 | function getOrCreateRectBox(host, index) {
1666 | let box = STATE.rectPool[index];
1667 | if (!box) {
1668 | box = document.createElement('div');
1669 | Object.assign(box.style, {
1670 | position: 'fixed',
1671 | pointerEvents: 'none',
1672 | borderRadius: '4px',
1673 | transition: 'all 100ms ease-out',
1674 | display: 'none',
1675 | });
1676 | STATE.rectPool[index] = box;
1677 | }
1678 | // Ensure the box is attached to the host
1679 | if (!box.isConnected) {
1680 | host.appendChild(box);
1681 | }
1682 | return box;
1683 | }
1684 |
1685 | // Maximum rect pool size to prevent memory bloat
1686 | const MAX_RECT_POOL_SIZE = 100;
1687 |
1688 | /**
1689 | * Draw rect boxes with pooling optimization
1690 | * @param {Array<{x: number, y: number, width: number, height: number}>} rects - Rect data
1691 | * @param {Object} options - Drawing options
1692 | * @param {boolean} options.isVerify - Whether this is a verify highlight (affects verifyRectsActive)
1693 | */
1694 | function drawRectBoxes(
1695 | rects,
1696 | { color = CONFIG.COLORS.HOVER, dashed = true, offsetX = 0, offsetY = 0, isVerify = false } = {},
1697 | ) {
1698 | const host = ensureRectsHost();
1699 | const prevUsed = STATE.rectPoolUsed || 0;
1700 | // Limit rect count to prevent memory bloat
1701 | const count = Math.min(Array.isArray(rects) ? rects.length : 0, MAX_RECT_POOL_SIZE);
1702 |
1703 | // Update or show rect boxes
1704 | for (let i = 0; i < count; i++) {
1705 | const r = rects[i];
1706 | if (!r) continue;
1707 |
1708 | const x = Number.isFinite(r.left) ? r.left : Number.isFinite(r.x) ? r.x : 0;
1709 | const y = Number.isFinite(r.top) ? r.top : Number.isFinite(r.y) ? r.y : 0;
1710 | const w = Number.isFinite(r.width) ? r.width : 0;
1711 | const h = Number.isFinite(r.height) ? r.height : 0;
1712 |
1713 | const box = getOrCreateRectBox(host, i);
1714 | Object.assign(box.style, {
1715 | left: `${offsetX + x}px`,
1716 | top: `${offsetY + y}px`,
1717 | width: `${w}px`,
1718 | height: `${h}px`,
1719 | border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`,
1720 | boxShadow: `0 0 0 2px ${color}22`,
1721 | display: 'block',
1722 | });
1723 | }
1724 |
1725 | // Hide excess boxes from previous render
1726 | for (let i = count; i < prevUsed; i++) {
1727 | const box = STATE.rectPool[i];
1728 | if (box) box.style.display = 'none';
1729 | }
1730 |
1731 | STATE.rectPoolUsed = count;
1732 | // Reset verifyRectsActive for hover operations (so clearHighlighter works correctly)
1733 | // Only set to true when isVerify is explicitly true
1734 | STATE.verifyRectsActive = isVerify;
1735 | }
1736 |
1737 | function drawRects(elements, color = CONFIG.COLORS.HOVER, dashed = true, isVerify = false) {
1738 | const rects = elements.map((el) => {
1739 | const r = el.getBoundingClientRect();
1740 | return { x: r.left, y: r.top, width: r.width, height: r.height };
1741 | });
1742 | drawRectBoxes(rects, { color, dashed, isVerify });
1743 | }
1744 |
1745 | // ============================================================================
1746 | // Interaction Logic
1747 | // ============================================================================
1748 |
1749 | function isInsidePanel(target) {
1750 | const shadow = PanelHost.getShadow();
1751 | return !!shadow && target instanceof Node && shadow.contains(target);
1752 | }
1753 |
1754 | /**
1755 | * Check if a node belongs to the element marker overlay (panel host or its shadow DOM)
1756 | * This is used to filter out overlay elements from query results to prevent self-highlighting
1757 | *
1758 | * @param {Node} node - The node to check
1759 | * @returns {boolean} True if the node is part of the overlay
1760 | */
1761 | function isOverlayElement(node) {
1762 | if (!(node instanceof Node)) return false;
1763 |
1764 | const host = PanelHost.getHost();
1765 | if (!host) return false;
1766 |
1767 | // Check if node is the panel host itself
1768 | if (node === host) return true;
1769 |
1770 | // Check if node is within the shadow DOM of the panel host
1771 | const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;
1772 | return root instanceof ShadowRoot && root.host === host;
1773 | }
1774 |
1775 | /**
1776 | * Filter out overlay elements from an array of elements
1777 | * This ensures that panel components are never included in highlight/verification results
1778 | *
1779 | * @param {Array} elements - Array of elements to filter
1780 | * @returns {Array} Filtered array without overlay elements
1781 | */
1782 | function filterOverlayElements(elements) {
1783 | if (!Array.isArray(elements)) return [];
1784 | return elements.filter((node) => !isOverlayElement(node));
1785 | }
1786 |
1787 | /**
1788 | * Get the effective event target for page element selection, considering shadow DOM boundaries.
1789 | *
1790 | * This function resolves the real target element from a pointer event by walking the
1791 | * composed path (if available) to find the innermost page element, skipping overlay elements.
1792 | *
1793 | * Background:
1794 | * - When events bubble up from inside shadow DOM, they get "retargeted" at shadow boundaries
1795 | * - By the time a window-level listener receives the event, ev.target points to the shadow host
1796 | * - composedPath() exposes the original event path before retargeting
1797 | * - This allows us to select elements inside shadow DOM (e.g., <td-header> internals)
1798 | *
1799 | * IMPORTANT: This function should only be called AFTER verifying the event is not from
1800 | * overlay UI (panel buttons, etc). Otherwise it will filter out overlay elements and break
1801 | * panel interactions.
1802 | *
1803 | * @param {Event} ev - The pointer event (mousemove, click, etc.)
1804 | * @returns {Element|null} The innermost non-overlay page element, or null if none found
1805 | */
1806 | function getDeepPageTarget(ev) {
1807 | if (!ev) return null;
1808 |
1809 | // Try to walk the composed path to find the innermost non-overlay element
1810 | try {
1811 | const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;
1812 | if (Array.isArray(path) && path.length > 0) {
1813 | // Walk from innermost to outermost, find the first real page element
1814 | for (const node of path) {
1815 | if (node instanceof Element && !isOverlayElement(node)) {
1816 | return node;
1817 | }
1818 | }
1819 | }
1820 | } catch (error) {
1821 | // composedPath() may throw in some edge cases (e.g., detached nodes)
1822 | // Fall through to use ev.target
1823 | }
1824 |
1825 | // Fallback: use ev.target if composedPath is unavailable or all nodes were filtered
1826 | const fallback = ev.target instanceof Element ? ev.target : null;
1827 | // If fallback is overlay, return null (caller should handle this case)
1828 | if (fallback && !isOverlayElement(fallback)) {
1829 | return fallback;
1830 | }
1831 | return null;
1832 | }
1833 |
1834 | // Store pending hover event for rAF processing
1835 | let pendingHoverEvent = null;
1836 |
1837 | /**
1838 | * Process mouse move event - the actual hover update logic
1839 | * Separated from onMouseMove for rAF throttling
1840 | */
1841 | function processMouseMove(ev) {
1842 | if (!STATE.active) return;
1843 |
1844 | const rawTarget = ev?.target;
1845 | if (!(rawTarget instanceof Element)) {
1846 | STATE.hoverEl = null;
1847 | STATE.lastHoverTarget = null;
1848 | clearHighlighter();
1849 | return;
1850 | }
1851 |
1852 | const host = PanelHost.getHost();
1853 | if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {
1854 | STATE.hoverEl = null;
1855 | STATE.lastHoverTarget = null;
1856 | clearHighlighter();
1857 | return;
1858 | }
1859 |
1860 | const target = getDeepPageTarget(ev) || rawTarget;
1861 | STATE.hoverEl = target;
1862 |
1863 | // Get current listMode
1864 | let listMode = false;
1865 | try {
1866 | listMode = !!StateStore.get('listMode');
1867 | } catch {}
1868 |
1869 | // Skip update if target and mode haven't changed
1870 | const last = STATE.lastHoverTarget;
1871 | if (last && last.element === target && last.listMode === listMode) {
1872 | return;
1873 | }
1874 | STATE.lastHoverTarget = { element: target, listMode };
1875 |
1876 | if (!IS_MAIN) {
1877 | try {
1878 | const list = listMode ? computeElementList(target) || [target] : [target];
1879 | const rects = list.map((el) => {
1880 | const r = el.getBoundingClientRect();
1881 | return { x: r.left, y: r.top, width: r.width, height: r.height };
1882 | });
1883 |
1884 | // Performance: Don't generate selector on hover (defer to click)
1885 | window.top.postMessage({ type: 'em_hover', rects }, '*');
1886 | } catch {}
1887 | return;
1888 | }
1889 |
1890 | if (listMode) {
1891 | STATE.hoveredList = computeElementList(target) || [target];
1892 | drawRects(STATE.hoveredList);
1893 | } else {
1894 | moveHighlighterTo(target);
1895 | }
1896 | }
1897 |
1898 | /**
1899 | * Mouse move handler with rAF throttling
1900 | * Ensures hover updates are batched to animation frame rate
1901 | */
1902 | function onMouseMove(ev) {
1903 | if (!STATE.active) return;
1904 |
1905 | // Store the latest event
1906 | pendingHoverEvent = ev;
1907 |
1908 | // Skip if already scheduled
1909 | if (STATE.hoverRafId != null) return;
1910 |
1911 | // Schedule processing on next animation frame
1912 | STATE.hoverRafId = requestAnimationFrame(() => {
1913 | STATE.hoverRafId = null;
1914 | const latest = pendingHoverEvent;
1915 | pendingHoverEvent = null;
1916 | if (!latest) return;
1917 | processMouseMove(latest);
1918 | });
1919 | }
1920 |
1921 | // ============================================================================
1922 | // Event Listeners Management
1923 | // ============================================================================
1924 |
1925 | function attachPointerListeners() {
1926 | if (STATE.listenersAttached) return;
1927 | window.addEventListener('mousemove', onMouseMove, true);
1928 | window.addEventListener('click', onClick, true);
1929 | STATE.listenersAttached = true;
1930 | }
1931 |
1932 | function detachPointerListeners() {
1933 | if (!STATE.listenersAttached) return;
1934 | window.removeEventListener('mousemove', onMouseMove, true);
1935 | window.removeEventListener('click', onClick, true);
1936 | STATE.listenersAttached = false;
1937 | }
1938 |
1939 | function attachKeyboardListener() {
1940 | window.addEventListener('keydown', onKeyDown, true);
1941 | }
1942 |
1943 | function detachKeyboardListener() {
1944 | window.removeEventListener('keydown', onKeyDown, true);
1945 | }
1946 |
1947 | function syncInteractionMode() {
1948 | if (!STATE.active) return;
1949 | const activeTab = StateStore.get('activeTab');
1950 | if (activeTab === 'execute') {
1951 | // In execute mode, detach pointer listeners to allow real interactions
1952 | // but keep keyboard listener for Esc key
1953 | detachPointerListeners();
1954 | // Only clear the hover highlighter, not the verification rects
1955 | if (STATE.highlighter) STATE.highlighter.style.display = 'none';
1956 | } else {
1957 | // In attributes mode, attach all listeners for element selection
1958 | attachPointerListeners();
1959 | }
1960 | }
1961 |
1962 | // ============================================================================
1963 | // Event Handlers
1964 | // ============================================================================
1965 |
1966 | function onClick(ev) {
1967 | if (!STATE.active) return;
1968 |
1969 | // First, use the raw ev.target to check for overlay UI
1970 | // This ensures panel buttons and other UI elements remain interactive
1971 | const rawTarget = ev.target;
1972 | const host = PanelHost.getHost();
1973 |
1974 | // Check if raw target is the panel host itself or inside the shadow DOM
1975 | // IMPORTANT: Return early WITHOUT preventDefault to allow overlay button clicks
1976 | if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {
1977 | return;
1978 | }
1979 |
1980 | // Now we know it's a page element, prevent default and get deep target
1981 | ev.preventDefault();
1982 | ev.stopPropagation();
1983 |
1984 | if (!(rawTarget instanceof Element)) return;
1985 |
1986 | // Get the deep target (considering shadow DOM) after confirming it's not overlay
1987 | const target = getDeepPageTarget(ev) || rawTarget;
1988 |
1989 | if (!IS_MAIN) {
1990 | try {
1991 | const selectorType = StateStore.get('selectorType');
1992 | const listMode = StateStore.get('listMode');
1993 |
1994 | const sel =
1995 | selectorType === 'xpath'
1996 | ? generateXPath(target)
1997 | : listMode
1998 | ? generateListSelector(target)
1999 | : generateSelector(target);
2000 |
2001 | window.top.postMessage({ type: 'em_click', innerSel: sel }, '*');
2002 | } catch {}
2003 | return;
2004 | }
2005 |
2006 | setSelection(target);
2007 | }
2008 |
2009 | function onKeyDown(e) {
2010 | if (!STATE.active) return;
2011 |
2012 | // Check if the focused element is inside the panel - if so, don't handle selection keys
2013 | if (isInsidePanel(e.target)) {
2014 | // Key event is from panel, don't interfere
2015 | if (e.key !== 'Escape') return; // Still allow Escape to close
2016 | }
2017 |
2018 | // In execute mode, only handle Escape to close - don't intercept other keys
2019 | // This allows real page interactions (typing, scrolling, etc.)
2020 | const activeTab = StateStore.get('activeTab');
2021 | if (activeTab === 'execute') {
2022 | if (e.key === 'Escape') {
2023 | e.preventDefault();
2024 | stop();
2025 | }
2026 | return; // Don't intercept Space/Arrow keys in execute mode
2027 | }
2028 |
2029 | if (e.key === 'Escape') {
2030 | e.preventDefault();
2031 | stop();
2032 | } else if (e.key === ' ' || e.code === 'Space') {
2033 | e.preventDefault();
2034 | const t = STATE.hoverEl || STATE.selectedEl;
2035 | if (t) setSelection(t);
2036 | } else if (e.key === 'ArrowUp') {
2037 | e.preventDefault();
2038 | const base = STATE.selectedEl || STATE.hoverEl;
2039 | if (base?.parentElement) setSelection(base.parentElement);
2040 | } else if (e.key === 'ArrowDown') {
2041 | e.preventDefault();
2042 | const base = STATE.selectedEl || STATE.hoverEl;
2043 | if (base?.firstElementChild) setSelection(base.firstElementChild);
2044 | }
2045 | }
2046 |
2047 | function setSelection(el) {
2048 | if (!(el instanceof Element)) return;
2049 |
2050 | STATE.selectedEl = el;
2051 |
2052 | const selectorType = StateStore.get('selectorType');
2053 | const listMode = StateStore.get('listMode');
2054 |
2055 | const sel =
2056 | selectorType === 'xpath'
2057 | ? generateXPath(el)
2058 | : listMode
2059 | ? generateListSelector(el)
2060 | : generateSelector(el);
2061 |
2062 | const name = getAccessibleName(el) || el.tagName.toLowerCase();
2063 |
2064 | const selectorText = STATE.box?.querySelector('#__em_selector');
2065 | const inputName = STATE.box?.querySelector('#__em_name');
2066 | const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');
2067 |
2068 | if (selectorText) selectorText.textContent = sel;
2069 | if (selectorDisplay) selectorDisplay.textContent = sel;
2070 | if (inputName && !inputName.value) inputName.value = name;
2071 |
2072 | moveHighlighterTo(el);
2073 | }
2074 |
2075 | // ============================================================================
2076 | // Validation Logic
2077 | // ============================================================================
2078 |
2079 | /**
2080 | * Verify selector by highlighting only (non-destructive)
2081 | */
2082 | async function verifyHighlightOnly() {
2083 | try {
2084 | const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
2085 | if (!selector) return;
2086 |
2087 | StateStore.set({
2088 | validation: { status: 'running', message: 'Verifying selector...' },
2089 | });
2090 |
2091 | const selectorType = StateStore.get('selectorType');
2092 | const listMode = StateStore.get('listMode');
2093 | const effectiveType = listMode ? 'css' : selectorType;
2094 |
2095 | // Query for matches
2096 | const matches =
2097 | effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);
2098 |
2099 | // Additional defense: filter out any overlay elements that might have slipped through
2100 | const filteredMatches = filterOverlayElements(matches);
2101 |
2102 | if (!filteredMatches || filteredMatches.length === 0) {
2103 | StateStore.set({
2104 | validation: { status: 'failure', message: 'No elements found' },
2105 | });
2106 | return;
2107 | }
2108 |
2109 | // Scroll first match into view
2110 | const primaryMatch = filteredMatches[0];
2111 | if (primaryMatch) {
2112 | primaryMatch.scrollIntoView({
2113 | block: 'center',
2114 | inline: 'center',
2115 | behavior: 'smooth',
2116 | });
2117 | }
2118 |
2119 | await sleep(200);
2120 |
2121 | // Highlight matches with isVerify=true to prevent clearing on hover
2122 | drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false, true);
2123 |
2124 | StateStore.set({
2125 | validation: {
2126 | status: 'success',
2127 | message: `Found ${filteredMatches.length} element${filteredMatches.length > 1 ? 's' : ''}`,
2128 | },
2129 | });
2130 |
2131 | // Auto-clear highlight after 2 seconds
2132 | setTimeout(() => {
2133 | clearRects();
2134 | StateStore.set({
2135 | validation: { status: 'idle', message: '' },
2136 | });
2137 | }, 2000);
2138 | } catch (error) {
2139 | console.error('[verifyHighlightOnly] error:', error);
2140 | StateStore.set({
2141 | validation: { status: 'failure', message: error.message || 'Verification failed' },
2142 | });
2143 | }
2144 | }
2145 |
2146 | /**
2147 | * Execute action on selector (destructive)
2148 | */
2149 | async function verifySelectorNow() {
2150 | try {
2151 | const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
2152 | if (!selector) return;
2153 |
2154 | StateStore.set({
2155 | validation: { status: 'running', message: 'Executing action...' },
2156 | });
2157 |
2158 | const selectorType = StateStore.get('selectorType');
2159 | const listMode = StateStore.get('listMode');
2160 |
2161 | const effectiveType = listMode ? 'css' : selectorType;
2162 |
2163 | const matches =
2164 | effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);
2165 |
2166 | // Additional defense: filter out any overlay elements that might have slipped through
2167 | const filteredMatches = filterOverlayElements(matches);
2168 |
2169 | if (!filteredMatches || filteredMatches.length === 0) {
2170 | StateStore.set({
2171 | validation: { status: 'failure', message: 'No elements found' },
2172 | });
2173 | return;
2174 | }
2175 |
2176 | drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);
2177 |
2178 | const action = STATE.box?.querySelector('#__em_action')?.value || 'hover';
2179 |
2180 | const payload = {
2181 | type: 'element_marker_validate',
2182 | selector,
2183 | selectorType: effectiveType,
2184 | action,
2185 | listMode,
2186 | };
2187 |
2188 | // Action-specific parameters with validation
2189 | if (action === 'type_text') {
2190 | const actionText = String(
2191 | STATE.box?.querySelector('#__em_action_text')?.value || '',
2192 | ).trim();
2193 | if (!actionText) {
2194 | StateStore.set({
2195 | validation: { status: 'failure', message: 'Text is required for type_text' },
2196 | });
2197 | return;
2198 | }
2199 | payload.text = actionText;
2200 | }
2201 |
2202 | if (action === 'press_keys') {
2203 | const actionKeys = String(
2204 | STATE.box?.querySelector('#__em_action_keys')?.value || '',
2205 | ).trim();
2206 | if (!actionKeys) {
2207 | StateStore.set({
2208 | validation: { status: 'failure', message: 'Keys are required for press_keys' },
2209 | });
2210 | return;
2211 | }
2212 | payload.keys = actionKeys;
2213 | }
2214 |
2215 | if (action === 'scroll') {
2216 | const direction = STATE.box?.querySelector('#__em_scroll_direction')?.value || 'down';
2217 | const rawAmount = Number(STATE.box?.querySelector('#__em_scroll_distance')?.value);
2218 | // Clamp to 1-10 range (backend expects ticks, not pixels)
2219 | const amount = Math.max(
2220 | 1,
2221 | Math.min(Math.round(Number.isFinite(rawAmount) ? rawAmount : 3), 10),
2222 | );
2223 | payload.scrollDirection = direction;
2224 | payload.scrollAmount = amount;
2225 | }
2226 |
2227 | if (['left_click', 'double_click', 'right_click'].includes(action)) {
2228 | payload.modifiers = {
2229 | altKey: !!STATE.box?.querySelector('#__em_mod_alt')?.checked,
2230 | ctrlKey: !!STATE.box?.querySelector('#__em_mod_ctrl')?.checked,
2231 | metaKey: !!STATE.box?.querySelector('#__em_mod_meta')?.checked,
2232 | shiftKey: !!STATE.box?.querySelector('#__em_mod_shift')?.checked,
2233 | };
2234 | payload.button = STATE.box?.querySelector('#__em_btn')?.value || 'left';
2235 | payload.waitForNavigation = !!STATE.box?.querySelector('#__em_wait_nav')?.checked;
2236 | payload.timeoutMs = Number(STATE.box?.querySelector('#__em_nav_timeout')?.value) || 3000;
2237 | }
2238 |
2239 | const res = await chrome.runtime.sendMessage(payload);
2240 |
2241 | const success = !!res?.tool?.ok;
2242 | const newEntry = {
2243 | action,
2244 | success,
2245 | timestamp: Date.now(),
2246 | matchCount: filteredMatches.length,
2247 | };
2248 | const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);
2249 |
2250 | if (res?.tool?.ok) {
2251 | StateStore.set({
2252 | validation: {
2253 | status: 'success',
2254 | message: `✓ 验证成功 (匹配 ${filteredMatches.length} 个元素)`,
2255 | },
2256 | validationHistory: history,
2257 | });
2258 | } else {
2259 | StateStore.set({
2260 | validation: {
2261 | status: 'failure',
2262 | message: res?.tool?.error || '验证失败',
2263 | },
2264 | validationHistory: history,
2265 | });
2266 | }
2267 | } catch (err) {
2268 | const newEntry = {
2269 | action: STATE.box?.querySelector('#__em_action')?.value || 'hover',
2270 | success: false,
2271 | timestamp: Date.now(),
2272 | matchCount: 0,
2273 | };
2274 | const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);
2275 |
2276 | StateStore.set({
2277 | validation: {
2278 | status: 'failure',
2279 | message: `错误: ${err.message}`,
2280 | },
2281 | validationHistory: history,
2282 | });
2283 | }
2284 | }
2285 |
2286 | /**
2287 | * Highlight selector from external request (popup/background)
2288 | * Supports composite iframe selectors: "frameSelector |> innerSelector"
2289 | */
2290 | async function highlightSelectorExternal({ selector, selectorType = 'css', listMode = false }) {
2291 | const normalized = String(selector || '').trim();
2292 | if (!normalized) {
2293 | return { success: false, error: 'selector is required' };
2294 | }
2295 |
2296 | try {
2297 | // Handle composite iframe selector
2298 | if (normalized.includes('|>')) {
2299 | const parts = normalized
2300 | .split('|>')
2301 | .map((s) => s.trim())
2302 | .filter(Boolean);
2303 |
2304 | if (parts.length >= 2) {
2305 | const frameSel = parts[0];
2306 | const innerSel = parts.slice(1).join(' |> ');
2307 |
2308 | // Find frame element
2309 | let frameEl = null;
2310 | try {
2311 | frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel);
2312 | } catch {}
2313 |
2314 | if (
2315 | !frameEl ||
2316 | !(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement)
2317 | ) {
2318 | return { success: false, error: `Frame element not found: ${frameSel}` };
2319 | }
2320 |
2321 | const cw = frameEl.contentWindow;
2322 | if (!cw) {
2323 | return { success: false, error: 'Unable to access frame contentWindow' };
2324 | }
2325 |
2326 | // Forward highlight request to iframe
2327 | return new Promise((resolve) => {
2328 | const reqId = `em_highlight_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
2329 | const listener = (ev) => {
2330 | try {
2331 | const data = ev?.data;
2332 | if (!data || data.type !== 'em-highlight-result' || data.reqId !== reqId) return;
2333 | window.removeEventListener('message', listener, true);
2334 | resolve(data.result);
2335 | } catch {}
2336 | };
2337 |
2338 | window.addEventListener('message', listener, true);
2339 | setTimeout(() => {
2340 | window.removeEventListener('message', listener, true);
2341 | resolve({ success: false, error: 'Frame highlight timeout' });
2342 | }, 3000);
2343 |
2344 | cw.postMessage(
2345 | {
2346 | type: 'em-highlight-request',
2347 | reqId,
2348 | selector: innerSel,
2349 | selectorType,
2350 | listMode,
2351 | },
2352 | '*',
2353 | );
2354 | });
2355 | }
2356 | }
2357 |
2358 | // Handle normal selector (non-iframe)
2359 | const effectiveType = listMode ? 'css' : selectorType;
2360 | const matches =
2361 | effectiveType === 'xpath' ? evaluateXPathAll(normalized) : queryAllDeep(normalized);
2362 |
2363 | // Additional defense: filter out any overlay elements that might have slipped through
2364 | const filteredMatches = filterOverlayElements(matches);
2365 |
2366 | if (!filteredMatches || filteredMatches.length === 0) {
2367 | return { success: false, error: 'No elements found for selector' };
2368 | }
2369 |
2370 | // Scroll first match into view
2371 | const primaryMatch = filteredMatches[0];
2372 | if (primaryMatch) {
2373 | primaryMatch.scrollIntoView({
2374 | block: 'center',
2375 | inline: 'center',
2376 | behavior: 'smooth',
2377 | });
2378 | }
2379 |
2380 | await sleep(150);
2381 |
2382 | // Draw highlight rectangles
2383 | drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);
2384 |
2385 | // Auto-clear after 2 seconds
2386 | setTimeout(() => {
2387 | clearRects();
2388 | }, 2000);
2389 |
2390 | return { success: true, count: filteredMatches.length };
2391 | } catch (error) {
2392 | return { success: false, error: error.message || String(error) };
2393 | }
2394 | }
2395 |
2396 | function copySelectorNow() {
2397 | try {
2398 | const sel = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
2399 | if (!sel) return;
2400 | navigator.clipboard?.writeText(sel).catch(() => {});
2401 |
2402 | StateStore.set({
2403 | validation: { status: 'success', message: '✓ 已复制到剪贴板' },
2404 | });
2405 |
2406 | setTimeout(() => {
2407 | StateStore.set({ validation: { status: 'idle', message: '' } });
2408 | }, 2000);
2409 | } catch {}
2410 | }
2411 |
2412 | async function save() {
2413 | try {
2414 | const name = STATE.box?.querySelector('#__em_name')?.value?.trim();
2415 | const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
2416 |
2417 | if (!selector) return;
2418 |
2419 | const url = location.href;
2420 | let selectorType = StateStore.get('selectorType');
2421 | const listMode = StateStore.get('listMode');
2422 |
2423 | if (listMode && selectorType === 'xpath') {
2424 | selectorType = 'css';
2425 | }
2426 |
2427 | await chrome.runtime.sendMessage({
2428 | type: 'element_marker_save',
2429 | marker: {
2430 | url,
2431 | name: name || selector,
2432 | selector,
2433 | selectorType,
2434 | listMode,
2435 | },
2436 | });
2437 | } catch {}
2438 |
2439 | stop();
2440 | }
2441 |
2442 | // ============================================================================
2443 | // Lifecycle Management
2444 | // ============================================================================
2445 |
2446 | function start() {
2447 | if (STATE.active) return;
2448 | STATE.active = true;
2449 |
2450 | if (IS_MAIN) {
2451 | const { host } = PanelHost.mount();
2452 | STATE.box = host;
2453 | StateStore.init();
2454 | bindControls();
2455 | }
2456 |
2457 | ensureHighlighter();
2458 | ensureRectsHost();
2459 |
2460 | attachPointerListeners();
2461 | attachKeyboardListener();
2462 | syncInteractionMode();
2463 | }
2464 |
2465 | function stop() {
2466 | STATE.active = false;
2467 |
2468 | detachPointerListeners();
2469 | detachKeyboardListener();
2470 |
2471 | // Cancel pending rAF
2472 | if (STATE.hoverRafId != null) {
2473 | cancelAnimationFrame(STATE.hoverRafId);
2474 | STATE.hoverRafId = null;
2475 | }
2476 | pendingHoverEvent = null;
2477 |
2478 | try {
2479 | STATE.highlighter?.remove();
2480 | STATE.rectsHost?.remove();
2481 | PanelHost.unmount();
2482 | DragController.destroy();
2483 | } catch {}
2484 |
2485 | STATE.highlighter = null;
2486 | STATE.rectsHost = null;
2487 | STATE.box = null;
2488 | STATE.hoveredList = [];
2489 | STATE.hoverEl = null;
2490 | STATE.selectedEl = null;
2491 | STATE.lastHoverTarget = null;
2492 | STATE.verifyRectsActive = false;
2493 |
2494 | // Clear rect pool to release DOM references
2495 | STATE.rectPool.length = 0;
2496 | STATE.rectPoolUsed = 0;
2497 | }
2498 |
2499 | // ============================================================================
2500 | // Controls Binding
2501 | // ============================================================================
2502 |
2503 | function bindControls() {
2504 | const host = STATE.box;
2505 | if (!host) return;
2506 |
2507 | // Close/Cancel
2508 | host.querySelector('#__em_close')?.addEventListener('click', stop);
2509 | host.querySelector('#__em_cancel')?.addEventListener('click', stop);
2510 |
2511 | // Save
2512 | host.querySelector('#__em_save')?.addEventListener('click', save);
2513 |
2514 | // Verify (highlight only) & Execute (real action)
2515 | host.querySelector('#__em_verify')?.addEventListener('click', verifyHighlightOnly);
2516 | host.querySelector('#__em_execute')?.addEventListener('click', verifySelectorNow);
2517 |
2518 | // Copy
2519 | host.querySelector('#__em_copy')?.addEventListener('click', copySelectorNow);
2520 | host.querySelector('#__em_copy_selector')?.addEventListener('click', copySelectorNow);
2521 |
2522 | // Action change handler - show/hide action-specific options
2523 | host.querySelector('#__em_action')?.addEventListener('change', (e) => {
2524 | updateActionSpecificUI(e.target.value);
2525 | });
2526 |
2527 | // Selector type
2528 | host.querySelector('#__em_selector_type')?.addEventListener('change', (e) => {
2529 | const newType = e.target.value;
2530 | const listMode = StateStore.get('listMode');
2531 |
2532 | // If switching to XPath while in list mode, disable list mode
2533 | if (newType === 'xpath' && listMode) {
2534 | StateStore.set({ selectorType: newType, listMode: false });
2535 | } else {
2536 | StateStore.set({ selectorType: newType });
2537 | }
2538 |
2539 | // Regenerate selector for the currently selected element
2540 | if (STATE.selectedEl) {
2541 | setSelection(STATE.selectedEl);
2542 | }
2543 | // Note: If no selectedEl (e.g., iframe selections or manual input),
2544 | // preserve existing selector text instead of clearing it
2545 | });
2546 |
2547 | // List mode toggle
2548 | host.querySelector('#__em_toggle_list')?.addEventListener('click', (e) => {
2549 | const listMode = StateStore.get('listMode');
2550 | const newListMode = !listMode;
2551 |
2552 | // If enabling list mode, force CSS selector type
2553 | if (newListMode) {
2554 | StateStore.set({ listMode: true, selectorType: 'css' });
2555 | const selectorTypeSelect = host.querySelector('#__em_selector_type');
2556 | if (selectorTypeSelect) selectorTypeSelect.value = 'css';
2557 | } else {
2558 | StateStore.set({ listMode: false });
2559 | }
2560 |
2561 | // Update button active state
2562 | const btn = e.currentTarget;
2563 | if (btn) {
2564 | if (newListMode) {
2565 | btn.classList.add('active');
2566 | } else {
2567 | btn.classList.remove('active');
2568 | }
2569 | }
2570 |
2571 | // Regenerate selector for the currently selected element
2572 | if (STATE.selectedEl) {
2573 | setSelection(STATE.selectedEl);
2574 | }
2575 |
2576 | clearHighlighter();
2577 | });
2578 |
2579 | // Tab toggle (switch between Attributes and Execute)
2580 | host.querySelector('#__em_toggle_tab')?.addEventListener('click', () => {
2581 | const currentTab = StateStore.get('activeTab');
2582 | StateStore.set({ activeTab: currentTab === 'attributes' ? 'execute' : 'attributes' });
2583 | });
2584 |
2585 | // Tab switching
2586 | const tabs = host.querySelectorAll('.em-tab');
2587 | tabs.forEach((tab) => {
2588 | tab.addEventListener('click', () => {
2589 | StateStore.set({ activeTab: tab.dataset.tab });
2590 | });
2591 | });
2592 |
2593 | // Navigation buttons
2594 | host.querySelector('#__em_nav_up')?.addEventListener('click', () => {
2595 | const base = STATE.selectedEl || STATE.hoverEl;
2596 | if (base?.parentElement) setSelection(base.parentElement);
2597 | });
2598 |
2599 | host.querySelector('#__em_nav_down')?.addEventListener('click', () => {
2600 | const base = STATE.selectedEl || STATE.hoverEl;
2601 | if (base?.firstElementChild) setSelection(base.firstElementChild);
2602 | });
2603 |
2604 | // Preferences
2605 | host.querySelector('#__em_pref_id')?.addEventListener('change', (e) => {
2606 | const prefs = { ...StateStore.get('prefs'), preferId: !!e.target.checked };
2607 | StateStore.set({ prefs });
2608 | });
2609 | host.querySelector('#__em_pref_attr')?.addEventListener('change', (e) => {
2610 | const prefs = { ...StateStore.get('prefs'), preferStableAttr: !!e.target.checked };
2611 | StateStore.set({ prefs });
2612 | });
2613 | host.querySelector('#__em_pref_class')?.addEventListener('change', (e) => {
2614 | const prefs = { ...StateStore.get('prefs'), preferClass: !!e.target.checked };
2615 | StateStore.set({ prefs });
2616 | });
2617 |
2618 | // Drag - use entire header as drag handle
2619 | const dragHandle = host.querySelector('#__em_drag_handle');
2620 | if (dragHandle) {
2621 | DragController.init(dragHandle);
2622 | }
2623 |
2624 | syncUIWithState();
2625 | }
2626 |
2627 | function updateActionSpecificUI(action) {
2628 | const host = STATE.box;
2629 | if (!host) return;
2630 |
2631 | // Hide all action-specific groups
2632 | const textGroup = host.querySelector('#__em_action_text_group');
2633 | const keysGroup = host.querySelector('#__em_action_keys_group');
2634 | const scrollOptions = host.querySelector('#__em_scroll_options');
2635 | const clickOptions = host.querySelector('#__em_click_options');
2636 |
2637 | if (textGroup) textGroup.style.display = 'none';
2638 | if (keysGroup) keysGroup.style.display = 'none';
2639 | if (scrollOptions) scrollOptions.style.display = 'none';
2640 | if (clickOptions) clickOptions.style.display = 'none';
2641 |
2642 | // Show relevant options based on action
2643 | if (action === 'type_text') {
2644 | if (textGroup) textGroup.style.display = 'block';
2645 | } else if (action === 'press_keys') {
2646 | if (keysGroup) keysGroup.style.display = 'block';
2647 | } else if (action === 'scroll') {
2648 | if (scrollOptions) scrollOptions.style.display = 'block';
2649 | } else if (['left_click', 'double_click', 'right_click'].includes(action)) {
2650 | if (clickOptions) clickOptions.style.display = 'block';
2651 |
2652 | // For right_click, button selector is not relevant (always 'right')
2653 | // Hide the button field for right_click
2654 | const buttonField = host.querySelector('#__em_btn')?.closest('.em-field');
2655 | if (buttonField) {
2656 | buttonField.style.display = action === 'right_click' ? 'none' : 'block';
2657 | }
2658 | }
2659 | // hover: no extra options needed
2660 | }
2661 |
2662 | function syncUIWithState() {
2663 | const host = STATE.box;
2664 | if (!host) return;
2665 |
2666 | const state = StateStore.get();
2667 |
2668 | const typeSelect = host.querySelector('#__em_selector_type');
2669 | if (typeSelect) typeSelect.value = state.selectorType;
2670 |
2671 | // Initialize list mode button state
2672 | const listModeBtn = host.querySelector('#__em_toggle_list');
2673 | if (listModeBtn) {
2674 | if (state.listMode) {
2675 | listModeBtn.classList.add('active');
2676 | } else {
2677 | listModeBtn.classList.remove('active');
2678 | }
2679 | }
2680 |
2681 | const prefId = host.querySelector('#__em_pref_id');
2682 | const prefAttr = host.querySelector('#__em_pref_attr');
2683 | const prefClass = host.querySelector('#__em_pref_class');
2684 | if (prefId) prefId.checked = state.prefs.preferId;
2685 | if (prefAttr) prefAttr.checked = state.prefs.preferStableAttr;
2686 | if (prefClass) prefClass.checked = state.prefs.preferClass;
2687 |
2688 | // Initialize action-specific UI
2689 | const actionSelect = host.querySelector('#__em_action');
2690 | if (actionSelect) {
2691 | updateActionSpecificUI(actionSelect.value);
2692 | }
2693 | }
2694 |
2695 | // ============================================================================
2696 | // Cross-Frame Bridge
2697 | // ============================================================================
2698 |
2699 | // Register window message listener in all frames (not just main)
2700 | // to support cross-frame highlighting from popup validation
2701 | window.addEventListener(
2702 | 'message',
2703 | (ev) => {
2704 | try {
2705 | const data = ev?.data;
2706 | if (!data) return;
2707 |
2708 | // Handle iframe highlight request (works even when overlay is inactive)
2709 | if (data.type === 'em-highlight-request') {
2710 | highlightSelectorExternal({
2711 | selector: data.selector,
2712 | selectorType: data.selectorType || 'css',
2713 | listMode: !!data.listMode,
2714 | })
2715 | .then((result) => {
2716 | window.parent.postMessage(
2717 | {
2718 | type: 'em-highlight-result',
2719 | reqId: data.reqId,
2720 | result,
2721 | },
2722 | '*',
2723 | );
2724 | })
2725 | .catch((error) => {
2726 | window.parent.postMessage(
2727 | {
2728 | type: 'em-highlight-result',
2729 | reqId: data.reqId,
2730 | result: { success: false, error: error?.message || String(error) },
2731 | },
2732 | '*',
2733 | );
2734 | });
2735 | return;
2736 | }
2737 |
2738 | // Following messages only relevant when overlay is active
2739 | if (!STATE.active) return;
2740 |
2741 | // Only main frame handles these overlay-related messages
2742 | if (!IS_MAIN) return;
2743 |
2744 | const iframes = Array.from(document.querySelectorAll('iframe'));
2745 | const host = iframes.find((f) => {
2746 | try {
2747 | return f.contentWindow === ev.source;
2748 | } catch {
2749 | return false;
2750 | }
2751 | });
2752 |
2753 | if (!host) return;
2754 |
2755 | const base = host.getBoundingClientRect();
2756 |
2757 | if (data.type === 'em_hover' && Array.isArray(data.rects)) {
2758 | // Use pooled rect boxes for better performance
2759 | drawRectBoxes(data.rects, {
2760 | offsetX: base.left,
2761 | offsetY: base.top,
2762 | color: CONFIG.COLORS.HOVER,
2763 | dashed: true,
2764 | });
2765 | } else if (data.type === 'em_click' && data.innerSel) {
2766 | const frameSel = generateSelector(host);
2767 | const composite = frameSel ? `${frameSel} |> ${data.innerSel}` : data.innerSel;
2768 | const selectorText = STATE.box?.querySelector('#__em_selector');
2769 | const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');
2770 | if (selectorText) selectorText.textContent = composite;
2771 | if (selectorDisplay) selectorDisplay.textContent = composite;
2772 | }
2773 | } catch {}
2774 | },
2775 | true,
2776 | );
2777 |
2778 | // ============================================================================
2779 | // Message Handlers
2780 | // ============================================================================
2781 |
2782 | chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
2783 | if (request?.action === 'element_marker_start') {
2784 | start();
2785 | sendResponse({ ok: true });
2786 | return true;
2787 | } else if (request?.action === 'element_marker_ping') {
2788 | sendResponse({ status: 'pong' });
2789 | return false;
2790 | } else if (request?.action === 'element_marker_highlight') {
2791 | highlightSelectorExternal({
2792 | selector: request.selector,
2793 | selectorType: request.selectorType,
2794 | listMode: !!request.listMode,
2795 | })
2796 | .then((result) => sendResponse(result))
2797 | .catch((error) => sendResponse({ success: false, error: error?.message || String(error) }));
2798 | return true;
2799 | }
2800 | return false;
2801 | });
2802 | })();
2803 |
```