This is page 39 of 43. Use http://codebase.md/hangwin/mcp-chrome?lines=false&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
/* eslint-disable */
(function () {
if (window.__ELEMENT_MARKER_INSTALLED__) return;
window.__ELEMENT_MARKER_INSTALLED__ = true;
const IS_MAIN = window === window.top;
// ============================================================================
// Utility Functions
// ============================================================================
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// ============================================================================
// Constants & Configuration
// ============================================================================
const CONFIG = {
DEFAULTS: {
PREFS: {
preferId: true,
preferStableAttr: true,
preferClass: true,
},
SELECTOR_TYPE: 'css',
LIST_MODE: false,
},
Z_INDEX: {
OVERLAY: 2147483646,
HIGHLIGHTER: 2147483645,
RECTS: 2147483644,
},
COLORS: {
PRIMARY: '#2563eb',
SUCCESS: '#10b981',
WARNING: '#f59e0b',
DANGER: '#ef4444',
HOVER: '#10b981',
VERIFY: '#3b82f6',
},
};
// ============================================================================
// Panel Host Module - Shadow DOM Management
// ============================================================================
const PanelHost = (() => {
let hostElement = null;
let shadowRoot = null;
const PANEL_STYLES = `
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
.em-panel {
width: 400px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
transition: opacity 150ms ease;
}
/* Header */
.em-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
user-select: none;
}
.em-title {
font-size: 20px;
font-weight: 500;
color: #262626;
}
.em-header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.em-icon-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: #a3a3a3;
cursor: pointer;
transition: color 150ms ease;
padding: 0;
}
.em-icon-btn:hover {
color: #525252;
}
.em-icon-btn svg {
width: 20px;
height: 20px;
stroke-width: 2;
}
/* Controls Row */
.em-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.em-select-wrapper {
flex: 1;
position: relative;
}
.em-select {
width: 100%;
height: 44px;
padding: 0 40px 0 16px;
background: #f5f5f5;
color: #262626;
font-size: 15px;
border: none;
border-radius: 10px;
appearance: none;
cursor: pointer;
outline: none;
font-family: inherit;
font-weight: 400;
}
.em-select-wrapper::after {
content: '';
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 0;
height: 0;
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 6px solid #737373;
pointer-events: none;
}
.em-square-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border: none;
border-radius: 10px;
cursor: pointer;
transition: background 150ms ease;
padding: 0;
}
.em-square-btn:hover {
background: #e5e5e5;
}
.em-square-btn.active {
background: #2563eb;
}
.em-square-btn.active svg {
color: #ffffff;
}
.em-square-btn svg {
width: 18px;
height: 18px;
color: #525252;
stroke-width: 2;
}
/* Selector Display */
.em-selector-display {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
padding: 0 12px 0 16px;
background: #f5f5f5;
border-radius: 10px;
margin-bottom: 16px;
}
.em-selector-display svg {
width: 18px;
height: 18px;
color: #a3a3a3;
flex-shrink: 0;
stroke-width: 2;
}
.em-selector-text {
flex: 1;
font-size: 14px;
color: #525252;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
user-select: text;
}
.em-selector-nav {
display: flex;
gap: 2px;
}
.em-nav-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
cursor: pointer;
transition: background 150ms ease;
border-radius: 6px;
padding: 0;
}
.em-nav-btn:hover {
background: #e5e5e5;
}
.em-nav-btn svg {
width: 16px;
height: 16px;
color: #525252;
stroke-width: 2;
}
/* Tabs */
.em-tabs {
display: inline-flex;
gap: 2px;
padding: 2px;
background: #f5f5f5;
border-radius: 8px;
margin-bottom: 16px;
}
.em-tab {
padding: 6px 16px;
font-size: 12px;
font-weight: 500;
color: #737373;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 150ms ease;
}
.em-tab:hover {
color: #404040;
}
.em-tab.active {
color: #262626;
background: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
/* Content */
.em-content {
margin-bottom: 0;
}
#__em_tab_settings {
max-height: min(60vh, 480px);
overflow-y: auto;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
#__em_tab_settings::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.em-section-title {
font-size: 13px;
color: #737373;
margin-bottom: 16px;
font-weight: 400;
}
.em-attributes {
display: flex;
flex-direction: column;
gap: 12px;
}
.em-attribute {
display: flex;
flex-direction: column;
gap: 6px;
}
.em-attribute-label {
font-size: 12px;
color: #a3a3a3;
font-weight: 400;
}
.em-attribute-value {
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 12px 0 16px;
background: #f5f5f5;
border-radius: 10px;
}
.em-attribute-value.editable {
padding: 0 16px;
}
.em-attribute-value svg {
width: 18px;
height: 18px;
stroke-width: 2;
cursor: pointer;
transition: color 150ms ease;
flex-shrink: 0;
}
.em-attribute-value svg.copy-icon {
color: #a3a3a3;
}
.em-attribute-value svg.copy-icon:hover {
color: #525252;
}
.em-attribute-value svg.copy-icon.disabled {
color: #d4d4d4;
cursor: default;
}
.em-attribute-text {
flex: 1;
font-size: 14px;
color: #404040;
user-select: text;
}
.em-attribute-text.empty {
color: #a3a3a3;
}
.em-input {
flex: 1;
border: none;
background: transparent;
font-size: 14px;
color: #404040;
font-family: inherit;
outline: none;
padding: 0;
height: 44px;
}
.em-input::placeholder {
color: #a3a3a3;
}
/* Settings Panel */
.em-settings {
display: flex;
flex-direction: column;
gap: 16px;
}
.em-settings-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.em-settings-label {
font-size: 12px;
font-weight: 500;
color: #737373;
}
.em-checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.em-checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #404040;
cursor: pointer;
}
.em-checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
margin: 0;
}
/* Action Buttons */
.em-actions {
display: flex;
gap: 8px;
margin-top: 20px;
}
.em-btn {
flex: 1;
height: 40px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 150ms ease;
}
.em-btn-primary {
background: #2563eb;
color: #ffffff;
}
.em-btn-primary:hover {
background: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.em-btn-success {
background: #10b981;
color: #ffffff;
}
.em-btn-success:hover {
background: #059669;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
}
.em-btn-ghost {
background: #f5f5f5;
color: #404040;
}
.em-btn-ghost:hover {
background: #e5e5e5;
}
/* Footer */
.em-footer {
font-size: 12px;
color: #a3a3a3;
text-align: center;
margin-top: 16px;
}
.em-footer kbd {
display: inline-block;
padding: 2px 6px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 11px;
color: #737373;
}
/* Status */
.em-status {
font-size: 13px;
padding: 10px 12px;
border-radius: 8px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.em-status.idle {
display: none;
}
.em-status.running {
background: rgba(37, 99, 235, 0.1);
color: #2563eb;
}
.em-status.success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.em-status.failure {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
/* Grid Layout */
.em-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.em-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.em-field-label {
font-size: 12px;
color: #a3a3a3;
}
.em-field-input {
height: 40px;
padding: 0 12px;
background: #f5f5f5;
border: none;
border-radius: 8px;
font-size: 14px;
color: #404040;
font-family: inherit;
outline: none;
}
.em-field-input:focus {
background: #e5e5e5;
}
/* Details/Accordion */
.em-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.em-details summary {
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #737373;
padding: 8px 0;
user-select: none;
list-style: none;
}
.em-details summary::-webkit-details-marker {
display: none;
}
.em-details summary:hover {
color: #404040;
}
.em-details[open] summary {
margin-bottom: 12px;
}
/* Dragging state */
body[data-em-dragging] {
user-select: none !important;
cursor: grabbing !important;
}
body[data-em-dragging] * {
cursor: grabbing !important;
}
/* SVG Icons */
svg {
fill: none;
stroke: currentColor;
}
.em-drag-handle {
cursor: grab;
}
.em-drag-handle:active {
cursor: grabbing;
}
`;
const PANEL_TEMPLATE = `
<div class="em-panel" id="em_panel_root">
<!-- Header -->
<div class="em-header em-drag-handle" id="__em_drag_handle" title="Drag to move">
<h2 class="em-title">元素标注</h2>
<div class="em-header-actions">
<button class="em-icon-btn" id="__em_close" title="Close">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<!-- Controls -->
<div class="em-controls">
<div class="em-select-wrapper">
<select class="em-select" id="__em_selector_type">
<option value="css">CSS Selector</option>
<option value="xpath">XPath</option>
</select>
</div>
<button class="em-square-btn" id="__em_toggle_list" title="列表模式 - 批量标注相似元素 (仅支持CSS)">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16"/>
</svg>
</button>
<button class="em-square-btn" id="__em_toggle_tab" title="Toggle Execute tab">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
</svg>
</button>
</div>
<!-- Selector Display -->
<div class="em-selector-display">
<svg viewBox="0 0 24 24" id="__em_copy_selector" title="Copy selector">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span class="em-selector-text" id="__em_selector_text">Click an element to select</span>
<div class="em-selector-nav">
<button class="em-nav-btn" id="__em_nav_up" title="Select parent">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
</svg>
</button>
<button class="em-nav-btn" id="__em_nav_down" title="Select child">
<svg viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
</svg>
</button>
</div>
</div>
<!-- Tabs -->
<div class="em-tabs">
<button class="em-tab active" data-tab="attributes">Attributes</button>
<button class="em-tab" data-tab="execute">Execute</button>
</div>
<!-- Status -->
<div class="em-status idle" id="__em_status"></div>
<!-- Content: Attributes Tab -->
<div class="em-content" id="__em_tab_attributes">
<h3 class="em-section-title">#1 Element</h3>
<div class="em-attributes">
<div class="em-attribute">
<div class="em-attribute-label">name</div>
<div class="em-attribute-value editable">
<input class="em-input" id="__em_name" placeholder="Element name" />
</div>
</div>
<div class="em-attribute">
<div class="em-attribute-label">selector</div>
<div class="em-attribute-value">
<svg class="copy-icon" viewBox="0 0 24 24" id="__em_copy" title="Copy">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
</svg>
<span class="em-attribute-text" id="__em_selector">-</span>
</div>
</div>
</div>
<h3 class="em-section-title">Selector Preferences</h3>
<div class="em-settings">
<div class="em-checkbox-group">
<label class="em-checkbox-label">
<input type="checkbox" id="__em_pref_id" checked />
<span>Prefer ID</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_pref_attr" checked />
<span>Prefer stable attributes</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_pref_class" checked />
<span>Prefer class names</span>
</label>
</div>
</div>
<div class="em-actions">
<button class="em-btn em-btn-primary" id="__em_verify">Verify (Highlight Only)</button>
</div>
<div class="em-actions">
<button class="em-btn em-btn-success" id="__em_save">Save</button>
<button class="em-btn em-btn-ghost" id="__em_cancel">Cancel</button>
</div>
</div>
<!-- Content: Execute Tab -->
<div class="em-content" id="__em_tab_execute" style="display: none;">
<div class="em-settings">
<div class="em-settings-group">
<div class="em-settings-label">Action</div>
<div class="em-select-wrapper">
<select class="em-select" id="__em_action">
<option value="hover">Hover</option>
<option value="left_click">Left click</option>
<option value="double_click">Double click</option>
<option value="right_click">Right click</option>
<option value="scroll">Scroll</option>
<option value="type_text">Type text</option>
<option value="press_keys">Press keys</option>
</select>
</div>
</div>
<!-- Action-specific inputs (dynamically shown/hidden) -->
<div class="em-settings-group" id="__em_action_text_group" style="display: none;">
<div class="em-settings-label">Text</div>
<input class="em-field-input" id="__em_action_text" placeholder="Text to type" />
</div>
<div class="em-settings-group" id="__em_action_keys_group" style="display: none;">
<div class="em-settings-label">Keys</div>
<input class="em-field-input" id="__em_action_keys" placeholder="Keys to press (e.g., Enter, Ctrl+C)" />
</div>
<div class="em-settings-group" id="__em_scroll_options" style="display: none;">
<div class="em-settings-label">Scroll Direction</div>
<div class="em-select-wrapper">
<select class="em-select" id="__em_scroll_direction">
<option value="down">Down</option>
<option value="up">Up</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
<div class="em-field" style="margin-top: 8px;">
<div class="em-field-label">Amount (1-10, ~100px each)</div>
<input class="em-field-input" id="__em_scroll_distance" type="number" min="1" max="10" step="1" value="3" />
</div>
</div>
<!-- Click-specific options -->
<div id="__em_click_options" style="display: none;">
<div class="em-grid">
<div class="em-field">
<div class="em-field-label">Button</div>
<select class="em-select" id="__em_btn">
<option value="left">Left</option>
<option value="middle">Middle</option>
<option value="right">Right</option>
</select>
</div>
<div class="em-field">
<div class="em-field-label">Timeout (ms)</div>
<input class="em-field-input" id="__em_nav_timeout" type="number" value="3000" />
</div>
</div>
<div class="em-checkbox-group" style="margin-top: 12px;">
<label class="em-checkbox-label">
<input type="checkbox" id="__em_wait_nav" />
<span>Wait for navigation</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_mod_alt" />
<span>Alt key</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_mod_ctrl" />
<span>Ctrl key</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_mod_meta" />
<span>Meta key</span>
</label>
<label class="em-checkbox-label">
<input type="checkbox" id="__em_mod_shift" />
<span>Shift key</span>
</label>
</div>
</div>
<div class="em-actions" style="margin-top: 16px;">
<button class="em-btn em-btn-primary" id="__em_execute">Execute</button>
</div>
<!-- Execution History -->
<div id="__em_execution_history" style="margin-top: 16px; display: none;">
<div class="em-settings-label">Recent Executions</div>
<div id="__em_history_list" style="font-size: 12px; color: #737373; margin-top: 8px;"></div>
</div>
</div>
</div>
<!-- Footer -->
<div class="em-footer">
Click or press <kbd>Space</kbd> to select an element
</div>
</div>
`;
function mount() {
if (hostElement) return { host: hostElement, shadow: shadowRoot };
hostElement = document.createElement('div');
hostElement.id = '__element_marker_overlay';
Object.assign(hostElement.style, {
position: 'fixed',
top: '24px',
right: '24px',
zIndex: String(CONFIG.Z_INDEX.OVERLAY),
pointerEvents: 'none',
});
shadowRoot = hostElement.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = `<style>${PANEL_STYLES}</style>${PANEL_TEMPLATE}`;
hostElement.querySelector = (...args) => shadowRoot.querySelector(...args);
hostElement.querySelectorAll = (...args) => shadowRoot.querySelectorAll(...args);
const panel = shadowRoot.querySelector('.em-panel');
if (panel) {
panel.style.pointerEvents = 'auto';
}
document.documentElement.appendChild(hostElement);
return { host: hostElement, shadow: shadowRoot };
}
function unmount() {
if (hostElement?.parentNode) {
hostElement.parentNode.removeChild(hostElement);
}
hostElement = null;
shadowRoot = null;
}
function getHost() {
return hostElement;
}
function getShadow() {
return shadowRoot;
}
return {
mount,
unmount,
getHost,
getShadow,
};
})();
// ============================================================================
// State Store Module - Centralized State Management
// ============================================================================
const StateStore = (() => {
const state = {
selectorType: CONFIG.DEFAULTS.SELECTOR_TYPE,
listMode: CONFIG.DEFAULTS.LIST_MODE,
prefs: { ...CONFIG.DEFAULTS.PREFS },
activeTab: 'attributes',
validation: {
status: 'idle',
message: '',
},
validationHistory: [], // Last 5 validation results
};
const listeners = new Set();
function init() {
return state;
}
function get(key) {
return key ? state[key] : state;
}
function set(partial) {
const changed = {};
Object.keys(partial).forEach((key) => {
if (JSON.stringify(state[key]) !== JSON.stringify(partial[key])) {
changed[key] = true;
state[key] = partial[key];
}
});
if (Object.keys(changed).length === 0) return;
if (changed.validation) {
updateValidationUI();
}
if (changed.activeTab) {
updateTabUI();
}
if (changed.listMode) {
updateListModeUI();
}
if (changed.validationHistory) {
updateValidationHistoryUI();
}
notifyListeners();
}
function subscribe(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
}
function notifyListeners() {
listeners.forEach((cb) => {
try {
cb(state);
} catch (err) {
console.error('[StateStore] Listener error:', err);
}
});
}
function updateValidationUI() {
const statusEl = PanelHost.getShadow()?.getElementById('__em_status');
if (!statusEl) return;
const { status, message } = state.validation;
statusEl.className = `em-status ${status}`;
statusEl.textContent = message;
}
function updateListModeUI() {
const shadow = PanelHost.getShadow();
if (!shadow) return;
const btn = shadow.getElementById('__em_toggle_list');
if (!btn) return;
if (state.listMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
function updateTabUI() {
const shadow = PanelHost.getShadow();
if (!shadow) return;
const tabs = shadow.querySelectorAll('.em-tab');
tabs.forEach((tab) => {
if (tab.dataset.tab === state.activeTab) {
tab.classList.add('active');
} else {
tab.classList.remove('active');
}
});
const attrContent = shadow.getElementById('__em_tab_attributes');
const executeContent = shadow.getElementById('__em_tab_execute');
if (attrContent)
attrContent.style.display = state.activeTab === 'attributes' ? 'block' : 'none';
if (executeContent)
executeContent.style.display = state.activeTab === 'execute' ? 'block' : 'none';
// Sync interaction mode when tab changes
syncInteractionMode();
}
function updateValidationHistoryUI() {
const shadow = PanelHost.getShadow();
if (!shadow) return;
const historyContainer = shadow.getElementById('__em_execution_history');
const historyList = shadow.getElementById('__em_history_list');
if (!historyContainer || !historyList) return;
if (state.validationHistory.length === 0) {
historyContainer.style.display = 'none';
return;
}
historyContainer.style.display = 'block';
historyList.innerHTML = state.validationHistory
.slice(-5)
.reverse()
.map((entry) => {
const icon = entry.success ? '✓' : '✗';
const color = entry.success ? '#10b981' : '#ef4444';
const timestamp = new Date(entry.timestamp).toLocaleTimeString();
return `<div style="padding: 6px 0; border-bottom: 1px solid #f5f5f5;">
<span style="color: ${color}; font-weight: 600;">${icon}</span>
<span style="margin-left: 6px;">${entry.action}</span>
<span style="float: right; color: #a3a3a3; font-size: 11px;">${timestamp}</span>
</div>`;
})
.join('');
}
return {
init,
get,
set,
subscribe,
};
})();
// ============================================================================
// Drag Controller Module
// ============================================================================
const DragController = (() => {
let dragging = false;
let startPos = { x: 0, y: 0 };
let startOffset = { top: 0, right: 0 };
function init(handleElement) {
if (!handleElement) return;
handleElement.addEventListener('mousedown', onDragStart);
}
function onDragStart(event) {
event.preventDefault();
dragging = true;
const host = PanelHost.getHost();
if (!host) return;
startPos = { x: event.clientX, y: event.clientY };
startOffset = {
top: parseInt(host.style.top) || 0,
right: parseInt(host.style.right) || 0,
};
document.addEventListener('mousemove', onDragMove, { capture: true, passive: false });
document.addEventListener('mouseup', onDragEnd, { capture: true, passive: false });
document.body.setAttribute('data-em-dragging', 'true');
}
function onDragMove(event) {
if (!dragging) return;
event.preventDefault();
event.stopPropagation();
const host = PanelHost.getHost();
if (!host) return;
const deltaX = event.clientX - startPos.x;
const deltaY = event.clientY - startPos.y;
const newTop = Math.max(8, startOffset.top + deltaY);
const newRight = Math.max(8, startOffset.right - deltaX);
host.style.top = `${newTop}px`;
host.style.right = `${newRight}px`;
}
function onDragEnd(event) {
if (!dragging) return;
event.preventDefault();
event.stopPropagation();
dragging = false;
document.removeEventListener('mousemove', onDragMove, { capture: true });
document.removeEventListener('mouseup', onDragEnd, { capture: true });
document.body.removeAttribute('data-em-dragging');
}
function destroy() {
if (dragging) {
onDragEnd(new MouseEvent('mouseup'));
}
}
return { init, destroy };
})();
// [继续下一部分...]
// ============================================================================
// Selector Engine - Heuristic Selector Generation
// ============================================================================
function generateSelector(el) {
if (!(el instanceof Element)) return '';
const prefs = StateStore.get('prefs');
if (prefs.preferId && el.id) {
const idSel = `#${CSS.escape(el.id)}`;
if (isDeepSelectorUnique(idSel, el)) return idSel;
}
if (prefs.preferStableAttr) {
const attrNames = [
'data-testid',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
'title',
'alt',
'aria-label',
];
const tag = el.tagName.toLowerCase();
for (const attr of attrNames) {
const v = el.getAttribute(attr);
if (!v) continue;
const attrSel = `[${attr}="${CSS.escape(v)}"]`;
const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${attrSel}` : attrSel;
if (isDeepSelectorUnique(testSel, el)) return testSel;
}
}
if (prefs.preferClass) {
try {
const classes = Array.from(el.classList || []).filter(
(c) => c && /^[a-zA-Z0-9_-]+$/.test(c),
);
const tag = el.tagName.toLowerCase();
for (const cls of classes) {
const sel = `.${CSS.escape(cls)}`;
if (isDeepSelectorUnique(sel, el)) return sel;
}
for (const cls of classes) {
const sel = `${tag}.${CSS.escape(cls)}`;
if (isDeepSelectorUnique(sel, el)) return sel;
}
for (let i = 0; i < Math.min(classes.length, 3); i++) {
for (let j = i + 1; j < Math.min(classes.length, 3); j++) {
const sel = `.${CSS.escape(classes[i])}.${CSS.escape(classes[j])}`;
if (isDeepSelectorUnique(sel, el)) return sel;
}
}
} catch {}
}
if (prefs.preferStableAttr) {
try {
let cur = el;
const anchorAttrs = [
'id',
'data-testid',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
];
// Detect shadow DOM boundary
const root = el.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
const boundary = isShadowElement ? root.host : document.body;
while (cur && cur !== boundary) {
if (cur.id) {
const anchor = `#${CSS.escape(cur.id)}`;
if (isDeepSelectorUnique(anchor, cur)) {
const rel = buildPathFromAncestor(cur, el);
const composed = rel ? `${anchor} ${rel}` : anchor;
if (isDeepSelectorUnique(composed, el)) return composed;
}
}
for (const attr of anchorAttrs) {
const val = cur.getAttribute(attr);
if (!val) continue;
const aSel = `[${attr}="${CSS.escape(val)}"]`;
if (isDeepSelectorUnique(aSel, cur)) {
const rel = buildPathFromAncestor(cur, el);
const composed = rel ? `${aSel} ${rel}` : aSel;
if (isDeepSelectorUnique(composed, el)) return composed;
}
}
cur = cur.parentElement;
}
} catch {}
}
return buildFullPath(el);
}
function buildPathFromAncestor(ancestor, target) {
const segs = [];
let cur = target;
// Detect if we're inside shadow DOM
const root = target.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
const boundary = isShadowElement ? root.host : document.body;
while (cur && cur !== ancestor && cur !== boundary) {
let seg = cur.tagName.toLowerCase();
const parent = cur.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((c) => c.tagName === cur.tagName);
if (siblings.length > 1) {
seg += `:nth-of-type(${siblings.indexOf(cur) + 1})`;
}
}
segs.unshift(seg);
cur = parent;
// Stop if we've reached the shadow root host
if (isShadowElement && cur === boundary) {
break;
}
}
return segs.join(' > ');
}
function buildFullPath(el) {
let path = '';
let current = el;
// Detect if the element is inside a shadow DOM
const root = el.getRootNode();
const isShadowElement = root instanceof ShadowRoot;
// Determine the boundary where we should stop traversing
const boundary = isShadowElement ? root.host : document.body;
while (current && current.nodeType === Node.ELEMENT_NODE && current !== boundary) {
let sel = current.tagName.toLowerCase();
const parent = current.parentElement;
if (parent) {
const siblings = Array.from(parent.children).filter((c) => c.tagName === current.tagName);
if (siblings.length > 1) {
sel += `:nth-of-type(${siblings.indexOf(current) + 1})`;
}
}
path = path ? `${sel} > ${path}` : sel;
current = parent;
// Stop if we've reached the shadow root host
if (isShadowElement && current === boundary) {
break;
}
}
// For shadow DOM elements, don't prepend "body >"
// The selector should be relative within the shadow tree
if (isShadowElement) {
return path || el.tagName.toLowerCase();
}
// For light DOM elements, keep the original behavior
return path ? `body > ${path}` : 'body';
}
function generateXPath(el) {
if (!(el instanceof Element)) return '';
if (el.id) return `//*[@id="${el.id}"]`;
const segs = [];
let cur = el;
while (cur && cur.nodeType === 1 && cur !== document.documentElement) {
const tag = cur.tagName.toLowerCase();
if (cur.id) {
segs.unshift(`//*[@id="${cur.id}"]`);
break;
}
let i = 1;
let sib = cur;
while ((sib = sib.previousElementSibling)) {
if (sib.tagName.toLowerCase() === tag) i++;
}
segs.unshift(`${tag}[${i}]`);
cur = cur.parentElement;
}
return segs[0]?.startsWith('//*') ? segs.join('/') : '//' + segs.join('/');
}
function generateListSelector(target) {
const list = computeElementList(target);
const selected = list?.[0] || target;
const parent = selected.parentElement;
if (!parent) return generateSelector(target);
const parentSel = generateSelector(parent);
const childRel = generateSelectorWithinRoot(selected, parent);
return parentSel && childRel ? `${parentSel} ${childRel}` : generateSelector(target);
}
function generateSelectorWithinRoot(el, root) {
if (!(el instanceof Element)) return '';
const tag = el.tagName.toLowerCase();
// Use isDeepSelectorUnique for ID to support shadow DOM elements
if (el.id) {
const idSel = `#${CSS.escape(el.id)}`;
if (isDeepSelectorUnique(idSel, el)) return idSel;
}
const attrNames = [
'data-testid',
'data-testId',
'data-test',
'data-qa',
'data-cy',
'name',
'title',
'alt',
'aria-label',
];
// Use isDeepSelectorUnique for attributes to support shadow DOM elements
for (const attr of attrNames) {
const v = el.getAttribute(attr);
if (!v) continue;
const aSel = `[${attr}="${CSS.escape(v)}"]`;
const testSel = /^(input|textarea|select)$/i.test(tag) ? `${tag}${aSel}` : aSel;
if (isDeepSelectorUnique(testSel, el)) return testSel;
}
try {
const classes = Array.from(el.classList || []).filter((c) => c && /^[a-zA-Z0-9_-]+$/.test(c));
// Use isDeepSelectorUnique for classes to support shadow DOM elements
for (const cls of classes) {
const sel = `.${CSS.escape(cls)}`;
if (isDeepSelectorUnique(sel, el)) return sel;
}
for (const cls of classes) {
const sel = `${tag}.${CSS.escape(cls)}`;
if (isDeepSelectorUnique(sel, el)) return sel;
}
} catch {}
return buildPathFromAncestor(root, el);
}
function getAccessibleName(el) {
try {
const labelledby = el.getAttribute('aria-labelledby');
if (labelledby) {
const labelEl = document.getElementById(labelledby);
if (labelEl) return (labelEl.textContent || '').trim();
}
const ariaLabel = el.getAttribute('aria-label');
if (ariaLabel) return ariaLabel.trim();
if (el.id) {
const label = document.querySelector(`label[for="${el.id}"]`);
if (label) return (label.textContent || '').trim();
}
const parentLabel = el.closest('label');
if (parentLabel) return (parentLabel.textContent || '').trim();
return (
el.getAttribute('placeholder') ||
el.getAttribute('value') ||
el.textContent ||
''
).trim();
} catch {
return '';
}
}
// ============================================================================
// List Mode Utilities
// ============================================================================
function getAllSiblings(el, selector) {
const siblings = [el];
const validate = (element) => {
const isSameTag = el.tagName === element.tagName;
let ok = isSameTag;
if (selector) {
try {
ok = ok && !!element.querySelector(selector);
} catch {}
}
return ok;
};
let next = el;
let prev = el;
let elementIndex = 1;
while ((prev = prev?.previousElementSibling)) {
if (validate(prev)) {
elementIndex += 1;
siblings.unshift(prev);
}
}
while ((next = next?.nextElementSibling)) {
if (validate(next)) siblings.push(next);
}
return { elements: siblings, index: elementIndex };
}
function getElementList(el, maxDepth = 50, paths = []) {
if (maxDepth === 0 || !el || el.tagName === 'BODY') return null;
let selector = el.tagName.toLowerCase();
const { elements, index } = getAllSiblings(el, paths.join(' > '));
let siblings = elements;
if (index !== 1) selector += `:nth-of-type(${index})`;
paths.unshift(selector);
if (siblings.length === 1) {
siblings = getElementList(el.parentElement, maxDepth - 1, paths);
}
return siblings;
}
function computeElementList(target) {
try {
return getElementList(target) || [target];
} catch {
return [target];
}
}
// ============================================================================
// Deep Query (Shadow DOM Support)
// ============================================================================
function* walkAllNodesDeep(root) {
const stack = [root];
let count = 0;
const MAX = 10000;
while (stack.length) {
const node = stack.pop();
if (!node || ++count > MAX) continue;
// Skip overlay elements to prevent panel self-highlighting
if (isOverlayElement(node)) {
continue;
}
yield node;
try {
if (node.children) {
const children = Array.from(node.children);
for (let i = children.length - 1; i >= 0; i--) {
stack.push(children[i]);
}
}
if (node.shadowRoot?.children) {
const srChildren = Array.from(node.shadowRoot.children);
for (let i = srChildren.length - 1; i >= 0; i--) {
stack.push(srChildren[i]);
}
}
} catch {}
}
}
function queryAllDeep(selector) {
const results = [];
for (const node of walkAllNodesDeep(document)) {
if (!(node instanceof Element)) continue;
try {
if (node.matches(selector)) results.push(node);
} catch {}
}
return results;
}
/**
* Check if a selector uniquely identifies the target element across the entire DOM tree,
* including shadow DOM boundaries.
*
* This function uses queryAllDeep to traverse both light DOM and shadow DOM,
* ensuring that selectors work correctly for elements inside shadow roots.
*
* @param {string} selector - The CSS selector to test
* @param {Element} target - The target element that should be uniquely identified
* @returns {boolean} True if the selector matches exactly one element and it's the target
*/
function isDeepSelectorUnique(selector, target) {
if (!selector || !(target instanceof Element)) return false;
try {
const matches = queryAllDeep(selector);
return matches.length === 1 && matches[0] === target;
} catch (error) {
return false;
}
}
function evaluateXPathAll(xpath) {
try {
const arr = [];
const res = document.evaluate(
xpath,
document,
null,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
null,
);
for (let i = 0; i < res.snapshotLength; i++) {
const n = res.snapshotItem(i);
// Filter out overlay elements to prevent panel self-highlighting
if (n?.nodeType === 1 && !isOverlayElement(n)) {
arr.push(n);
}
}
return arr;
} catch {
return [];
}
}
// ============================================================================
// Highlighter & Rects Management
// ============================================================================
const STATE = {
active: false,
hoverEl: null,
selectedEl: null,
box: null,
highlighter: null,
listenersAttached: false,
rectsHost: null,
hoveredList: [],
verifyRectsActive: false, // Track if verify rects are showing
// Performance optimization: rAF throttling for hover
hoverRafId: null,
lastHoverTarget: null,
// DOM pooling for rect elements
rectPool: [],
rectPoolUsed: 0,
};
function ensureHighlighter() {
if (STATE.highlighter) return STATE.highlighter;
const hl = document.createElement('div');
hl.id = '__element_marker_highlight';
Object.assign(hl.style, {
position: 'fixed',
zIndex: String(CONFIG.Z_INDEX.HIGHLIGHTER),
pointerEvents: 'none',
border: `2px solid ${CONFIG.COLORS.HOVER}`,
borderRadius: '4px',
boxShadow: `0 0 0 2px ${CONFIG.COLORS.HOVER}33`,
transition: 'all 100ms ease-out',
});
document.documentElement.appendChild(hl);
STATE.highlighter = hl;
return hl;
}
function ensureRectsHost() {
if (STATE.rectsHost) return STATE.rectsHost;
const host = document.createElement('div');
host.id = '__element_marker_rects';
Object.assign(host.style, {
position: 'fixed',
zIndex: String(CONFIG.Z_INDEX.RECTS),
pointerEvents: 'none',
inset: '0',
});
document.documentElement.appendChild(host);
STATE.rectsHost = host;
return host;
}
function moveHighlighterTo(el) {
const hl = ensureHighlighter();
const r = el.getBoundingClientRect();
hl.style.left = `${r.left}px`;
hl.style.top = `${r.top}px`;
hl.style.width = `${r.width}px`;
hl.style.height = `${r.height}px`;
hl.style.display = 'block';
}
function clearHighlighter() {
if (STATE.highlighter) STATE.highlighter.style.display = 'none';
// Only clear hover rects, not verify rects
if (!STATE.verifyRectsActive) {
clearRects();
}
}
function clearRects() {
// Hide all pooled rect boxes instead of destroying them
const used = STATE.rectPoolUsed || 0;
for (let i = 0; i < used; i++) {
const box = STATE.rectPool[i];
if (box) box.style.display = 'none';
}
STATE.rectPoolUsed = 0;
STATE.verifyRectsActive = false;
// Invalidate lastHoverTarget so next hover will redraw even on same element
STATE.lastHoverTarget = null;
}
/**
* Get or create a rect box from the pool
* @param {HTMLElement} host - The container element
* @param {number} index - The pool index
* @returns {HTMLDivElement} The rect box element
*/
function getOrCreateRectBox(host, index) {
let box = STATE.rectPool[index];
if (!box) {
box = document.createElement('div');
Object.assign(box.style, {
position: 'fixed',
pointerEvents: 'none',
borderRadius: '4px',
transition: 'all 100ms ease-out',
display: 'none',
});
STATE.rectPool[index] = box;
}
// Ensure the box is attached to the host
if (!box.isConnected) {
host.appendChild(box);
}
return box;
}
// Maximum rect pool size to prevent memory bloat
const MAX_RECT_POOL_SIZE = 100;
/**
* Draw rect boxes with pooling optimization
* @param {Array<{x: number, y: number, width: number, height: number}>} rects - Rect data
* @param {Object} options - Drawing options
* @param {boolean} options.isVerify - Whether this is a verify highlight (affects verifyRectsActive)
*/
function drawRectBoxes(
rects,
{ color = CONFIG.COLORS.HOVER, dashed = true, offsetX = 0, offsetY = 0, isVerify = false } = {},
) {
const host = ensureRectsHost();
const prevUsed = STATE.rectPoolUsed || 0;
// Limit rect count to prevent memory bloat
const count = Math.min(Array.isArray(rects) ? rects.length : 0, MAX_RECT_POOL_SIZE);
// Update or show rect boxes
for (let i = 0; i < count; i++) {
const r = rects[i];
if (!r) continue;
const x = Number.isFinite(r.left) ? r.left : Number.isFinite(r.x) ? r.x : 0;
const y = Number.isFinite(r.top) ? r.top : Number.isFinite(r.y) ? r.y : 0;
const w = Number.isFinite(r.width) ? r.width : 0;
const h = Number.isFinite(r.height) ? r.height : 0;
const box = getOrCreateRectBox(host, i);
Object.assign(box.style, {
left: `${offsetX + x}px`,
top: `${offsetY + y}px`,
width: `${w}px`,
height: `${h}px`,
border: `2px ${dashed ? 'dashed' : 'solid'} ${color}`,
boxShadow: `0 0 0 2px ${color}22`,
display: 'block',
});
}
// Hide excess boxes from previous render
for (let i = count; i < prevUsed; i++) {
const box = STATE.rectPool[i];
if (box) box.style.display = 'none';
}
STATE.rectPoolUsed = count;
// Reset verifyRectsActive for hover operations (so clearHighlighter works correctly)
// Only set to true when isVerify is explicitly true
STATE.verifyRectsActive = isVerify;
}
function drawRects(elements, color = CONFIG.COLORS.HOVER, dashed = true, isVerify = false) {
const rects = elements.map((el) => {
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
});
drawRectBoxes(rects, { color, dashed, isVerify });
}
// ============================================================================
// Interaction Logic
// ============================================================================
function isInsidePanel(target) {
const shadow = PanelHost.getShadow();
return !!shadow && target instanceof Node && shadow.contains(target);
}
/**
* Check if a node belongs to the element marker overlay (panel host or its shadow DOM)
* This is used to filter out overlay elements from query results to prevent self-highlighting
*
* @param {Node} node - The node to check
* @returns {boolean} True if the node is part of the overlay
*/
function isOverlayElement(node) {
if (!(node instanceof Node)) return false;
const host = PanelHost.getHost();
if (!host) return false;
// Check if node is the panel host itself
if (node === host) return true;
// Check if node is within the shadow DOM of the panel host
const root = typeof node.getRootNode === 'function' ? node.getRootNode() : null;
return root instanceof ShadowRoot && root.host === host;
}
/**
* Filter out overlay elements from an array of elements
* This ensures that panel components are never included in highlight/verification results
*
* @param {Array} elements - Array of elements to filter
* @returns {Array} Filtered array without overlay elements
*/
function filterOverlayElements(elements) {
if (!Array.isArray(elements)) return [];
return elements.filter((node) => !isOverlayElement(node));
}
/**
* Get the effective event target for page element selection, considering shadow DOM boundaries.
*
* This function resolves the real target element from a pointer event by walking the
* composed path (if available) to find the innermost page element, skipping overlay elements.
*
* Background:
* - When events bubble up from inside shadow DOM, they get "retargeted" at shadow boundaries
* - By the time a window-level listener receives the event, ev.target points to the shadow host
* - composedPath() exposes the original event path before retargeting
* - This allows us to select elements inside shadow DOM (e.g., <td-header> internals)
*
* IMPORTANT: This function should only be called AFTER verifying the event is not from
* overlay UI (panel buttons, etc). Otherwise it will filter out overlay elements and break
* panel interactions.
*
* @param {Event} ev - The pointer event (mousemove, click, etc.)
* @returns {Element|null} The innermost non-overlay page element, or null if none found
*/
function getDeepPageTarget(ev) {
if (!ev) return null;
// Try to walk the composed path to find the innermost non-overlay element
try {
const path = typeof ev.composedPath === 'function' ? ev.composedPath() : null;
if (Array.isArray(path) && path.length > 0) {
// Walk from innermost to outermost, find the first real page element
for (const node of path) {
if (node instanceof Element && !isOverlayElement(node)) {
return node;
}
}
}
} catch (error) {
// composedPath() may throw in some edge cases (e.g., detached nodes)
// Fall through to use ev.target
}
// Fallback: use ev.target if composedPath is unavailable or all nodes were filtered
const fallback = ev.target instanceof Element ? ev.target : null;
// If fallback is overlay, return null (caller should handle this case)
if (fallback && !isOverlayElement(fallback)) {
return fallback;
}
return null;
}
// Store pending hover event for rAF processing
let pendingHoverEvent = null;
/**
* Process mouse move event - the actual hover update logic
* Separated from onMouseMove for rAF throttling
*/
function processMouseMove(ev) {
if (!STATE.active) return;
const rawTarget = ev?.target;
if (!(rawTarget instanceof Element)) {
STATE.hoverEl = null;
STATE.lastHoverTarget = null;
clearHighlighter();
return;
}
const host = PanelHost.getHost();
if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {
STATE.hoverEl = null;
STATE.lastHoverTarget = null;
clearHighlighter();
return;
}
const target = getDeepPageTarget(ev) || rawTarget;
STATE.hoverEl = target;
// Get current listMode
let listMode = false;
try {
listMode = !!StateStore.get('listMode');
} catch {}
// Skip update if target and mode haven't changed
const last = STATE.lastHoverTarget;
if (last && last.element === target && last.listMode === listMode) {
return;
}
STATE.lastHoverTarget = { element: target, listMode };
if (!IS_MAIN) {
try {
const list = listMode ? computeElementList(target) || [target] : [target];
const rects = list.map((el) => {
const r = el.getBoundingClientRect();
return { x: r.left, y: r.top, width: r.width, height: r.height };
});
// Performance: Don't generate selector on hover (defer to click)
window.top.postMessage({ type: 'em_hover', rects }, '*');
} catch {}
return;
}
if (listMode) {
STATE.hoveredList = computeElementList(target) || [target];
drawRects(STATE.hoveredList);
} else {
moveHighlighterTo(target);
}
}
/**
* Mouse move handler with rAF throttling
* Ensures hover updates are batched to animation frame rate
*/
function onMouseMove(ev) {
if (!STATE.active) return;
// Store the latest event
pendingHoverEvent = ev;
// Skip if already scheduled
if (STATE.hoverRafId != null) return;
// Schedule processing on next animation frame
STATE.hoverRafId = requestAnimationFrame(() => {
STATE.hoverRafId = null;
const latest = pendingHoverEvent;
pendingHoverEvent = null;
if (!latest) return;
processMouseMove(latest);
});
}
// ============================================================================
// Event Listeners Management
// ============================================================================
function attachPointerListeners() {
if (STATE.listenersAttached) return;
window.addEventListener('mousemove', onMouseMove, true);
window.addEventListener('click', onClick, true);
STATE.listenersAttached = true;
}
function detachPointerListeners() {
if (!STATE.listenersAttached) return;
window.removeEventListener('mousemove', onMouseMove, true);
window.removeEventListener('click', onClick, true);
STATE.listenersAttached = false;
}
function attachKeyboardListener() {
window.addEventListener('keydown', onKeyDown, true);
}
function detachKeyboardListener() {
window.removeEventListener('keydown', onKeyDown, true);
}
function syncInteractionMode() {
if (!STATE.active) return;
const activeTab = StateStore.get('activeTab');
if (activeTab === 'execute') {
// In execute mode, detach pointer listeners to allow real interactions
// but keep keyboard listener for Esc key
detachPointerListeners();
// Only clear the hover highlighter, not the verification rects
if (STATE.highlighter) STATE.highlighter.style.display = 'none';
} else {
// In attributes mode, attach all listeners for element selection
attachPointerListeners();
}
}
// ============================================================================
// Event Handlers
// ============================================================================
function onClick(ev) {
if (!STATE.active) return;
// First, use the raw ev.target to check for overlay UI
// This ensures panel buttons and other UI elements remain interactive
const rawTarget = ev.target;
const host = PanelHost.getHost();
// Check if raw target is the panel host itself or inside the shadow DOM
// IMPORTANT: Return early WITHOUT preventDefault to allow overlay button clicks
if ((host && rawTarget === host) || isInsidePanel(rawTarget)) {
return;
}
// Now we know it's a page element, prevent default and get deep target
ev.preventDefault();
ev.stopPropagation();
if (!(rawTarget instanceof Element)) return;
// Get the deep target (considering shadow DOM) after confirming it's not overlay
const target = getDeepPageTarget(ev) || rawTarget;
if (!IS_MAIN) {
try {
const selectorType = StateStore.get('selectorType');
const listMode = StateStore.get('listMode');
const sel =
selectorType === 'xpath'
? generateXPath(target)
: listMode
? generateListSelector(target)
: generateSelector(target);
window.top.postMessage({ type: 'em_click', innerSel: sel }, '*');
} catch {}
return;
}
setSelection(target);
}
function onKeyDown(e) {
if (!STATE.active) return;
// Check if the focused element is inside the panel - if so, don't handle selection keys
if (isInsidePanel(e.target)) {
// Key event is from panel, don't interfere
if (e.key !== 'Escape') return; // Still allow Escape to close
}
// In execute mode, only handle Escape to close - don't intercept other keys
// This allows real page interactions (typing, scrolling, etc.)
const activeTab = StateStore.get('activeTab');
if (activeTab === 'execute') {
if (e.key === 'Escape') {
e.preventDefault();
stop();
}
return; // Don't intercept Space/Arrow keys in execute mode
}
if (e.key === 'Escape') {
e.preventDefault();
stop();
} else if (e.key === ' ' || e.code === 'Space') {
e.preventDefault();
const t = STATE.hoverEl || STATE.selectedEl;
if (t) setSelection(t);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const base = STATE.selectedEl || STATE.hoverEl;
if (base?.parentElement) setSelection(base.parentElement);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const base = STATE.selectedEl || STATE.hoverEl;
if (base?.firstElementChild) setSelection(base.firstElementChild);
}
}
function setSelection(el) {
if (!(el instanceof Element)) return;
STATE.selectedEl = el;
const selectorType = StateStore.get('selectorType');
const listMode = StateStore.get('listMode');
const sel =
selectorType === 'xpath'
? generateXPath(el)
: listMode
? generateListSelector(el)
: generateSelector(el);
const name = getAccessibleName(el) || el.tagName.toLowerCase();
const selectorText = STATE.box?.querySelector('#__em_selector');
const inputName = STATE.box?.querySelector('#__em_name');
const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');
if (selectorText) selectorText.textContent = sel;
if (selectorDisplay) selectorDisplay.textContent = sel;
if (inputName && !inputName.value) inputName.value = name;
moveHighlighterTo(el);
}
// ============================================================================
// Validation Logic
// ============================================================================
/**
* Verify selector by highlighting only (non-destructive)
*/
async function verifyHighlightOnly() {
try {
const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
if (!selector) return;
StateStore.set({
validation: { status: 'running', message: 'Verifying selector...' },
});
const selectorType = StateStore.get('selectorType');
const listMode = StateStore.get('listMode');
const effectiveType = listMode ? 'css' : selectorType;
// Query for matches
const matches =
effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);
// Additional defense: filter out any overlay elements that might have slipped through
const filteredMatches = filterOverlayElements(matches);
if (!filteredMatches || filteredMatches.length === 0) {
StateStore.set({
validation: { status: 'failure', message: 'No elements found' },
});
return;
}
// Scroll first match into view
const primaryMatch = filteredMatches[0];
if (primaryMatch) {
primaryMatch.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
await sleep(200);
// Highlight matches with isVerify=true to prevent clearing on hover
drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false, true);
StateStore.set({
validation: {
status: 'success',
message: `Found ${filteredMatches.length} element${filteredMatches.length > 1 ? 's' : ''}`,
},
});
// Auto-clear highlight after 2 seconds
setTimeout(() => {
clearRects();
StateStore.set({
validation: { status: 'idle', message: '' },
});
}, 2000);
} catch (error) {
console.error('[verifyHighlightOnly] error:', error);
StateStore.set({
validation: { status: 'failure', message: error.message || 'Verification failed' },
});
}
}
/**
* Execute action on selector (destructive)
*/
async function verifySelectorNow() {
try {
const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
if (!selector) return;
StateStore.set({
validation: { status: 'running', message: 'Executing action...' },
});
const selectorType = StateStore.get('selectorType');
const listMode = StateStore.get('listMode');
const effectiveType = listMode ? 'css' : selectorType;
const matches =
effectiveType === 'xpath' ? evaluateXPathAll(selector) : queryAllDeep(selector);
// Additional defense: filter out any overlay elements that might have slipped through
const filteredMatches = filterOverlayElements(matches);
if (!filteredMatches || filteredMatches.length === 0) {
StateStore.set({
validation: { status: 'failure', message: 'No elements found' },
});
return;
}
drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);
const action = STATE.box?.querySelector('#__em_action')?.value || 'hover';
const payload = {
type: 'element_marker_validate',
selector,
selectorType: effectiveType,
action,
listMode,
};
// Action-specific parameters with validation
if (action === 'type_text') {
const actionText = String(
STATE.box?.querySelector('#__em_action_text')?.value || '',
).trim();
if (!actionText) {
StateStore.set({
validation: { status: 'failure', message: 'Text is required for type_text' },
});
return;
}
payload.text = actionText;
}
if (action === 'press_keys') {
const actionKeys = String(
STATE.box?.querySelector('#__em_action_keys')?.value || '',
).trim();
if (!actionKeys) {
StateStore.set({
validation: { status: 'failure', message: 'Keys are required for press_keys' },
});
return;
}
payload.keys = actionKeys;
}
if (action === 'scroll') {
const direction = STATE.box?.querySelector('#__em_scroll_direction')?.value || 'down';
const rawAmount = Number(STATE.box?.querySelector('#__em_scroll_distance')?.value);
// Clamp to 1-10 range (backend expects ticks, not pixels)
const amount = Math.max(
1,
Math.min(Math.round(Number.isFinite(rawAmount) ? rawAmount : 3), 10),
);
payload.scrollDirection = direction;
payload.scrollAmount = amount;
}
if (['left_click', 'double_click', 'right_click'].includes(action)) {
payload.modifiers = {
altKey: !!STATE.box?.querySelector('#__em_mod_alt')?.checked,
ctrlKey: !!STATE.box?.querySelector('#__em_mod_ctrl')?.checked,
metaKey: !!STATE.box?.querySelector('#__em_mod_meta')?.checked,
shiftKey: !!STATE.box?.querySelector('#__em_mod_shift')?.checked,
};
payload.button = STATE.box?.querySelector('#__em_btn')?.value || 'left';
payload.waitForNavigation = !!STATE.box?.querySelector('#__em_wait_nav')?.checked;
payload.timeoutMs = Number(STATE.box?.querySelector('#__em_nav_timeout')?.value) || 3000;
}
const res = await chrome.runtime.sendMessage(payload);
const success = !!res?.tool?.ok;
const newEntry = {
action,
success,
timestamp: Date.now(),
matchCount: filteredMatches.length,
};
const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);
if (res?.tool?.ok) {
StateStore.set({
validation: {
status: 'success',
message: `✓ 验证成功 (匹配 ${filteredMatches.length} 个元素)`,
},
validationHistory: history,
});
} else {
StateStore.set({
validation: {
status: 'failure',
message: res?.tool?.error || '验证失败',
},
validationHistory: history,
});
}
} catch (err) {
const newEntry = {
action: STATE.box?.querySelector('#__em_action')?.value || 'hover',
success: false,
timestamp: Date.now(),
matchCount: 0,
};
const history = [...(StateStore.get('validationHistory') || []), newEntry].slice(-5);
StateStore.set({
validation: {
status: 'failure',
message: `错误: ${err.message}`,
},
validationHistory: history,
});
}
}
/**
* Highlight selector from external request (popup/background)
* Supports composite iframe selectors: "frameSelector |> innerSelector"
*/
async function highlightSelectorExternal({ selector, selectorType = 'css', listMode = false }) {
const normalized = String(selector || '').trim();
if (!normalized) {
return { success: false, error: 'selector is required' };
}
try {
// Handle composite iframe selector
if (normalized.includes('|>')) {
const parts = normalized
.split('|>')
.map((s) => s.trim())
.filter(Boolean);
if (parts.length >= 2) {
const frameSel = parts[0];
const innerSel = parts.slice(1).join(' |> ');
// Find frame element
let frameEl = null;
try {
frameEl = querySelectorDeepFirst(frameSel) || document.querySelector(frameSel);
} catch {}
if (
!frameEl ||
!(frameEl instanceof HTMLIFrameElement || frameEl instanceof HTMLFrameElement)
) {
return { success: false, error: `Frame element not found: ${frameSel}` };
}
const cw = frameEl.contentWindow;
if (!cw) {
return { success: false, error: 'Unable to access frame contentWindow' };
}
// Forward highlight request to iframe
return new Promise((resolve) => {
const reqId = `em_highlight_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const listener = (ev) => {
try {
const data = ev?.data;
if (!data || data.type !== 'em-highlight-result' || data.reqId !== reqId) return;
window.removeEventListener('message', listener, true);
resolve(data.result);
} catch {}
};
window.addEventListener('message', listener, true);
setTimeout(() => {
window.removeEventListener('message', listener, true);
resolve({ success: false, error: 'Frame highlight timeout' });
}, 3000);
cw.postMessage(
{
type: 'em-highlight-request',
reqId,
selector: innerSel,
selectorType,
listMode,
},
'*',
);
});
}
}
// Handle normal selector (non-iframe)
const effectiveType = listMode ? 'css' : selectorType;
const matches =
effectiveType === 'xpath' ? evaluateXPathAll(normalized) : queryAllDeep(normalized);
// Additional defense: filter out any overlay elements that might have slipped through
const filteredMatches = filterOverlayElements(matches);
if (!filteredMatches || filteredMatches.length === 0) {
return { success: false, error: 'No elements found for selector' };
}
// Scroll first match into view
const primaryMatch = filteredMatches[0];
if (primaryMatch) {
primaryMatch.scrollIntoView({
block: 'center',
inline: 'center',
behavior: 'smooth',
});
}
await sleep(150);
// Draw highlight rectangles
drawRects(filteredMatches, CONFIG.COLORS.VERIFY, false);
// Auto-clear after 2 seconds
setTimeout(() => {
clearRects();
}, 2000);
return { success: true, count: filteredMatches.length };
} catch (error) {
return { success: false, error: error.message || String(error) };
}
}
function copySelectorNow() {
try {
const sel = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
if (!sel) return;
navigator.clipboard?.writeText(sel).catch(() => {});
StateStore.set({
validation: { status: 'success', message: '✓ 已复制到剪贴板' },
});
setTimeout(() => {
StateStore.set({ validation: { status: 'idle', message: '' } });
}, 2000);
} catch {}
}
async function save() {
try {
const name = STATE.box?.querySelector('#__em_name')?.value?.trim();
const selector = STATE.box?.querySelector('#__em_selector')?.textContent?.trim();
if (!selector) return;
const url = location.href;
let selectorType = StateStore.get('selectorType');
const listMode = StateStore.get('listMode');
if (listMode && selectorType === 'xpath') {
selectorType = 'css';
}
await chrome.runtime.sendMessage({
type: 'element_marker_save',
marker: {
url,
name: name || selector,
selector,
selectorType,
listMode,
},
});
} catch {}
stop();
}
// ============================================================================
// Lifecycle Management
// ============================================================================
function start() {
if (STATE.active) return;
STATE.active = true;
if (IS_MAIN) {
const { host } = PanelHost.mount();
STATE.box = host;
StateStore.init();
bindControls();
}
ensureHighlighter();
ensureRectsHost();
attachPointerListeners();
attachKeyboardListener();
syncInteractionMode();
}
function stop() {
STATE.active = false;
detachPointerListeners();
detachKeyboardListener();
// Cancel pending rAF
if (STATE.hoverRafId != null) {
cancelAnimationFrame(STATE.hoverRafId);
STATE.hoverRafId = null;
}
pendingHoverEvent = null;
try {
STATE.highlighter?.remove();
STATE.rectsHost?.remove();
PanelHost.unmount();
DragController.destroy();
} catch {}
STATE.highlighter = null;
STATE.rectsHost = null;
STATE.box = null;
STATE.hoveredList = [];
STATE.hoverEl = null;
STATE.selectedEl = null;
STATE.lastHoverTarget = null;
STATE.verifyRectsActive = false;
// Clear rect pool to release DOM references
STATE.rectPool.length = 0;
STATE.rectPoolUsed = 0;
}
// ============================================================================
// Controls Binding
// ============================================================================
function bindControls() {
const host = STATE.box;
if (!host) return;
// Close/Cancel
host.querySelector('#__em_close')?.addEventListener('click', stop);
host.querySelector('#__em_cancel')?.addEventListener('click', stop);
// Save
host.querySelector('#__em_save')?.addEventListener('click', save);
// Verify (highlight only) & Execute (real action)
host.querySelector('#__em_verify')?.addEventListener('click', verifyHighlightOnly);
host.querySelector('#__em_execute')?.addEventListener('click', verifySelectorNow);
// Copy
host.querySelector('#__em_copy')?.addEventListener('click', copySelectorNow);
host.querySelector('#__em_copy_selector')?.addEventListener('click', copySelectorNow);
// Action change handler - show/hide action-specific options
host.querySelector('#__em_action')?.addEventListener('change', (e) => {
updateActionSpecificUI(e.target.value);
});
// Selector type
host.querySelector('#__em_selector_type')?.addEventListener('change', (e) => {
const newType = e.target.value;
const listMode = StateStore.get('listMode');
// If switching to XPath while in list mode, disable list mode
if (newType === 'xpath' && listMode) {
StateStore.set({ selectorType: newType, listMode: false });
} else {
StateStore.set({ selectorType: newType });
}
// Regenerate selector for the currently selected element
if (STATE.selectedEl) {
setSelection(STATE.selectedEl);
}
// Note: If no selectedEl (e.g., iframe selections or manual input),
// preserve existing selector text instead of clearing it
});
// List mode toggle
host.querySelector('#__em_toggle_list')?.addEventListener('click', (e) => {
const listMode = StateStore.get('listMode');
const newListMode = !listMode;
// If enabling list mode, force CSS selector type
if (newListMode) {
StateStore.set({ listMode: true, selectorType: 'css' });
const selectorTypeSelect = host.querySelector('#__em_selector_type');
if (selectorTypeSelect) selectorTypeSelect.value = 'css';
} else {
StateStore.set({ listMode: false });
}
// Update button active state
const btn = e.currentTarget;
if (btn) {
if (newListMode) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
}
// Regenerate selector for the currently selected element
if (STATE.selectedEl) {
setSelection(STATE.selectedEl);
}
clearHighlighter();
});
// Tab toggle (switch between Attributes and Execute)
host.querySelector('#__em_toggle_tab')?.addEventListener('click', () => {
const currentTab = StateStore.get('activeTab');
StateStore.set({ activeTab: currentTab === 'attributes' ? 'execute' : 'attributes' });
});
// Tab switching
const tabs = host.querySelectorAll('.em-tab');
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
StateStore.set({ activeTab: tab.dataset.tab });
});
});
// Navigation buttons
host.querySelector('#__em_nav_up')?.addEventListener('click', () => {
const base = STATE.selectedEl || STATE.hoverEl;
if (base?.parentElement) setSelection(base.parentElement);
});
host.querySelector('#__em_nav_down')?.addEventListener('click', () => {
const base = STATE.selectedEl || STATE.hoverEl;
if (base?.firstElementChild) setSelection(base.firstElementChild);
});
// Preferences
host.querySelector('#__em_pref_id')?.addEventListener('change', (e) => {
const prefs = { ...StateStore.get('prefs'), preferId: !!e.target.checked };
StateStore.set({ prefs });
});
host.querySelector('#__em_pref_attr')?.addEventListener('change', (e) => {
const prefs = { ...StateStore.get('prefs'), preferStableAttr: !!e.target.checked };
StateStore.set({ prefs });
});
host.querySelector('#__em_pref_class')?.addEventListener('change', (e) => {
const prefs = { ...StateStore.get('prefs'), preferClass: !!e.target.checked };
StateStore.set({ prefs });
});
// Drag - use entire header as drag handle
const dragHandle = host.querySelector('#__em_drag_handle');
if (dragHandle) {
DragController.init(dragHandle);
}
syncUIWithState();
}
function updateActionSpecificUI(action) {
const host = STATE.box;
if (!host) return;
// Hide all action-specific groups
const textGroup = host.querySelector('#__em_action_text_group');
const keysGroup = host.querySelector('#__em_action_keys_group');
const scrollOptions = host.querySelector('#__em_scroll_options');
const clickOptions = host.querySelector('#__em_click_options');
if (textGroup) textGroup.style.display = 'none';
if (keysGroup) keysGroup.style.display = 'none';
if (scrollOptions) scrollOptions.style.display = 'none';
if (clickOptions) clickOptions.style.display = 'none';
// Show relevant options based on action
if (action === 'type_text') {
if (textGroup) textGroup.style.display = 'block';
} else if (action === 'press_keys') {
if (keysGroup) keysGroup.style.display = 'block';
} else if (action === 'scroll') {
if (scrollOptions) scrollOptions.style.display = 'block';
} else if (['left_click', 'double_click', 'right_click'].includes(action)) {
if (clickOptions) clickOptions.style.display = 'block';
// For right_click, button selector is not relevant (always 'right')
// Hide the button field for right_click
const buttonField = host.querySelector('#__em_btn')?.closest('.em-field');
if (buttonField) {
buttonField.style.display = action === 'right_click' ? 'none' : 'block';
}
}
// hover: no extra options needed
}
function syncUIWithState() {
const host = STATE.box;
if (!host) return;
const state = StateStore.get();
const typeSelect = host.querySelector('#__em_selector_type');
if (typeSelect) typeSelect.value = state.selectorType;
// Initialize list mode button state
const listModeBtn = host.querySelector('#__em_toggle_list');
if (listModeBtn) {
if (state.listMode) {
listModeBtn.classList.add('active');
} else {
listModeBtn.classList.remove('active');
}
}
const prefId = host.querySelector('#__em_pref_id');
const prefAttr = host.querySelector('#__em_pref_attr');
const prefClass = host.querySelector('#__em_pref_class');
if (prefId) prefId.checked = state.prefs.preferId;
if (prefAttr) prefAttr.checked = state.prefs.preferStableAttr;
if (prefClass) prefClass.checked = state.prefs.preferClass;
// Initialize action-specific UI
const actionSelect = host.querySelector('#__em_action');
if (actionSelect) {
updateActionSpecificUI(actionSelect.value);
}
}
// ============================================================================
// Cross-Frame Bridge
// ============================================================================
// Register window message listener in all frames (not just main)
// to support cross-frame highlighting from popup validation
window.addEventListener(
'message',
(ev) => {
try {
const data = ev?.data;
if (!data) return;
// Handle iframe highlight request (works even when overlay is inactive)
if (data.type === 'em-highlight-request') {
highlightSelectorExternal({
selector: data.selector,
selectorType: data.selectorType || 'css',
listMode: !!data.listMode,
})
.then((result) => {
window.parent.postMessage(
{
type: 'em-highlight-result',
reqId: data.reqId,
result,
},
'*',
);
})
.catch((error) => {
window.parent.postMessage(
{
type: 'em-highlight-result',
reqId: data.reqId,
result: { success: false, error: error?.message || String(error) },
},
'*',
);
});
return;
}
// Following messages only relevant when overlay is active
if (!STATE.active) return;
// Only main frame handles these overlay-related messages
if (!IS_MAIN) return;
const iframes = Array.from(document.querySelectorAll('iframe'));
const host = iframes.find((f) => {
try {
return f.contentWindow === ev.source;
} catch {
return false;
}
});
if (!host) return;
const base = host.getBoundingClientRect();
if (data.type === 'em_hover' && Array.isArray(data.rects)) {
// Use pooled rect boxes for better performance
drawRectBoxes(data.rects, {
offsetX: base.left,
offsetY: base.top,
color: CONFIG.COLORS.HOVER,
dashed: true,
});
} else if (data.type === 'em_click' && data.innerSel) {
const frameSel = generateSelector(host);
const composite = frameSel ? `${frameSel} |> ${data.innerSel}` : data.innerSel;
const selectorText = STATE.box?.querySelector('#__em_selector');
const selectorDisplay = STATE.box?.querySelector('#__em_selector_text');
if (selectorText) selectorText.textContent = composite;
if (selectorDisplay) selectorDisplay.textContent = composite;
}
} catch {}
},
true,
);
// ============================================================================
// Message Handlers
// ============================================================================
chrome.runtime.onMessage.addListener((request, _sender, sendResponse) => {
if (request?.action === 'element_marker_start') {
start();
sendResponse({ ok: true });
return true;
} else if (request?.action === 'element_marker_ping') {
sendResponse({ status: 'pong' });
return false;
} else if (request?.action === 'element_marker_highlight') {
highlightSelectorExternal({
selector: request.selector,
selectorType: request.selectorType,
listMode: !!request.listMode,
})
.then((result) => sendResponse(result))
.catch((error) => sendResponse({ success: false, error: error?.message || String(error) }));
return true;
}
return false;
});
})();
```