This is page 45 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/entrypoints/web-editor-v2/ui/property-panel/controls/layout-control.ts:
--------------------------------------------------------------------------------
```typescript
1 | /**
2 | * Layout Control (Phase 3.4 + 4.1/4.2 - Refactored)
3 | *
4 | * Edits inline layout styles:
5 | * - display (icon button group): block/inline/inline-block/flex/grid/none
6 | * - flex-direction (icon button group, shown when display=flex)
7 | * - justify-content + align-items (content bars, shown when display=flex)
8 | * - grid-template-columns/rows (dimensions picker, shown when display=grid)
9 | * - flex-wrap (select, shown when display=flex)
10 | * - gap (input, shown when display=flex/grid)
11 | */
12 |
13 | import { Disposer } from '../../../utils/disposables';
14 | import type {
15 | MultiStyleTransactionHandle,
16 | StyleTransactionHandle,
17 | TransactionManager,
18 | } from '../../../core/transaction-manager';
19 | import type { DesignControl } from '../types';
20 | import { createIconButtonGroup, type IconButtonGroup } from '../components/icon-button-group';
21 | import { createInputContainer, type InputContainer } from '../components/input-container';
22 | import { combineLengthValue, formatLengthForDisplay } from './css-helpers';
23 | import { wireNumberStepping } from './number-stepping';
24 |
25 | // =============================================================================
26 | // Constants
27 | // =============================================================================
28 |
29 | const SVG_NS = 'http://www.w3.org/2000/svg';
30 |
31 | const DISPLAY_VALUES = ['block', 'inline', 'inline-block', 'flex', 'grid', 'none'] as const;
32 | const FLEX_DIRECTION_VALUES = ['row', 'column', 'row-reverse', 'column-reverse'] as const;
33 | const FLEX_WRAP_VALUES = ['nowrap', 'wrap', 'wrap-reverse'] as const;
34 | const ALIGNMENT_AXIS_VALUES = ['flex-start', 'center', 'flex-end'] as const;
35 | const GRID_DIMENSION_MAX = 12;
36 |
37 | type DisplayValue = (typeof DISPLAY_VALUES)[number];
38 | type FlexDirectionValue = (typeof FLEX_DIRECTION_VALUES)[number];
39 | type AlignmentAxisValue = (typeof ALIGNMENT_AXIS_VALUES)[number];
40 |
41 | /** Single-property field keys */
42 | type LayoutProperty = 'display' | 'flex-direction' | 'flex-wrap' | 'row-gap' | 'column-gap';
43 |
44 | /** All field keys including composite fields */
45 | type FieldKey = LayoutProperty | 'alignment' | 'grid-dimensions';
46 |
47 | // =============================================================================
48 | // Field State Types
49 | // =============================================================================
50 |
51 | interface DisplayFieldState {
52 | kind: 'display-group';
53 | property: 'display';
54 | group: IconButtonGroup<DisplayValue>;
55 | handle: StyleTransactionHandle | null;
56 | row: HTMLElement;
57 | }
58 |
59 | interface FlexDirectionFieldState {
60 | kind: 'flex-direction-group';
61 | property: 'flex-direction';
62 | group: IconButtonGroup<FlexDirectionValue>;
63 | handle: StyleTransactionHandle | null;
64 | row: HTMLElement;
65 | }
66 |
67 | interface SelectFieldState {
68 | kind: 'select';
69 | property: 'flex-wrap';
70 | element: HTMLSelectElement;
71 | handle: StyleTransactionHandle | null;
72 | row: HTMLElement;
73 | }
74 |
75 | interface InputFieldState {
76 | kind: 'input';
77 | property: 'row-gap' | 'column-gap';
78 | element: HTMLInputElement;
79 | container: InputContainer;
80 | handle: StyleTransactionHandle | null;
81 | row: HTMLElement;
82 | }
83 |
84 | interface FlexAlignmentFieldState {
85 | kind: 'flex-alignment';
86 | properties: readonly ['justify-content', 'align-items'];
87 | justifyGroup: IconButtonGroup<AlignmentAxisValue>;
88 | alignGroup: IconButtonGroup<AlignmentAxisValue>;
89 | handle: MultiStyleTransactionHandle | null;
90 | row: HTMLElement;
91 | }
92 |
93 | interface GridDimensionsFieldState {
94 | kind: 'grid-dimensions';
95 | properties: readonly ['grid-template-columns', 'grid-template-rows'];
96 | previewButton: HTMLButtonElement;
97 | previewColsValue: HTMLSpanElement;
98 | previewRowsValue: HTMLSpanElement;
99 | popover: HTMLDivElement;
100 | colsContainer: InputContainer;
101 | rowsContainer: InputContainer;
102 | matrix: HTMLDivElement;
103 | tooltip: HTMLDivElement;
104 | cells: HTMLButtonElement[];
105 | handle: MultiStyleTransactionHandle | null;
106 | row: HTMLElement;
107 | }
108 |
109 | type FieldState =
110 | | DisplayFieldState
111 | | FlexDirectionFieldState
112 | | SelectFieldState
113 | | InputFieldState
114 | | FlexAlignmentFieldState
115 | | GridDimensionsFieldState;
116 |
117 | // =============================================================================
118 | // Helpers
119 | // =============================================================================
120 |
121 | function isFieldFocused(el: HTMLElement): boolean {
122 | try {
123 | const rootNode = el.getRootNode();
124 | if (rootNode instanceof ShadowRoot) return rootNode.activeElement === el;
125 | return document.activeElement === el;
126 | } catch {
127 | return false;
128 | }
129 | }
130 |
131 | function readInlineValue(element: Element, property: string): string {
132 | try {
133 | const style = (element as HTMLElement).style;
134 | return style?.getPropertyValue?.(property)?.trim() ?? '';
135 | } catch {
136 | return '';
137 | }
138 | }
139 |
140 | function readComputedValue(element: Element, property: string): string {
141 | try {
142 | return window.getComputedStyle(element).getPropertyValue(property).trim();
143 | } catch {
144 | return '';
145 | }
146 | }
147 |
148 | function isDisplayValue(value: string): value is DisplayValue {
149 | return (DISPLAY_VALUES as readonly string[]).includes(value);
150 | }
151 |
152 | function isFlexDirectionValue(value: string): value is FlexDirectionValue {
153 | return (FLEX_DIRECTION_VALUES as readonly string[]).includes(value);
154 | }
155 |
156 | function isAlignmentAxisValue(value: string): value is AlignmentAxisValue {
157 | return (ALIGNMENT_AXIS_VALUES as readonly string[]).includes(value);
158 | }
159 |
160 | /**
161 | * Map computed display values to the closest option value.
162 | */
163 | function normalizeDisplayValue(computed: string): string {
164 | const trimmed = computed.trim();
165 | if (trimmed === 'inline-flex') return 'flex';
166 | if (trimmed === 'inline-grid') return 'grid';
167 | return trimmed;
168 | }
169 |
170 | function clampInt(value: number, min: number, max: number): number {
171 | if (!Number.isFinite(value)) return min;
172 | return Math.min(max, Math.max(min, Math.trunc(value)));
173 | }
174 |
175 | /**
176 | * Split CSS value into top-level tokens (respects parentheses depth).
177 | */
178 | function splitTopLevelTokens(value: string): string[] {
179 | const tokens: string[] = [];
180 | let depth = 0;
181 | let current = '';
182 |
183 | for (let i = 0; i < value.length; i++) {
184 | const ch = value[i]!;
185 | if (ch === '(') depth++;
186 | if (ch === ')' && depth > 0) depth--;
187 |
188 | if (depth === 0 && /\s/.test(ch)) {
189 | const t = current.trim();
190 | if (t) tokens.push(t);
191 | current = '';
192 | continue;
193 | }
194 | current += ch;
195 | }
196 |
197 | const tail = current.trim();
198 | if (tail) tokens.push(tail);
199 | return tokens;
200 | }
201 |
202 | function parseRepeatCount(token: string): number | null {
203 | const match = token.match(/^repeat\(\s*(\d+)\s*,/i);
204 | if (!match) return null;
205 | const n = parseInt(match[1]!, 10);
206 | return Number.isFinite(n) && n > 0 ? n : null;
207 | }
208 |
209 | /**
210 | * Count grid tracks from grid-template-columns/rows value.
211 | */
212 | function countGridTracks(raw: string): number | null {
213 | const trimmed = raw.trim();
214 | if (!trimmed || trimmed === 'none') return null;
215 |
216 | const tokens = splitTopLevelTokens(trimmed);
217 | let count = 0;
218 |
219 | for (const t of tokens) {
220 | // Ignore line-name tokens like [col-start]
221 | if (/^\[.*\]$/.test(t)) continue;
222 | count += parseRepeatCount(t) ?? 1;
223 | }
224 |
225 | return count > 0 ? count : null;
226 | }
227 |
228 | function formatGridTemplate(count: number): string {
229 | const n = clampInt(count, 1, GRID_DIMENSION_MAX);
230 | return n === 1 ? '1fr' : `repeat(${n}, 1fr)`;
231 | }
232 |
233 | // =============================================================================
234 | // SVG Icon Helpers
235 | // =============================================================================
236 |
237 | function createBaseIconSvg(): SVGSVGElement {
238 | const svg = document.createElementNS(SVG_NS, 'svg');
239 | svg.setAttribute('viewBox', '0 0 15 15');
240 | svg.setAttribute('fill', 'none');
241 | svg.setAttribute('aria-hidden', 'true');
242 | svg.setAttribute('focusable', 'false');
243 | return svg;
244 | }
245 |
246 | function applyStroke(el: SVGElement, strokeWidth = '1.2'): void {
247 | el.setAttribute('stroke', 'currentColor');
248 | el.setAttribute('stroke-width', strokeWidth);
249 | el.setAttribute('stroke-linecap', 'round');
250 | el.setAttribute('stroke-linejoin', 'round');
251 | }
252 |
253 | function createDisplayIcon(value: DisplayValue): SVGElement {
254 | const svg = createBaseIconSvg();
255 |
256 | // 容器边框(虚线矩形表示容器)
257 | const container = document.createElementNS(SVG_NS, 'rect');
258 | container.setAttribute('x', '2');
259 | container.setAttribute('y', '2');
260 | container.setAttribute('width', '11');
261 | container.setAttribute('height', '11');
262 | container.setAttribute('rx', '1.5');
263 | container.setAttribute('stroke', 'currentColor');
264 | container.setAttribute('stroke-width', '1');
265 | container.setAttribute('stroke-dasharray', '2 1');
266 | container.setAttribute('fill', 'none');
267 | container.setAttribute('opacity', '0.5');
268 |
269 | const addBlock = (x: number, y: number, w: number, h: number) => {
270 | const rect = document.createElementNS(SVG_NS, 'rect');
271 | rect.setAttribute('x', String(x));
272 | rect.setAttribute('y', String(y));
273 | rect.setAttribute('width', String(w));
274 | rect.setAttribute('height', String(h));
275 | rect.setAttribute('rx', '0.5');
276 | rect.setAttribute('fill', 'currentColor');
277 | svg.append(rect);
278 | };
279 |
280 | const addLine = (x: number, y: number, w: number) => {
281 | const line = document.createElementNS(SVG_NS, 'rect');
282 | line.setAttribute('x', String(x));
283 | line.setAttribute('y', String(y));
284 | line.setAttribute('width', String(w));
285 | line.setAttribute('height', '1');
286 | line.setAttribute('rx', '0.5');
287 | line.setAttribute('fill', 'currentColor');
288 | svg.append(line);
289 | };
290 |
291 | switch (value) {
292 | case 'block':
293 | // 两个全宽的块级元素,垂直堆叠
294 | addBlock(3.5, 3.5, 8, 3);
295 | addBlock(3.5, 8.5, 8, 3);
296 | break;
297 | case 'inline':
298 | // 三行文本表示内联流
299 | addLine(3.5, 4.5, 8);
300 | addLine(3.5, 7.5, 5);
301 | addLine(3.5, 10.5, 6.5);
302 | break;
303 | case 'inline-block':
304 | // 左边一个块,右边两行文本
305 | addBlock(3.5, 4.5, 3.5, 6);
306 | addLine(8, 5.5, 4);
307 | addLine(8, 8.5, 3);
308 | break;
309 | case 'flex':
310 | // 三个水平排列的弹性子项
311 | addBlock(3.5, 4.5, 2.5, 6);
312 | addBlock(6.5, 4.5, 2.5, 6);
313 | addBlock(9.5, 4.5, 2.5, 6);
314 | break;
315 | case 'grid':
316 | // 2x2 网格布局
317 | addBlock(3.5, 3.5, 3.5, 3.5);
318 | addBlock(8, 3.5, 3.5, 3.5);
319 | addBlock(3.5, 8, 3.5, 3.5);
320 | addBlock(8, 8, 3.5, 3.5);
321 | break;
322 | case 'none': {
323 | // 禁用符号:斜线
324 | const slash = document.createElementNS(SVG_NS, 'path');
325 | slash.setAttribute('d', 'M4 11L11 4');
326 | slash.setAttribute('stroke', 'currentColor');
327 | slash.setAttribute('stroke-width', '1.5');
328 | slash.setAttribute('stroke-linecap', 'round');
329 | svg.append(slash);
330 | break;
331 | }
332 | }
333 |
334 | svg.prepend(container);
335 | return svg;
336 | }
337 |
338 | function createFlowIcon(direction: FlexDirectionValue): SVGElement {
339 | const svg = createBaseIconSvg();
340 | const path = document.createElementNS(SVG_NS, 'path');
341 | applyStroke(path, '1.5');
342 |
343 | const DIRECTION_PATHS: Record<FlexDirectionValue, string> = {
344 | row: 'M2 7.5H13M10 4.5L13 7.5L10 10.5',
345 | 'row-reverse': 'M13 7.5H2M5 4.5L2 7.5L5 10.5',
346 | column: 'M7.5 2V13M4.5 10L7.5 13L10.5 10',
347 | 'column-reverse': 'M7.5 13V2M4.5 5L7.5 2L10.5 5',
348 | };
349 |
350 | path.setAttribute('d', DIRECTION_PATHS[direction]);
351 | svg.append(path);
352 | return svg;
353 | }
354 |
355 | function createHorizontalAlignIcon(value: AlignmentAxisValue): SVGElement {
356 | const svg = createBaseIconSvg();
357 |
358 | // 容器边框(虚线矩形表示容器)
359 | const container = document.createElementNS(SVG_NS, 'rect');
360 | container.setAttribute('x', '2');
361 | container.setAttribute('y', '2');
362 | container.setAttribute('width', '11');
363 | container.setAttribute('height', '11');
364 | container.setAttribute('rx', '1.5');
365 | container.setAttribute('stroke', 'currentColor');
366 | container.setAttribute('stroke-width', '1');
367 | container.setAttribute('stroke-dasharray', '2 1');
368 | container.setAttribute('fill', 'none');
369 | container.setAttribute('opacity', '0.5');
370 |
371 | // 内容块的 X 坐标根据对齐方式不同
372 | const blockX: Record<AlignmentAxisValue, number> = {
373 | 'flex-start': 3.5, // 左对齐
374 | center: 5.5, // 居中对齐
375 | 'flex-end': 7.5, // 右对齐
376 | };
377 |
378 | // 两个小方块表示子元素(水平方向排列变为垂直方向排列)
379 | const block1 = document.createElementNS(SVG_NS, 'rect');
380 | block1.setAttribute('x', String(blockX[value]));
381 | block1.setAttribute('y', '4');
382 | block1.setAttribute('width', '4');
383 | block1.setAttribute('height', '3');
384 | block1.setAttribute('rx', '0.5');
385 | block1.setAttribute('fill', 'currentColor');
386 |
387 | const block2 = document.createElementNS(SVG_NS, 'rect');
388 | block2.setAttribute('x', String(blockX[value]));
389 | block2.setAttribute('y', '8');
390 | block2.setAttribute('width', '4');
391 | block2.setAttribute('height', '3');
392 | block2.setAttribute('rx', '0.5');
393 | block2.setAttribute('fill', 'currentColor');
394 |
395 | svg.append(container, block1, block2);
396 | return svg;
397 | }
398 |
399 | function createVerticalAlignIcon(value: AlignmentAxisValue): SVGElement {
400 | const svg = createBaseIconSvg();
401 |
402 | // 容器边框(虚线矩形表示容器)
403 | const container = document.createElementNS(SVG_NS, 'rect');
404 | container.setAttribute('x', '2');
405 | container.setAttribute('y', '2');
406 | container.setAttribute('width', '11');
407 | container.setAttribute('height', '11');
408 | container.setAttribute('rx', '1.5');
409 | container.setAttribute('stroke', 'currentColor');
410 | container.setAttribute('stroke-width', '1');
411 | container.setAttribute('stroke-dasharray', '2 1');
412 | container.setAttribute('fill', 'none');
413 | container.setAttribute('opacity', '0.5');
414 |
415 | // 内容块的 Y 坐标根据对齐方式不同
416 | const blockY: Record<AlignmentAxisValue, number> = {
417 | 'flex-start': 3.5, // 顶部对齐
418 | center: 5.5, // 居中对齐
419 | 'flex-end': 7.5, // 底部对齐
420 | };
421 |
422 | // 两个小方块表示子元素
423 | const block1 = document.createElementNS(SVG_NS, 'rect');
424 | block1.setAttribute('x', '4');
425 | block1.setAttribute('y', String(blockY[value]));
426 | block1.setAttribute('width', '3');
427 | block1.setAttribute('height', '4');
428 | block1.setAttribute('rx', '0.5');
429 | block1.setAttribute('fill', 'currentColor');
430 |
431 | const block2 = document.createElementNS(SVG_NS, 'rect');
432 | block2.setAttribute('x', '8');
433 | block2.setAttribute('y', String(blockY[value]));
434 | block2.setAttribute('width', '3');
435 | block2.setAttribute('height', '4');
436 | block2.setAttribute('rx', '0.5');
437 | block2.setAttribute('fill', 'currentColor');
438 |
439 | svg.append(container, block1, block2);
440 | return svg;
441 | }
442 |
443 | function createGapIcon(): SVGElement {
444 | const svg = createBaseIconSvg();
445 | const path = document.createElementNS(SVG_NS, 'path');
446 | path.setAttribute('stroke', 'currentColor');
447 | path.setAttribute('d', 'M1.5 4.5H13.5M1.5 10.5H13.5');
448 | svg.append(path);
449 | return svg;
450 | }
451 |
452 | function createGridColumnsIcon(): SVGElement {
453 | const svg = createBaseIconSvg();
454 |
455 | const r1 = document.createElementNS(SVG_NS, 'rect');
456 | r1.setAttribute('x', '3');
457 | r1.setAttribute('y', '4');
458 | r1.setAttribute('width', '3.5');
459 | r1.setAttribute('height', '7');
460 | r1.setAttribute('rx', '1');
461 | applyStroke(r1);
462 |
463 | const r2 = document.createElementNS(SVG_NS, 'rect');
464 | r2.setAttribute('x', '8.5');
465 | r2.setAttribute('y', '4');
466 | r2.setAttribute('width', '3.5');
467 | r2.setAttribute('height', '7');
468 | r2.setAttribute('rx', '1');
469 | applyStroke(r2);
470 |
471 | svg.append(r1, r2);
472 | return svg;
473 | }
474 |
475 | function createGridRowsIcon(): SVGElement {
476 | const svg = createBaseIconSvg();
477 |
478 | const r1 = document.createElementNS(SVG_NS, 'rect');
479 | r1.setAttribute('x', '4');
480 | r1.setAttribute('y', '3');
481 | r1.setAttribute('width', '7');
482 | r1.setAttribute('height', '3.5');
483 | r1.setAttribute('rx', '1');
484 | applyStroke(r1);
485 |
486 | const r2 = document.createElementNS(SVG_NS, 'rect');
487 | r2.setAttribute('x', '4');
488 | r2.setAttribute('y', '8.5');
489 | r2.setAttribute('width', '7');
490 | r2.setAttribute('height', '3.5');
491 | r2.setAttribute('rx', '1');
492 | applyStroke(r2);
493 |
494 | svg.append(r1, r2);
495 | return svg;
496 | }
497 |
498 | // =============================================================================
499 | // Factory
500 | // =============================================================================
501 |
502 | export interface LayoutControlOptions {
503 | container: HTMLElement;
504 | transactionManager: TransactionManager;
505 | }
506 |
507 | export function createLayoutControl(options: LayoutControlOptions): DesignControl {
508 | const { container, transactionManager } = options;
509 | const disposer = new Disposer();
510 |
511 | let currentTarget: Element | null = null;
512 |
513 | const root = document.createElement('div');
514 | root.className = 'we-field-group';
515 |
516 | // ---------------------------------------------------------------------------
517 | // Display row (icon button group)
518 | // ---------------------------------------------------------------------------
519 | const displayRow = document.createElement('div');
520 | displayRow.className = 'we-field';
521 |
522 | const displayLabel = document.createElement('span');
523 | displayLabel.className = 'we-field-label';
524 | displayLabel.textContent = 'Display';
525 |
526 | const displayMount = document.createElement('div');
527 | displayMount.className = 'we-field-content';
528 |
529 | displayRow.append(displayLabel, displayMount);
530 |
531 | const displayGroup = createIconButtonGroup<DisplayValue>({
532 | container: displayMount,
533 | ariaLabel: 'Display',
534 | columns: 6,
535 | items: DISPLAY_VALUES.map((v) => ({
536 | value: v,
537 | ariaLabel: v,
538 | title: v,
539 | icon: createDisplayIcon(v),
540 | })),
541 | onChange: (value) => {
542 | const handle = beginTransaction('display');
543 | if (handle) handle.set(value);
544 | commitTransaction('display');
545 | syncAllFields();
546 | },
547 | });
548 | disposer.add(() => displayGroup.dispose());
549 |
550 | // ---------------------------------------------------------------------------
551 | // Flex direction row (icon button group)
552 | // ---------------------------------------------------------------------------
553 | const directionRow = document.createElement('div');
554 | directionRow.className = 'we-field';
555 |
556 | const directionLabel = document.createElement('span');
557 | directionLabel.className = 'we-field-label';
558 | directionLabel.textContent = 'Flow';
559 |
560 | const directionMount = document.createElement('div');
561 | directionMount.className = 'we-field-content';
562 |
563 | directionRow.append(directionLabel, directionMount);
564 |
565 | const directionGroup = createIconButtonGroup<FlexDirectionValue>({
566 | container: directionMount,
567 | ariaLabel: 'Flex direction',
568 | columns: 4,
569 | items: FLEX_DIRECTION_VALUES.map((dir) => ({
570 | value: dir,
571 | ariaLabel: dir.replace('-', ' '),
572 | title: dir.replace('-', ' '),
573 | icon: createFlowIcon(dir),
574 | })),
575 | onChange: (value) => {
576 | const handle = beginTransaction('flex-direction');
577 | if (handle) handle.set(value);
578 | commitTransaction('flex-direction');
579 | syncAllFields();
580 | },
581 | });
582 | disposer.add(() => directionGroup.dispose());
583 | directionGroup.setValue(null);
584 |
585 | // ---------------------------------------------------------------------------
586 | // Flex wrap row (select)
587 | // ---------------------------------------------------------------------------
588 | const wrapRow = document.createElement('div');
589 | wrapRow.className = 'we-field';
590 | const wrapLabel = document.createElement('span');
591 | wrapLabel.className = 'we-field-label';
592 | wrapLabel.textContent = 'Wrap';
593 | const wrapSelect = document.createElement('select');
594 | wrapSelect.className = 'we-select';
595 | wrapSelect.setAttribute('aria-label', 'flex-wrap');
596 | for (const v of FLEX_WRAP_VALUES) {
597 | const opt = document.createElement('option');
598 | opt.value = v;
599 | opt.textContent = v;
600 | wrapSelect.append(opt);
601 | }
602 | wrapRow.append(wrapLabel, wrapSelect);
603 |
604 | // ---------------------------------------------------------------------------
605 | // Alignment row (content bars for justify-content + align-items)
606 | // ---------------------------------------------------------------------------
607 | const alignmentRow = document.createElement('div');
608 | alignmentRow.className = 'we-field';
609 |
610 | const alignmentLabel = document.createElement('span');
611 | alignmentLabel.className = 'we-field-label';
612 | alignmentLabel.textContent = 'Align';
613 |
614 | const alignmentMount = document.createElement('div');
615 | alignmentMount.className = 'we-field-content';
616 | alignmentMount.style.display = 'flex';
617 | alignmentMount.style.gap = '4px';
618 |
619 | alignmentRow.append(alignmentLabel, alignmentMount);
620 |
621 | // Justify group with H label
622 | const justifyWrapper = document.createElement('div');
623 | justifyWrapper.style.flex = '1';
624 | justifyWrapper.style.minWidth = '0';
625 | justifyWrapper.style.display = 'flex';
626 | justifyWrapper.style.flexDirection = 'column';
627 | justifyWrapper.style.gap = '2px';
628 |
629 | const justifyHint = document.createElement('span');
630 | justifyHint.className = 'we-field-hint';
631 | justifyHint.textContent = 'H';
632 |
633 | const justifyMount = document.createElement('div');
634 |
635 | justifyWrapper.append(justifyHint, justifyMount);
636 |
637 | // Align group with V label
638 | const alignWrapper = document.createElement('div');
639 | alignWrapper.style.flex = '1';
640 | alignWrapper.style.minWidth = '0';
641 | alignWrapper.style.display = 'flex';
642 | alignWrapper.style.flexDirection = 'column';
643 | alignWrapper.style.gap = '2px';
644 |
645 | const alignHint = document.createElement('span');
646 | alignHint.className = 'we-field-hint';
647 | alignHint.textContent = 'V';
648 |
649 | const alignMount = document.createElement('div');
650 |
651 | alignWrapper.append(alignHint, alignMount);
652 |
653 | alignmentMount.append(justifyWrapper, alignWrapper);
654 |
655 | const justifyGroup = createIconButtonGroup<AlignmentAxisValue>({
656 | container: justifyMount,
657 | ariaLabel: 'Justify content',
658 | columns: 3,
659 | items: ALIGNMENT_AXIS_VALUES.map((v) => ({
660 | value: v,
661 | ariaLabel: `justify-content: ${v}`,
662 | title: v,
663 | icon: createHorizontalAlignIcon(v),
664 | })),
665 | onChange: (justifyContent) => {
666 | const handle = beginAlignmentTransaction();
667 | if (!handle) return;
668 | const alignItems = alignGroup.getValue() ?? 'center';
669 | handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });
670 | commitAlignmentTransaction();
671 | syncAllFields();
672 | },
673 | });
674 |
675 | const alignGroup = createIconButtonGroup<AlignmentAxisValue>({
676 | container: alignMount,
677 | ariaLabel: 'Align items',
678 | columns: 3,
679 | items: ALIGNMENT_AXIS_VALUES.map((v) => ({
680 | value: v,
681 | ariaLabel: `align-items: ${v}`,
682 | title: v,
683 | icon: createVerticalAlignIcon(v),
684 | })),
685 | onChange: (alignItems) => {
686 | const handle = beginAlignmentTransaction();
687 | if (!handle) return;
688 | const justifyContent = justifyGroup.getValue() ?? 'center';
689 | handle.set({ 'justify-content': justifyContent, 'align-items': alignItems });
690 | commitAlignmentTransaction();
691 | syncAllFields();
692 | },
693 | });
694 |
695 | disposer.add(() => justifyGroup.dispose());
696 | disposer.add(() => alignGroup.dispose());
697 | justifyGroup.setValue(null);
698 | alignGroup.setValue(null);
699 |
700 | // ---------------------------------------------------------------------------
701 | // Grid dimensions row (grid-template-columns/rows)
702 | // ---------------------------------------------------------------------------
703 | const gridRow = document.createElement('div');
704 | gridRow.className = 'we-field';
705 |
706 | const gridLabel = document.createElement('span');
707 | gridLabel.className = 'we-field-label';
708 | gridLabel.textContent = 'Grid';
709 |
710 | const gridMount = document.createElement('div');
711 | gridMount.className = 'we-field-content';
712 | gridMount.style.position = 'relative';
713 |
714 | gridRow.append(gridLabel, gridMount);
715 |
716 | const gridPreviewButton = document.createElement('button');
717 | gridPreviewButton.type = 'button';
718 | gridPreviewButton.className = 'we-grid-dimensions-preview';
719 | gridPreviewButton.setAttribute('aria-label', 'Grid dimensions');
720 | gridPreviewButton.setAttribute('aria-expanded', 'false');
721 | gridPreviewButton.setAttribute('aria-haspopup', 'dialog');
722 |
723 | // Single line preview: cols × rows
724 | const gridPreviewColsValue = document.createElement('span');
725 | gridPreviewColsValue.textContent = '1';
726 | const gridPreviewTimes = document.createElement('span');
727 | gridPreviewTimes.textContent = ' × ';
728 | const gridPreviewRowsValue = document.createElement('span');
729 | gridPreviewRowsValue.textContent = '1';
730 |
731 | gridPreviewButton.append(gridPreviewColsValue, gridPreviewTimes, gridPreviewRowsValue);
732 |
733 | const gridPopover = document.createElement('div');
734 | gridPopover.className = 'we-grid-dimensions-popover';
735 | gridPopover.hidden = true;
736 |
737 | const gridInputs = document.createElement('div');
738 | gridInputs.className = 'we-grid-dimensions-inputs';
739 |
740 | const colsContainer = createInputContainer({
741 | ariaLabel: 'Grid columns',
742 | inputMode: 'numeric',
743 | prefix: createGridColumnsIcon(),
744 | suffix: null,
745 | });
746 | colsContainer.root.style.width = '72px';
747 | colsContainer.root.style.flex = '0 0 auto';
748 |
749 | const times = document.createElement('span');
750 | times.className = 'we-grid-dimensions-times';
751 | times.textContent = '×';
752 |
753 | const rowsContainer = createInputContainer({
754 | ariaLabel: 'Grid rows',
755 | inputMode: 'numeric',
756 | prefix: createGridRowsIcon(),
757 | suffix: null,
758 | });
759 | rowsContainer.root.style.width = '72px';
760 | rowsContainer.root.style.flex = '0 0 auto';
761 |
762 | gridInputs.append(colsContainer.root, times, rowsContainer.root);
763 |
764 | const matrix = document.createElement('div');
765 | matrix.className = 'we-grid-dimensions-matrix';
766 | matrix.setAttribute('role', 'grid');
767 |
768 | const cells: HTMLButtonElement[] = [];
769 | for (let r = 1; r <= GRID_DIMENSION_MAX; r++) {
770 | for (let c = 1; c <= GRID_DIMENSION_MAX; c++) {
771 | const cell = document.createElement('button');
772 | cell.type = 'button';
773 | cell.className = 'we-grid-dimensions-cell';
774 | cell.dataset.row = String(r);
775 | cell.dataset.col = String(c);
776 | cell.setAttribute('role', 'gridcell');
777 | cell.setAttribute('aria-label', `${c} × ${r}`);
778 | cells.push(cell);
779 | matrix.append(cell);
780 | }
781 | }
782 |
783 | const tooltip = document.createElement('div');
784 | tooltip.className = 'we-grid-dimensions-tooltip';
785 | tooltip.hidden = true;
786 |
787 | gridPopover.append(gridInputs, matrix, tooltip);
788 | gridMount.append(gridPreviewButton, gridPopover);
789 |
790 | wireNumberStepping(disposer, colsContainer.input, {
791 | mode: 'number',
792 | integer: true,
793 | min: 1,
794 | max: GRID_DIMENSION_MAX,
795 | });
796 | wireNumberStepping(disposer, rowsContainer.input, {
797 | mode: 'number',
798 | integer: true,
799 | min: 1,
800 | max: GRID_DIMENSION_MAX,
801 | });
802 |
803 | // ---------------------------------------------------------------------------
804 | // Gap row (row-gap and column-gap inputs) - vertical layout
805 | // ---------------------------------------------------------------------------
806 | const gapRow = document.createElement('div');
807 | gapRow.className = 'we-field';
808 | const gapLabel = document.createElement('span');
809 | gapLabel.className = 'we-field-label';
810 | gapLabel.textContent = 'Gap';
811 |
812 | const gapMount = document.createElement('div');
813 | gapMount.className = 'we-field-content';
814 |
815 | const gapInputs = document.createElement('div');
816 | gapInputs.className = 'we-grid-gap-inputs';
817 |
818 | const rowGapContainer = createInputContainer({
819 | ariaLabel: 'Row gap',
820 | inputMode: 'decimal',
821 | prefix: createGridRowsIcon(),
822 | suffix: 'px',
823 | });
824 |
825 | const columnGapContainer = createInputContainer({
826 | ariaLabel: 'Column gap',
827 | inputMode: 'decimal',
828 | prefix: createGridColumnsIcon(),
829 | suffix: 'px',
830 | });
831 |
832 | gapInputs.append(rowGapContainer.root, columnGapContainer.root);
833 | gapMount.append(gapInputs);
834 | gapRow.append(gapLabel, gapMount);
835 |
836 | wireNumberStepping(disposer, rowGapContainer.input, { mode: 'css-length' });
837 | wireNumberStepping(disposer, columnGapContainer.input, { mode: 'css-length' });
838 |
839 | // ---------------------------------------------------------------------------
840 | // Grid + Gap combined row (two columns when display=grid)
841 | // ---------------------------------------------------------------------------
842 | const gridGapRow = document.createElement('div');
843 | gridGapRow.className = 'we-grid-gap-row';
844 | gridGapRow.hidden = true;
845 |
846 | // Adjust gridRow and gapRow to fit in two-column layout
847 | gridRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--grid');
848 | gapRow.classList.add('we-grid-gap-col', 'we-grid-gap-col--gap');
849 |
850 | gridGapRow.append(gridRow, gapRow);
851 |
852 | // ---------------------------------------------------------------------------
853 | // Assemble DOM
854 | // ---------------------------------------------------------------------------
855 | root.append(displayRow, directionRow, wrapRow, alignmentRow, gridGapRow);
856 | container.append(root);
857 | disposer.add(() => root.remove());
858 |
859 | // ---------------------------------------------------------------------------
860 | // Field State Registry
861 | // ---------------------------------------------------------------------------
862 | const fields: Record<FieldKey, FieldState> = {
863 | display: {
864 | kind: 'display-group',
865 | property: 'display',
866 | group: displayGroup,
867 | handle: null,
868 | row: displayRow,
869 | },
870 | 'flex-direction': {
871 | kind: 'flex-direction-group',
872 | property: 'flex-direction',
873 | group: directionGroup,
874 | handle: null,
875 | row: directionRow,
876 | },
877 | 'flex-wrap': {
878 | kind: 'select',
879 | property: 'flex-wrap',
880 | element: wrapSelect,
881 | handle: null,
882 | row: wrapRow,
883 | },
884 | alignment: {
885 | kind: 'flex-alignment',
886 | properties: ['justify-content', 'align-items'] as const,
887 | justifyGroup,
888 | alignGroup,
889 | handle: null,
890 | row: alignmentRow,
891 | },
892 | 'grid-dimensions': {
893 | kind: 'grid-dimensions',
894 | properties: ['grid-template-columns', 'grid-template-rows'] as const,
895 | previewButton: gridPreviewButton,
896 | previewColsValue: gridPreviewColsValue,
897 | previewRowsValue: gridPreviewRowsValue,
898 | popover: gridPopover,
899 | colsContainer,
900 | rowsContainer,
901 | matrix,
902 | tooltip,
903 | cells,
904 | handle: null,
905 | row: gridRow,
906 | },
907 | 'row-gap': {
908 | kind: 'input',
909 | property: 'row-gap',
910 | element: rowGapContainer.input,
911 | container: rowGapContainer,
912 | handle: null,
913 | row: gapRow,
914 | },
915 | 'column-gap': {
916 | kind: 'input',
917 | property: 'column-gap',
918 | element: columnGapContainer.input,
919 | container: columnGapContainer,
920 | handle: null,
921 | row: gapRow,
922 | },
923 | };
924 |
925 | /** Single-property fields for iteration */
926 | const STYLE_PROPS: readonly LayoutProperty[] = [
927 | 'display',
928 | 'flex-direction',
929 | 'flex-wrap',
930 | 'row-gap',
931 | 'column-gap',
932 | ];
933 | /** All field keys for iteration */
934 | const FIELD_KEYS: readonly FieldKey[] = [
935 | 'display',
936 | 'flex-direction',
937 | 'flex-wrap',
938 | 'alignment',
939 | 'grid-dimensions',
940 | 'row-gap',
941 | 'column-gap',
942 | ];
943 |
944 | // ---------------------------------------------------------------------------
945 | // Transaction Management
946 | // ---------------------------------------------------------------------------
947 |
948 | function beginTransaction(property: LayoutProperty): StyleTransactionHandle | null {
949 | if (disposer.isDisposed) return null;
950 | const target = currentTarget;
951 | if (!target || !target.isConnected) return null;
952 |
953 | const field = fields[property];
954 | if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return null;
955 | if (field.handle) return field.handle;
956 |
957 | const handle = transactionManager.beginStyle(target, property);
958 | field.handle = handle;
959 | return handle;
960 | }
961 |
962 | function commitTransaction(property: LayoutProperty): void {
963 | const field = fields[property];
964 | if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;
965 | const handle = field.handle;
966 | field.handle = null;
967 | if (handle) handle.commit({ merge: true });
968 | }
969 |
970 | function rollbackTransaction(property: LayoutProperty): void {
971 | const field = fields[property];
972 | if (field.kind === 'flex-alignment' || field.kind === 'grid-dimensions') return;
973 | const handle = field.handle;
974 | field.handle = null;
975 | if (handle) handle.rollback();
976 | }
977 |
978 | function beginAlignmentTransaction(): MultiStyleTransactionHandle | null {
979 | if (disposer.isDisposed) return null;
980 | const target = currentTarget;
981 | if (!target || !target.isConnected) return null;
982 |
983 | const field = fields.alignment;
984 | if (field.kind !== 'flex-alignment') return null;
985 | if (field.handle) return field.handle;
986 |
987 | const handle = transactionManager.beginMultiStyle(target, [...field.properties]);
988 | field.handle = handle;
989 | return handle;
990 | }
991 |
992 | function commitAlignmentTransaction(): void {
993 | const field = fields.alignment;
994 | if (field.kind !== 'flex-alignment') return;
995 | const handle = field.handle;
996 | field.handle = null;
997 | handle?.commit({ merge: true });
998 | }
999 |
1000 | function beginGridTransaction(): MultiStyleTransactionHandle | null {
1001 | if (disposer.isDisposed) return null;
1002 | const target = currentTarget;
1003 | if (!target || !target.isConnected) return null;
1004 |
1005 | const field = fields['grid-dimensions'];
1006 | if (field.kind !== 'grid-dimensions') return null;
1007 | if (field.handle) return field.handle;
1008 |
1009 | const handle = transactionManager.beginMultiStyle(target, [...field.properties]);
1010 | field.handle = handle;
1011 | return handle;
1012 | }
1013 |
1014 | function commitGridTransaction(): void {
1015 | const field = fields['grid-dimensions'];
1016 | if (field.kind !== 'grid-dimensions') return;
1017 | const handle = field.handle;
1018 | field.handle = null;
1019 | handle?.commit({ merge: true });
1020 | }
1021 |
1022 | function rollbackGridTransaction(): void {
1023 | const field = fields['grid-dimensions'];
1024 | if (field.kind !== 'grid-dimensions') return;
1025 | const handle = field.handle;
1026 | field.handle = null;
1027 | handle?.rollback();
1028 | }
1029 |
1030 | function commitAllTransactions(): void {
1031 | for (const p of STYLE_PROPS) commitTransaction(p);
1032 | commitAlignmentTransaction();
1033 | commitGridTransaction();
1034 | }
1035 |
1036 | // ---------------------------------------------------------------------------
1037 | // Visibility Control
1038 | // ---------------------------------------------------------------------------
1039 |
1040 | function updateVisibility(): void {
1041 | const target = currentTarget;
1042 | const rawDisplay = target
1043 | ? readInlineValue(target, 'display') || readComputedValue(target, 'display')
1044 | : (displayGroup.getValue() ?? 'block');
1045 | const displayValue = normalizeDisplayValue(rawDisplay);
1046 |
1047 | const trimmed = displayValue.trim();
1048 | const isFlex = trimmed === 'flex' || trimmed === 'inline-flex';
1049 | const isGrid = trimmed === 'grid' || trimmed === 'inline-grid';
1050 | const isFlexOrGrid = isFlex || isGrid;
1051 |
1052 | directionRow.hidden = !isFlex;
1053 | wrapRow.hidden = !isFlex;
1054 | alignmentRow.hidden = !isFlex;
1055 |
1056 | // Grid + Gap row visibility
1057 | gridGapRow.hidden = !isFlexOrGrid;
1058 | gridRow.hidden = !isGrid;
1059 | gapRow.hidden = !isFlexOrGrid;
1060 | }
1061 |
1062 | // ---------------------------------------------------------------------------
1063 | // Field Synchronization
1064 | // ---------------------------------------------------------------------------
1065 |
1066 | function syncField(key: FieldKey, force = false): void {
1067 | const field = fields[key];
1068 | const target = currentTarget;
1069 |
1070 | // Handle display icon button group
1071 | if (field.kind === 'display-group') {
1072 | const group = field.group;
1073 |
1074 | if (!target || !target.isConnected) {
1075 | group.setDisabled(true);
1076 | group.setValue(null);
1077 | return;
1078 | }
1079 |
1080 | group.setDisabled(false);
1081 | const isEditing = field.handle !== null;
1082 | if (isEditing && !force) return;
1083 |
1084 | const inline = readInlineValue(target, 'display');
1085 | const computed = readComputedValue(target, 'display');
1086 | let raw = (inline || computed).trim();
1087 | raw = normalizeDisplayValue(raw);
1088 | group.setValue(isDisplayValue(raw) ? raw : 'block');
1089 | return;
1090 | }
1091 |
1092 | // Handle flex-direction icon button group
1093 | if (field.kind === 'flex-direction-group') {
1094 | const group = field.group;
1095 |
1096 | if (!target || !target.isConnected) {
1097 | group.setDisabled(true);
1098 | group.setValue(null);
1099 | return;
1100 | }
1101 |
1102 | group.setDisabled(false);
1103 | const isEditing = field.handle !== null;
1104 | if (isEditing && !force) return;
1105 |
1106 | const inline = readInlineValue(target, 'flex-direction');
1107 | const computed = readComputedValue(target, 'flex-direction');
1108 | const raw = (inline || computed).trim();
1109 | group.setValue(isFlexDirectionValue(raw) ? raw : null);
1110 | return;
1111 | }
1112 |
1113 | // Handle flex alignment (content bars)
1114 | if (field.kind === 'flex-alignment') {
1115 | if (!target || !target.isConnected) {
1116 | field.justifyGroup.setDisabled(true);
1117 | field.alignGroup.setDisabled(true);
1118 | field.justifyGroup.setValue(null);
1119 | field.alignGroup.setValue(null);
1120 | return;
1121 | }
1122 |
1123 | field.justifyGroup.setDisabled(false);
1124 | field.alignGroup.setDisabled(false);
1125 | const isEditing = field.handle !== null;
1126 | if (isEditing && !force) return;
1127 |
1128 | const justifyInline = readInlineValue(target, 'justify-content');
1129 | const justifyComputed = readComputedValue(target, 'justify-content');
1130 | const alignInline = readInlineValue(target, 'align-items');
1131 | const alignComputed = readComputedValue(target, 'align-items');
1132 |
1133 | const justifyRaw = (justifyInline || justifyComputed).trim();
1134 | const alignRaw = (alignInline || alignComputed).trim();
1135 |
1136 | if (isAlignmentAxisValue(justifyRaw) && isAlignmentAxisValue(alignRaw)) {
1137 | field.justifyGroup.setValue(justifyRaw);
1138 | field.alignGroup.setValue(alignRaw);
1139 | } else {
1140 | field.justifyGroup.setValue(null);
1141 | field.alignGroup.setValue(null);
1142 | }
1143 | return;
1144 | }
1145 |
1146 | // Handle grid dimensions (grid-template-columns/rows)
1147 | if (field.kind === 'grid-dimensions') {
1148 | const {
1149 | previewButton,
1150 | popover,
1151 | colsContainer,
1152 | rowsContainer,
1153 | tooltip,
1154 | cells: gridCells,
1155 | } = field;
1156 |
1157 | if (!target || !target.isConnected) {
1158 | previewButton.disabled = true;
1159 | field.previewColsValue.textContent = '—';
1160 | field.previewRowsValue.textContent = '—';
1161 | previewButton.setAttribute('aria-expanded', 'false');
1162 | popover.hidden = true;
1163 | colsContainer.input.disabled = true;
1164 | rowsContainer.input.disabled = true;
1165 | tooltip.hidden = true;
1166 | for (const cell of gridCells) {
1167 | cell.dataset.active = 'false';
1168 | cell.dataset.selected = 'false';
1169 | }
1170 | return;
1171 | }
1172 |
1173 | previewButton.disabled = false;
1174 | colsContainer.input.disabled = false;
1175 | rowsContainer.input.disabled = false;
1176 |
1177 | const isEditing =
1178 | field.handle !== null ||
1179 | isFieldFocused(colsContainer.input) ||
1180 | isFieldFocused(rowsContainer.input);
1181 | if (isEditing && !force) return;
1182 |
1183 | const colsRaw =
1184 | readInlineValue(target, 'grid-template-columns') ||
1185 | readComputedValue(target, 'grid-template-columns');
1186 | const rowsRaw =
1187 | readInlineValue(target, 'grid-template-rows') ||
1188 | readComputedValue(target, 'grid-template-rows');
1189 |
1190 | const cols = clampInt(countGridTracks(colsRaw) ?? 1, 1, GRID_DIMENSION_MAX);
1191 | const rows = clampInt(countGridTracks(rowsRaw) ?? 1, 1, GRID_DIMENSION_MAX);
1192 |
1193 | colsContainer.input.value = String(cols);
1194 | rowsContainer.input.value = String(rows);
1195 | field.previewColsValue.textContent = String(cols);
1196 | field.previewRowsValue.textContent = String(rows);
1197 | // Update aria-label for screen readers
1198 | previewButton.setAttribute('aria-label', `Grid: ${cols} columns, ${rows} rows`);
1199 |
1200 | // Default rendering uses current values
1201 | tooltip.hidden = true;
1202 | for (const cell of gridCells) {
1203 | const c = parseInt(cell.dataset.col ?? '0', 10);
1204 | const r = parseInt(cell.dataset.row ?? '0', 10);
1205 | const selected = c > 0 && r > 0 && c <= cols && r <= rows;
1206 | cell.dataset.selected = selected ? 'true' : 'false';
1207 | cell.dataset.active = selected ? 'true' : 'false';
1208 | }
1209 | return;
1210 | }
1211 |
1212 | // Handle input field (row-gap / column-gap)
1213 | if (field.kind === 'input') {
1214 | const input = field.element;
1215 |
1216 | if (!target || !target.isConnected) {
1217 | input.disabled = true;
1218 | input.value = '';
1219 | input.placeholder = '';
1220 | field.container.setSuffix('px');
1221 | return;
1222 | }
1223 |
1224 | input.disabled = false;
1225 | const isEditing = field.handle !== null || isFieldFocused(input);
1226 | if (isEditing && !force) return;
1227 |
1228 | const inlineValue = readInlineValue(target, field.property);
1229 | const displayValue = inlineValue || readComputedValue(target, field.property);
1230 | const formatted = formatLengthForDisplay(displayValue);
1231 | input.value = formatted.value;
1232 | field.container.setSuffix(formatted.suffix);
1233 | input.placeholder = '';
1234 | return;
1235 | }
1236 |
1237 | // Handle select field (flex-wrap)
1238 | if (field.kind === 'select') {
1239 | const select = field.element;
1240 |
1241 | if (!target || !target.isConnected) {
1242 | select.disabled = true;
1243 | return;
1244 | }
1245 |
1246 | select.disabled = false;
1247 | const isEditing = field.handle !== null || isFieldFocused(select);
1248 | if (isEditing && !force) return;
1249 |
1250 | const inline = readInlineValue(target, field.property);
1251 | const computed = readComputedValue(target, field.property);
1252 | const val = inline || computed;
1253 |
1254 | const hasOption = Array.from(select.options).some((o) => o.value === val);
1255 | select.value = hasOption ? val : (select.options[0]?.value ?? '');
1256 | }
1257 | }
1258 |
1259 | function syncAllFields(): void {
1260 | for (const key of FIELD_KEYS) syncField(key);
1261 | updateVisibility();
1262 | }
1263 |
1264 | // ---------------------------------------------------------------------------
1265 | // Event Wiring
1266 | // ---------------------------------------------------------------------------
1267 |
1268 | function wireSelect(property: 'flex-wrap'): void {
1269 | const field = fields[property];
1270 | if (field.kind !== 'select') return;
1271 | const select = field.element;
1272 |
1273 | const preview = () => {
1274 | const handle = beginTransaction(property);
1275 | if (handle) handle.set(select.value);
1276 | };
1277 |
1278 | disposer.listen(select, 'input', preview);
1279 | disposer.listen(select, 'change', preview);
1280 | disposer.listen(select, 'blur', () => {
1281 | commitTransaction(property);
1282 | syncAllFields();
1283 | });
1284 |
1285 | disposer.listen(select, 'keydown', (e: KeyboardEvent) => {
1286 | if (e.key === 'Enter') {
1287 | e.preventDefault();
1288 | commitTransaction(property);
1289 | syncAllFields();
1290 | select.blur();
1291 | } else if (e.key === 'Escape') {
1292 | e.preventDefault();
1293 | rollbackTransaction(property);
1294 | syncField(property, true);
1295 | }
1296 | });
1297 | }
1298 |
1299 | function wireInput(property: 'row-gap' | 'column-gap'): void {
1300 | const field = fields[property];
1301 | if (field.kind !== 'input') return;
1302 | const input = field.element;
1303 |
1304 | disposer.listen(input, 'input', () => {
1305 | const handle = beginTransaction(property);
1306 | if (!handle) return;
1307 | const suffix = field.container.getSuffixText();
1308 | handle.set(combineLengthValue(input.value, suffix));
1309 | });
1310 |
1311 | disposer.listen(input, 'blur', () => {
1312 | commitTransaction(property);
1313 | syncAllFields();
1314 | });
1315 |
1316 | disposer.listen(input, 'keydown', (e: KeyboardEvent) => {
1317 | if (e.key === 'Enter') {
1318 | e.preventDefault();
1319 | commitTransaction(property);
1320 | syncAllFields();
1321 | input.blur();
1322 | } else if (e.key === 'Escape') {
1323 | e.preventDefault();
1324 | rollbackTransaction(property);
1325 | syncField(property, true);
1326 | }
1327 | });
1328 | }
1329 |
1330 | wireSelect('flex-wrap');
1331 | wireInput('row-gap');
1332 | wireInput('column-gap');
1333 |
1334 | // ---------------------------------------------------------------------------
1335 | // Grid Dimensions Picker Wiring
1336 | // ---------------------------------------------------------------------------
1337 |
1338 | let gridHoverCols: number | null = null;
1339 | let gridHoverRows: number | null = null;
1340 |
1341 | function renderGridSelection(field: GridDimensionsFieldState, cols: number, rows: number): void {
1342 | const activeCols = gridHoverCols ?? cols;
1343 | const activeRows = gridHoverRows ?? rows;
1344 |
1345 | for (const cell of field.cells) {
1346 | const c = parseInt(cell.dataset.col ?? '0', 10);
1347 | const r = parseInt(cell.dataset.row ?? '0', 10);
1348 | const selected = c > 0 && r > 0 && c <= cols && r <= rows;
1349 | const active = c > 0 && r > 0 && c <= activeCols && r <= activeRows;
1350 | cell.dataset.selected = selected ? 'true' : 'false';
1351 | cell.dataset.active = active ? 'true' : 'false';
1352 | }
1353 |
1354 | if (gridHoverCols !== null && gridHoverRows !== null) {
1355 | field.tooltip.textContent = `${gridHoverCols} × ${gridHoverRows}`;
1356 | field.tooltip.hidden = false;
1357 | } else {
1358 | field.tooltip.hidden = true;
1359 | }
1360 | }
1361 |
1362 | function setGridPopoverOpen(field: GridDimensionsFieldState, open: boolean): void {
1363 | field.popover.hidden = !open;
1364 | field.previewButton.setAttribute('aria-expanded', open ? 'true' : 'false');
1365 |
1366 | // Reset hover when opening/closing
1367 | gridHoverCols = null;
1368 | gridHoverRows = null;
1369 |
1370 | const cols = clampInt(
1371 | parseInt(field.colsContainer.input.value || '1', 10) || 1,
1372 | 1,
1373 | GRID_DIMENSION_MAX,
1374 | );
1375 | const rows = clampInt(
1376 | parseInt(field.rowsContainer.input.value || '1', 10) || 1,
1377 | 1,
1378 | GRID_DIMENSION_MAX,
1379 | );
1380 | renderGridSelection(field, cols, rows);
1381 | }
1382 |
1383 | function previewGridDimensions(cols: number, rows: number): void {
1384 | const handle = beginGridTransaction();
1385 | if (!handle) return;
1386 | handle.set({
1387 | 'grid-template-columns': formatGridTemplate(cols),
1388 | 'grid-template-rows': formatGridTemplate(rows),
1389 | });
1390 | }
1391 |
1392 | const gridField = fields['grid-dimensions'];
1393 | if (gridField.kind === 'grid-dimensions') {
1394 | disposer.listen(gridField.previewButton, 'click', (e: MouseEvent) => {
1395 | e.preventDefault();
1396 | setGridPopoverOpen(gridField, gridField.popover.hidden);
1397 | if (!gridField.popover.hidden) {
1398 | gridField.colsContainer.input.focus();
1399 | gridField.colsContainer.input.select();
1400 | }
1401 | });
1402 |
1403 | // Close popover when clicking outside
1404 | // Use capture phase to catch clicks in Shadow DOM
1405 | const rootNode = gridField.previewButton.getRootNode() as Document | ShadowRoot;
1406 | const clickTarget = rootNode instanceof ShadowRoot ? rootNode : document;
1407 |
1408 | const handleOutsideClick = (e: Event): void => {
1409 | if (gridField.kind !== 'grid-dimensions') return;
1410 | if (gridField.popover.hidden) return;
1411 | const target = e.target as Node;
1412 | // Check if click is inside gridRow (which contains both button and popover)
1413 | if (!gridRow.contains(target)) {
1414 | setGridPopoverOpen(gridField, false);
1415 | }
1416 | };
1417 |
1418 | clickTarget.addEventListener('click', handleOutsideClick, true);
1419 | disposer.add(() => {
1420 | clickTarget.removeEventListener('click', handleOutsideClick, true);
1421 | });
1422 |
1423 | // Inputs: live preview, blur commit, ESC rollback
1424 | const wireGridInput = (input: HTMLInputElement) => {
1425 | disposer.listen(input, 'input', () => {
1426 | const cols = clampInt(
1427 | parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1428 | 1,
1429 | GRID_DIMENSION_MAX,
1430 | );
1431 | const rows = clampInt(
1432 | parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1433 | 1,
1434 | GRID_DIMENSION_MAX,
1435 | );
1436 | renderGridSelection(gridField, cols, rows);
1437 | previewGridDimensions(cols, rows);
1438 | });
1439 |
1440 | disposer.listen(input, 'blur', () => {
1441 | commitGridTransaction();
1442 | syncAllFields();
1443 | });
1444 |
1445 | disposer.listen(input, 'keydown', (ev: KeyboardEvent) => {
1446 | if (ev.key === 'Enter') {
1447 | ev.preventDefault();
1448 | commitGridTransaction();
1449 | syncAllFields();
1450 | return;
1451 | }
1452 | if (ev.key === 'Escape') {
1453 | ev.preventDefault();
1454 | rollbackGridTransaction();
1455 | setGridPopoverOpen(gridField, false);
1456 | syncField('grid-dimensions', true);
1457 | }
1458 | });
1459 | };
1460 |
1461 | wireGridInput(gridField.colsContainer.input);
1462 | wireGridInput(gridField.rowsContainer.input);
1463 |
1464 | // Matrix hover + click select
1465 | disposer.listen(gridField.matrix, 'mouseover', (e: MouseEvent) => {
1466 | const el = e.target as HTMLElement;
1467 | const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;
1468 | if (!cell) return;
1469 | gridHoverCols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1470 | gridHoverRows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1471 | const cols = clampInt(
1472 | parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1473 | 1,
1474 | GRID_DIMENSION_MAX,
1475 | );
1476 | const rows = clampInt(
1477 | parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1478 | 1,
1479 | GRID_DIMENSION_MAX,
1480 | );
1481 | renderGridSelection(gridField, cols, rows);
1482 | });
1483 |
1484 | disposer.listen(gridField.matrix, 'mouseleave', () => {
1485 | gridHoverCols = null;
1486 | gridHoverRows = null;
1487 | const cols = clampInt(
1488 | parseInt(gridField.colsContainer.input.value || '1', 10) || 1,
1489 | 1,
1490 | GRID_DIMENSION_MAX,
1491 | );
1492 | const rows = clampInt(
1493 | parseInt(gridField.rowsContainer.input.value || '1', 10) || 1,
1494 | 1,
1495 | GRID_DIMENSION_MAX,
1496 | );
1497 | renderGridSelection(gridField, cols, rows);
1498 | });
1499 |
1500 | disposer.listen(gridField.matrix, 'click', (e: MouseEvent) => {
1501 | const el = e.target as HTMLElement;
1502 | const cell = el.closest('.we-grid-dimensions-cell') as HTMLButtonElement | null;
1503 | if (!cell) return;
1504 | const cols = clampInt(parseInt(cell.dataset.col ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1505 | const rows = clampInt(parseInt(cell.dataset.row ?? '1', 10) || 1, 1, GRID_DIMENSION_MAX);
1506 | gridField.colsContainer.input.value = String(cols);
1507 | gridField.rowsContainer.input.value = String(rows);
1508 | previewGridDimensions(cols, rows);
1509 | commitGridTransaction();
1510 | setGridPopoverOpen(gridField, false);
1511 | syncAllFields();
1512 | });
1513 | }
1514 |
1515 | function setTarget(element: Element | null): void {
1516 | if (disposer.isDisposed) return;
1517 | if (element !== currentTarget) commitAllTransactions();
1518 | currentTarget = element;
1519 | syncAllFields();
1520 | }
1521 |
1522 | function refresh(): void {
1523 | if (disposer.isDisposed) return;
1524 | syncAllFields();
1525 | }
1526 |
1527 | function dispose(): void {
1528 | commitAllTransactions();
1529 | currentTarget = null;
1530 | disposer.dispose();
1531 | }
1532 |
1533 | syncAllFields();
1534 |
1535 | return { setTarget, refresh, dispose };
1536 | }
1537 |
```
--------------------------------------------------------------------------------
/app/chrome-extension/entrypoints/background/tools/browser/computer.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { createErrorResponse, ToolResult } from '@/common/tool-handler';
2 | import { BaseBrowserToolExecutor } from '../base-browser';
3 | import { TOOL_NAMES } from 'chrome-mcp-shared';
4 | import { ERROR_MESSAGES, TIMEOUTS } from '@/common/constants';
5 | import { TOOL_MESSAGE_TYPES } from '@/common/message-types';
6 | import { clickTool, fillTool } from './interaction';
7 | import { keyboardTool } from './keyboard';
8 | import { screenshotTool } from './screenshot';
9 | import { screenshotContextManager, scaleCoordinates } from '@/utils/screenshot-context';
10 | import { cdpSessionManager } from '@/utils/cdp-session-manager';
11 | import {
12 | captureFrameOnAction,
13 | isAutoCaptureActive,
14 | type ActionMetadata,
15 | type ActionType,
16 | } from './gif-recorder';
17 |
18 | type MouseButton = 'left' | 'right' | 'middle';
19 |
20 | interface Coordinates {
21 | x: number;
22 | y: number;
23 | }
24 |
25 | interface ZoomRegion {
26 | x0: number;
27 | y0: number;
28 | x1: number;
29 | y1: number;
30 | }
31 |
32 | interface Modifiers {
33 | altKey?: boolean;
34 | ctrlKey?: boolean;
35 | metaKey?: boolean;
36 | shiftKey?: boolean;
37 | }
38 |
39 | interface ComputerParams {
40 | action:
41 | | 'left_click'
42 | | 'right_click'
43 | | 'double_click'
44 | | 'triple_click'
45 | | 'left_click_drag'
46 | | 'scroll'
47 | | 'type'
48 | | 'key'
49 | | 'hover'
50 | | 'wait'
51 | | 'fill'
52 | | 'fill_form'
53 | | 'resize_page'
54 | | 'scroll_to'
55 | | 'zoom'
56 | | 'screenshot';
57 | // click/scroll coordinates in screenshot space (if screenshot context exists) or viewport space
58 | coordinates?: Coordinates; // for click/scroll; for drag, this is endCoordinates
59 | startCoordinates?: Coordinates; // for drag start
60 | // Optional element refs (from chrome_read_page) as alternative to coordinates
61 | ref?: string; // click target or drag end
62 | startRef?: string; // drag start
63 | scrollDirection?: 'up' | 'down' | 'left' | 'right';
64 | scrollAmount?: number;
65 | text?: string; // for type/key
66 | repeat?: number; // for key action (1-100)
67 | modifiers?: Modifiers; // for click actions
68 | region?: ZoomRegion; // for zoom action
69 | duration?: number; // seconds for wait
70 | // For fill
71 | selector?: string;
72 | selectorType?: 'css' | 'xpath'; // Type of selector (default: 'css')
73 | value?: string;
74 | frameId?: number; // Target frame for selector/ref resolution
75 | tabId?: number; // target existing tab id
76 | windowId?: number;
77 | background?: boolean; // avoid focusing/activating
78 | }
79 |
80 | // Minimal CDP helper encapsulated here to avoid scattering CDP code
81 | class CDPHelper {
82 | static async attach(tabId: number): Promise<void> {
83 | await cdpSessionManager.attach(tabId, 'computer');
84 | }
85 |
86 | static async detach(tabId: number): Promise<void> {
87 | await cdpSessionManager.detach(tabId, 'computer');
88 | }
89 |
90 | static async send(tabId: number, method: string, params?: object): Promise<any> {
91 | return await cdpSessionManager.sendCommand(tabId, method, params);
92 | }
93 |
94 | static async dispatchMouseEvent(tabId: number, opts: any) {
95 | const params: any = {
96 | type: opts.type,
97 | x: Math.round(opts.x),
98 | y: Math.round(opts.y),
99 | modifiers: opts.modifiers || 0,
100 | };
101 | if (
102 | opts.type === 'mousePressed' ||
103 | opts.type === 'mouseReleased' ||
104 | opts.type === 'mouseMoved'
105 | ) {
106 | params.button = opts.button || 'none';
107 | if (opts.type === 'mousePressed' || opts.type === 'mouseReleased') {
108 | params.clickCount = opts.clickCount || 1;
109 | }
110 | // Per CDP: buttons is ignored for mouseWheel
111 | params.buttons = opts.buttons !== undefined ? opts.buttons : 0;
112 | }
113 | if (opts.type === 'mouseWheel') {
114 | params.deltaX = opts.deltaX || 0;
115 | params.deltaY = opts.deltaY || 0;
116 | }
117 | await this.send(tabId, 'Input.dispatchMouseEvent', params);
118 | }
119 |
120 | static async insertText(tabId: number, text: string) {
121 | await this.send(tabId, 'Input.insertText', { text });
122 | }
123 |
124 | static modifierMask(mods: string[]): number {
125 | const map: Record<string, number> = {
126 | alt: 1,
127 | ctrl: 2,
128 | control: 2,
129 | meta: 4,
130 | cmd: 4,
131 | command: 4,
132 | win: 4,
133 | windows: 4,
134 | shift: 8,
135 | };
136 | let mask = 0;
137 | for (const m of mods) mask |= map[m] || 0;
138 | return mask;
139 | }
140 |
141 | // Enhanced key mapping for common non-character keys
142 | private static KEY_ALIASES: Record<string, { key: string; code?: string; text?: string }> = {
143 | enter: { key: 'Enter', code: 'Enter' },
144 | return: { key: 'Enter', code: 'Enter' },
145 | backspace: { key: 'Backspace', code: 'Backspace' },
146 | delete: { key: 'Delete', code: 'Delete' },
147 | tab: { key: 'Tab', code: 'Tab' },
148 | escape: { key: 'Escape', code: 'Escape' },
149 | esc: { key: 'Escape', code: 'Escape' },
150 | space: { key: ' ', code: 'Space', text: ' ' },
151 | pageup: { key: 'PageUp', code: 'PageUp' },
152 | pagedown: { key: 'PageDown', code: 'PageDown' },
153 | home: { key: 'Home', code: 'Home' },
154 | end: { key: 'End', code: 'End' },
155 | arrowup: { key: 'ArrowUp', code: 'ArrowUp' },
156 | arrowdown: { key: 'ArrowDown', code: 'ArrowDown' },
157 | arrowleft: { key: 'ArrowLeft', code: 'ArrowLeft' },
158 | arrowright: { key: 'ArrowRight', code: 'ArrowRight' },
159 | };
160 |
161 | private static resolveKeyDef(token: string): { key: string; code?: string; text?: string } {
162 | const t = (token || '').toLowerCase();
163 | if (this.KEY_ALIASES[t]) return this.KEY_ALIASES[t];
164 | if (/^f([1-9]|1[0-2])$/.test(t)) {
165 | return { key: t.toUpperCase(), code: t.toUpperCase() };
166 | }
167 | if (t.length === 1) {
168 | const upper = t.toUpperCase();
169 | return { key: upper, code: `Key${upper}`, text: t };
170 | }
171 | return { key: token };
172 | }
173 |
174 | static async dispatchSimpleKey(tabId: number, token: string) {
175 | const def = this.resolveKeyDef(token);
176 | if (def.text && def.text.length === 1) {
177 | await this.insertText(tabId, def.text);
178 | return;
179 | }
180 | await this.send(tabId, 'Input.dispatchKeyEvent', {
181 | type: 'rawKeyDown',
182 | key: def.key,
183 | code: def.code,
184 | });
185 | await this.send(tabId, 'Input.dispatchKeyEvent', {
186 | type: 'keyUp',
187 | key: def.key,
188 | code: def.code,
189 | });
190 | }
191 |
192 | static async dispatchKeyChord(tabId: number, chord: string) {
193 | const parts = chord.split('+');
194 | const modifiers: string[] = [];
195 | let keyToken = '';
196 | for (const pRaw of parts) {
197 | const p = pRaw.trim().toLowerCase();
198 | if (
199 | ['ctrl', 'control', 'alt', 'shift', 'cmd', 'meta', 'command', 'win', 'windows'].includes(p)
200 | )
201 | modifiers.push(p);
202 | else keyToken = pRaw.trim();
203 | }
204 | const mask = this.modifierMask(modifiers);
205 | const def = this.resolveKeyDef(keyToken);
206 | await this.send(tabId, 'Input.dispatchKeyEvent', {
207 | type: 'rawKeyDown',
208 | key: def.key,
209 | code: def.code,
210 | text: def.text,
211 | modifiers: mask,
212 | });
213 | await this.send(tabId, 'Input.dispatchKeyEvent', {
214 | type: 'keyUp',
215 | key: def.key,
216 | code: def.code,
217 | modifiers: mask,
218 | });
219 | }
220 | }
221 |
222 | class ComputerTool extends BaseBrowserToolExecutor {
223 | name = TOOL_NAMES.BROWSER.COMPUTER;
224 |
225 | async execute(args: ComputerParams): Promise<ToolResult> {
226 | const params = args || ({} as ComputerParams);
227 | if (!params.action) return createErrorResponse('Action parameter is required');
228 |
229 | try {
230 | const explicit = await this.tryGetTab(args.tabId);
231 | const tab = explicit || (await this.getActiveTabOrThrowInWindow(args.windowId));
232 | if (!tab.id)
233 | return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
234 |
235 | // Execute the action and capture frame on success
236 | const result = await this.executeAction(params, tab);
237 |
238 | // Trigger auto-capture on successful actions (except screenshot which is read-only)
239 | if (!result.isError && params.action !== 'screenshot' && params.action !== 'wait') {
240 | const actionType = this.mapActionToCapture(params.action);
241 | if (actionType) {
242 | // Convert to viewport-space coordinates for GIF overlays
243 | // params.coordinates may be screenshot-space when screenshot context exists
244 | const ctx = screenshotContextManager.getContext(tab.id);
245 | const toViewport = (c?: Coordinates): { x: number; y: number } | undefined => {
246 | if (!c) return undefined;
247 | if (!ctx) return { x: c.x, y: c.y };
248 | const scaled = scaleCoordinates(c.x, c.y, ctx);
249 | return { x: scaled.x, y: scaled.y };
250 | };
251 |
252 | const endCoords = toViewport(params.coordinates);
253 | const startCoords = toViewport(params.startCoordinates);
254 |
255 | await this.triggerAutoCapture(tab.id, actionType, {
256 | coordinateSpace: 'viewport',
257 | coordinates: endCoords,
258 | startCoordinates: startCoords,
259 | endCoordinates: actionType === 'drag' ? endCoords : undefined,
260 | text: params.text,
261 | ref: params.ref,
262 | });
263 | }
264 | }
265 |
266 | return result;
267 | } catch (error) {
268 | console.error('Error in computer tool:', error);
269 | return createErrorResponse(
270 | `Failed to execute action: ${error instanceof Error ? error.message : String(error)}`,
271 | );
272 | }
273 | }
274 |
275 | private mapActionToCapture(action: string): ActionType | null {
276 | const mapping: Record<string, ActionType> = {
277 | left_click: 'click',
278 | right_click: 'right_click',
279 | double_click: 'double_click',
280 | triple_click: 'triple_click',
281 | left_click_drag: 'drag',
282 | scroll: 'scroll',
283 | type: 'type',
284 | key: 'key',
285 | hover: 'hover',
286 | fill: 'fill',
287 | fill_form: 'fill',
288 | resize_page: 'other',
289 | scroll_to: 'scroll',
290 | zoom: 'other',
291 | };
292 | return mapping[action] || null;
293 | }
294 |
295 | private async executeAction(params: ComputerParams, tab: chrome.tabs.Tab): Promise<ToolResult> {
296 | if (!tab.id) {
297 | return createErrorResponse(ERROR_MESSAGES.TAB_NOT_FOUND + ': Active tab has no ID');
298 | }
299 |
300 | // Helper to project coordinates using screenshot context when available
301 | const project = (c?: Coordinates): Coordinates | undefined => {
302 | if (!c) return undefined;
303 | const ctx = screenshotContextManager.getContext(tab.id!);
304 | if (!ctx) return c;
305 | const scaled = scaleCoordinates(c.x, c.y, ctx);
306 | return { x: scaled.x, y: scaled.y };
307 | };
308 |
309 | switch (params.action) {
310 | case 'resize_page': {
311 | const width = Number((params as any).coordinates?.x || (params as any).text);
312 | const height = Number((params as any).coordinates?.y || (params as any).value);
313 | const w = Number((params as any).width ?? width);
314 | const h = Number((params as any).height ?? height);
315 | if (!Number.isFinite(w) || !Number.isFinite(h) || w <= 0 || h <= 0) {
316 | return createErrorResponse('Provide width and height for resize_page (positive numbers)');
317 | }
318 | try {
319 | // Prefer precise CDP emulation
320 | await CDPHelper.attach(tab.id);
321 | try {
322 | await CDPHelper.send(tab.id, 'Emulation.setDeviceMetricsOverride', {
323 | width: Math.round(w),
324 | height: Math.round(h),
325 | deviceScaleFactor: 0,
326 | mobile: false,
327 | screenWidth: Math.round(w),
328 | screenHeight: Math.round(h),
329 | });
330 | } finally {
331 | await CDPHelper.detach(tab.id);
332 | }
333 | } catch (e) {
334 | // Fallback: window resize
335 | if (tab.windowId !== undefined) {
336 | await chrome.windows.update(tab.windowId, {
337 | width: Math.round(w),
338 | height: Math.round(h),
339 | });
340 | } else {
341 | return createErrorResponse(
342 | `Failed to resize via CDP and cannot determine windowId: ${e instanceof Error ? e.message : String(e)}`,
343 | );
344 | }
345 | }
346 | return {
347 | content: [
348 | {
349 | type: 'text',
350 | text: JSON.stringify({ success: true, action: 'resize_page', width: w, height: h }),
351 | },
352 | ],
353 | isError: false,
354 | };
355 | }
356 | case 'hover': {
357 | // Resolve target point from ref | selector | coordinates
358 | let coord: Coordinates | undefined = undefined;
359 | let resolvedBy: 'ref' | 'selector' | 'coordinates' | undefined;
360 |
361 | try {
362 | if (params.ref) {
363 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
364 | // Scroll element into view first to ensure it's visible
365 | try {
366 | await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: params.ref });
367 | } catch {
368 | // Best effort - continue even if scroll fails
369 | }
370 | // Re-resolve coordinates after scroll
371 | const resolved = await this.sendMessageToTab(tab.id, {
372 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
373 | ref: params.ref,
374 | });
375 | if (resolved && resolved.success) {
376 | coord = project({ x: resolved.center.x, y: resolved.center.y });
377 | resolvedBy = 'ref';
378 | }
379 | } else if (params.selector) {
380 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
381 | const selectorType = params.selectorType || 'css';
382 | const ensured = await this.sendMessageToTab(tab.id, {
383 | action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
384 | selector: params.selector,
385 | isXPath: selectorType === 'xpath',
386 | });
387 | if (ensured && ensured.success) {
388 | // Scroll element into view first to ensure it's visible
389 | const resolvedRef = typeof ensured.ref === 'string' ? ensured.ref : undefined;
390 | if (resolvedRef) {
391 | try {
392 | await this.sendMessageToTab(tab.id, { action: 'focusByRef', ref: resolvedRef });
393 | } catch {
394 | // Best effort - continue even if scroll fails
395 | }
396 | // Re-resolve coordinates after scroll
397 | const reResolved = await this.sendMessageToTab(tab.id, {
398 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
399 | ref: resolvedRef,
400 | });
401 | if (reResolved && reResolved.success) {
402 | coord = project({ x: reResolved.center.x, y: reResolved.center.y });
403 | } else {
404 | coord = project({ x: ensured.center.x, y: ensured.center.y });
405 | }
406 | } else {
407 | coord = project({ x: ensured.center.x, y: ensured.center.y });
408 | }
409 | resolvedBy = 'selector';
410 | }
411 | } else if (params.coordinates) {
412 | coord = project(params.coordinates);
413 | resolvedBy = 'coordinates';
414 | }
415 | } catch (e) {
416 | // fall through to error handling below
417 | }
418 |
419 | if (!coord)
420 | return createErrorResponse(
421 | 'Provide ref or selector or coordinates for hover, or failed to resolve target',
422 | );
423 | {
424 | const stale = ((): any => {
425 | if (!params.coordinates) return null;
426 | const getHostname = (url: string): string => {
427 | try {
428 | return new URL(url).hostname;
429 | } catch {
430 | return '';
431 | }
432 | };
433 | const currentHostname = getHostname(tab.url || '');
434 | const ctx = screenshotContextManager.getContext(tab.id!);
435 | const contextHostname = (ctx as any)?.hostname as string | undefined;
436 | if (contextHostname && contextHostname !== currentHostname) {
437 | return createErrorResponse(
438 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during hover. Capture a new screenshot or use ref/selector.`,
439 | );
440 | }
441 | return null;
442 | })();
443 | if (stale) return stale;
444 | }
445 |
446 | try {
447 | await CDPHelper.attach(tab.id);
448 | try {
449 | // Move pointer to target. We can dispatch a single mouseMoved; browsers will generate mouseover/mouseenter as needed.
450 | await CDPHelper.dispatchMouseEvent(tab.id, {
451 | type: 'mouseMoved',
452 | x: coord.x,
453 | y: coord.y,
454 | button: 'none',
455 | buttons: 0,
456 | });
457 | } finally {
458 | await CDPHelper.detach(tab.id);
459 | }
460 |
461 | // Optional hold to allow UI (menus/tooltips) to appear
462 | const holdMs = Math.max(
463 | 0,
464 | Math.min(params.duration ? params.duration * 1000 : 400, 5000),
465 | );
466 | if (holdMs > 0) await new Promise((r) => setTimeout(r, holdMs));
467 |
468 | return {
469 | content: [
470 | {
471 | type: 'text',
472 | text: JSON.stringify({
473 | success: true,
474 | action: 'hover',
475 | coordinates: coord,
476 | resolvedBy,
477 | transport: 'cdp',
478 | }),
479 | },
480 | ],
481 | isError: false,
482 | };
483 | } catch (error) {
484 | console.warn('[ComputerTool] CDP hover failed, attempting DOM fallback', error);
485 | return await this.domHoverFallback(tab.id, coord, resolvedBy, params.ref);
486 | }
487 | }
488 | case 'left_click':
489 | case 'right_click': {
490 | // Calculate CDP modifier mask for click events
491 | const modifiersMask = CDPHelper.modifierMask(
492 | [
493 | params.modifiers?.altKey ? 'alt' : undefined,
494 | params.modifiers?.ctrlKey ? 'ctrl' : undefined,
495 | params.modifiers?.metaKey ? 'meta' : undefined,
496 | params.modifiers?.shiftKey ? 'shift' : undefined,
497 | ].filter((v): v is string => typeof v === 'string'),
498 | );
499 |
500 | if (params.ref) {
501 | // Prefer DOM click via ref
502 | const domResult = await clickTool.execute({
503 | ref: params.ref,
504 | waitForNavigation: false,
505 | timeout: TIMEOUTS.DEFAULT_WAIT * 5,
506 | button: params.action === 'right_click' ? 'right' : 'left',
507 | modifiers: params.modifiers,
508 | });
509 | return domResult;
510 | }
511 | if (params.selector) {
512 | // Support selector-based click
513 | const domResult = await clickTool.execute({
514 | selector: params.selector,
515 | selectorType: params.selectorType,
516 | frameId: params.frameId,
517 | waitForNavigation: false,
518 | timeout: TIMEOUTS.DEFAULT_WAIT * 5,
519 | button: params.action === 'right_click' ? 'right' : 'left',
520 | modifiers: params.modifiers,
521 | });
522 | return domResult;
523 | }
524 | if (!params.coordinates)
525 | return createErrorResponse('Provide ref, selector, or coordinates for click action');
526 | {
527 | const stale = ((): any => {
528 | const getHostname = (url: string): string => {
529 | try {
530 | return new URL(url).hostname;
531 | } catch {
532 | return '';
533 | }
534 | };
535 | const currentHostname = getHostname(tab.url || '');
536 | const ctx = screenshotContextManager.getContext(tab.id!);
537 | const contextHostname = (ctx as any)?.hostname as string | undefined;
538 | if (contextHostname && contextHostname !== currentHostname) {
539 | return createErrorResponse(
540 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,
541 | );
542 | }
543 | return null;
544 | })();
545 | if (stale) return stale;
546 | }
547 | const coord = project(params.coordinates)!;
548 | // Prefer DOM path via existing click tool
549 | const domResult = await clickTool.execute({
550 | coordinates: coord,
551 | waitForNavigation: false,
552 | timeout: TIMEOUTS.DEFAULT_WAIT * 5,
553 | button: params.action === 'right_click' ? 'right' : 'left',
554 | modifiers: params.modifiers,
555 | });
556 | if (!domResult.isError) {
557 | return domResult; // Standardized response from click tool
558 | }
559 | // Fallback to CDP if DOM failed
560 | try {
561 | await CDPHelper.attach(tab.id);
562 | const button: MouseButton = params.action === 'right_click' ? 'right' : 'left';
563 | const clickCount = 1;
564 | await CDPHelper.dispatchMouseEvent(tab.id, {
565 | type: 'mouseMoved',
566 | x: coord.x,
567 | y: coord.y,
568 | button: 'none',
569 | buttons: 0,
570 | modifiers: modifiersMask,
571 | });
572 | for (let i = 1; i <= clickCount; i++) {
573 | await CDPHelper.dispatchMouseEvent(tab.id, {
574 | type: 'mousePressed',
575 | x: coord.x,
576 | y: coord.y,
577 | button,
578 | buttons: button === 'left' ? 1 : 2,
579 | clickCount: i,
580 | modifiers: modifiersMask,
581 | });
582 | await CDPHelper.dispatchMouseEvent(tab.id, {
583 | type: 'mouseReleased',
584 | x: coord.x,
585 | y: coord.y,
586 | button,
587 | buttons: 0,
588 | clickCount: i,
589 | modifiers: modifiersMask,
590 | });
591 | }
592 | await CDPHelper.detach(tab.id);
593 | return {
594 | content: [
595 | {
596 | type: 'text',
597 | text: JSON.stringify({
598 | success: true,
599 | action: params.action,
600 | coordinates: coord,
601 | }),
602 | },
603 | ],
604 | isError: false,
605 | };
606 | } catch (e) {
607 | await CDPHelper.detach(tab.id);
608 | return createErrorResponse(
609 | `CDP click failed: ${e instanceof Error ? e.message : String(e)}`,
610 | );
611 | }
612 | }
613 | case 'double_click':
614 | case 'triple_click': {
615 | // Calculate CDP modifier mask for click events
616 | const modifiersMask = CDPHelper.modifierMask(
617 | [
618 | params.modifiers?.altKey ? 'alt' : undefined,
619 | params.modifiers?.ctrlKey ? 'ctrl' : undefined,
620 | params.modifiers?.metaKey ? 'meta' : undefined,
621 | params.modifiers?.shiftKey ? 'shift' : undefined,
622 | ].filter((v): v is string => typeof v === 'string'),
623 | );
624 |
625 | if (!params.coordinates && !params.ref && !params.selector)
626 | return createErrorResponse(
627 | 'Provide ref, selector, or coordinates for double/triple click',
628 | );
629 | let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);
630 | // If ref is provided, resolve center via accessibility helper
631 | if (params.ref) {
632 | try {
633 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
634 | const resolved = await this.sendMessageToTab(tab.id, {
635 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
636 | ref: params.ref,
637 | });
638 | if (resolved && resolved.success) {
639 | coord = project({ x: resolved.center.x, y: resolved.center.y })!;
640 | }
641 | } catch (e) {
642 | // ignore and use provided coordinates
643 | }
644 | } else if (params.selector) {
645 | // Support selector-based click
646 | try {
647 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
648 | const selectorType = params.selectorType || 'css';
649 | const ensured = await this.sendMessageToTab(
650 | tab.id,
651 | {
652 | action: TOOL_MESSAGE_TYPES.ENSURE_REF_FOR_SELECTOR,
653 | selector: params.selector,
654 | isXPath: selectorType === 'xpath',
655 | },
656 | params.frameId,
657 | );
658 | if (ensured && ensured.success) {
659 | coord = project({ x: ensured.center.x, y: ensured.center.y })!;
660 | }
661 | } catch (e) {
662 | // ignore
663 | }
664 | }
665 | if (!coord) return createErrorResponse('Failed to resolve coordinates from ref/selector');
666 | {
667 | const stale = ((): any => {
668 | if (!params.coordinates) return null;
669 | const getHostname = (url: string): string => {
670 | try {
671 | return new URL(url).hostname;
672 | } catch {
673 | return '';
674 | }
675 | };
676 | const currentHostname = getHostname(tab.url || '');
677 | const ctx = screenshotContextManager.getContext(tab.id!);
678 | const contextHostname = (ctx as any)?.hostname as string | undefined;
679 | if (contextHostname && contextHostname !== currentHostname) {
680 | return createErrorResponse(
681 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during ${params.action}. Capture a new screenshot or use ref/selector.`,
682 | );
683 | }
684 | return null;
685 | })();
686 | if (stale) return stale;
687 | }
688 | try {
689 | await CDPHelper.attach(tab.id);
690 | const button: MouseButton = 'left';
691 | const clickCount = params.action === 'double_click' ? 2 : 3;
692 | await CDPHelper.dispatchMouseEvent(tab.id, {
693 | type: 'mouseMoved',
694 | x: coord.x,
695 | y: coord.y,
696 | button: 'none',
697 | buttons: 0,
698 | modifiers: modifiersMask,
699 | });
700 | for (let i = 1; i <= clickCount; i++) {
701 | await CDPHelper.dispatchMouseEvent(tab.id, {
702 | type: 'mousePressed',
703 | x: coord.x,
704 | y: coord.y,
705 | button,
706 | buttons: 1,
707 | clickCount: i,
708 | modifiers: modifiersMask,
709 | });
710 | await CDPHelper.dispatchMouseEvent(tab.id, {
711 | type: 'mouseReleased',
712 | x: coord.x,
713 | y: coord.y,
714 | button,
715 | buttons: 0,
716 | clickCount: i,
717 | modifiers: modifiersMask,
718 | });
719 | }
720 | await CDPHelper.detach(tab.id);
721 | return {
722 | content: [
723 | {
724 | type: 'text',
725 | text: JSON.stringify({
726 | success: true,
727 | action: params.action,
728 | coordinates: coord,
729 | }),
730 | },
731 | ],
732 | isError: false,
733 | };
734 | } catch (e) {
735 | await CDPHelper.detach(tab.id);
736 | return createErrorResponse(
737 | `CDP ${params.action} failed: ${e instanceof Error ? e.message : String(e)}`,
738 | );
739 | }
740 | }
741 | case 'left_click_drag': {
742 | if (!params.startCoordinates && !params.startRef)
743 | return createErrorResponse('Provide startRef or startCoordinates for drag');
744 | if (!params.coordinates && !params.ref)
745 | return createErrorResponse('Provide ref or end coordinates for drag');
746 | let start = params.startCoordinates
747 | ? project(params.startCoordinates)!
748 | : (undefined as any);
749 | let end = params.coordinates ? project(params.coordinates)! : (undefined as any);
750 | {
751 | const stale = ((): any => {
752 | if (!params.startCoordinates && !params.coordinates) return null;
753 | const getHostname = (url: string): string => {
754 | try {
755 | return new URL(url).hostname;
756 | } catch {
757 | return '';
758 | }
759 | };
760 | const currentHostname = getHostname(tab.url || '');
761 | const ctx = screenshotContextManager.getContext(tab.id!);
762 | const contextHostname = (ctx as any)?.hostname as string | undefined;
763 | if (contextHostname && contextHostname !== currentHostname) {
764 | return createErrorResponse(
765 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during left_click_drag. Capture a new screenshot or use ref/selector.`,
766 | );
767 | }
768 | return null;
769 | })();
770 | if (stale) return stale;
771 | }
772 | if (params.startRef || params.ref) {
773 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
774 | }
775 | if (params.startRef) {
776 | try {
777 | const resolved = await this.sendMessageToTab(tab.id, {
778 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
779 | ref: params.startRef,
780 | });
781 | if (resolved && resolved.success)
782 | start = project({ x: resolved.center.x, y: resolved.center.y })!;
783 | } catch {
784 | // ignore
785 | }
786 | }
787 | if (params.ref) {
788 | try {
789 | const resolved = await this.sendMessageToTab(tab.id, {
790 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
791 | ref: params.ref,
792 | });
793 | if (resolved && resolved.success)
794 | end = project({ x: resolved.center.x, y: resolved.center.y })!;
795 | } catch {
796 | // ignore
797 | }
798 | }
799 | if (!start || !end) return createErrorResponse('Failed to resolve drag coordinates');
800 | try {
801 | await CDPHelper.attach(tab.id);
802 | await CDPHelper.dispatchMouseEvent(tab.id, {
803 | type: 'mouseMoved',
804 | x: start.x,
805 | y: start.y,
806 | button: 'none',
807 | buttons: 0,
808 | });
809 | await CDPHelper.dispatchMouseEvent(tab.id, {
810 | type: 'mousePressed',
811 | x: start.x,
812 | y: start.y,
813 | button: 'left',
814 | buttons: 1,
815 | clickCount: 1,
816 | });
817 | await CDPHelper.dispatchMouseEvent(tab.id, {
818 | type: 'mouseMoved',
819 | x: end.x,
820 | y: end.y,
821 | button: 'left',
822 | buttons: 1,
823 | });
824 | await CDPHelper.dispatchMouseEvent(tab.id, {
825 | type: 'mouseReleased',
826 | x: end.x,
827 | y: end.y,
828 | button: 'left',
829 | buttons: 0,
830 | clickCount: 1,
831 | });
832 | await CDPHelper.detach(tab.id);
833 | return {
834 | content: [
835 | {
836 | type: 'text',
837 | text: JSON.stringify({ success: true, action: 'left_click_drag', start, end }),
838 | },
839 | ],
840 | isError: false,
841 | };
842 | } catch (e) {
843 | await CDPHelper.detach(tab.id);
844 | return createErrorResponse(`Drag failed: ${e instanceof Error ? e.message : String(e)}`);
845 | }
846 | }
847 | case 'scroll': {
848 | if (!params.coordinates && !params.ref)
849 | return createErrorResponse('Provide ref or coordinates for scroll');
850 | let coord = params.coordinates ? project(params.coordinates)! : (undefined as any);
851 | if (params.ref) {
852 | try {
853 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
854 | const resolved = await this.sendMessageToTab(tab.id, {
855 | action: TOOL_MESSAGE_TYPES.RESOLVE_REF,
856 | ref: params.ref,
857 | });
858 | if (resolved && resolved.success)
859 | coord = project({ x: resolved.center.x, y: resolved.center.y })!;
860 | } catch {
861 | // ignore
862 | }
863 | }
864 | if (!coord) return createErrorResponse('Failed to resolve scroll coordinates');
865 | {
866 | const stale = ((): any => {
867 | if (!params.coordinates) return null;
868 | const getHostname = (url: string): string => {
869 | try {
870 | return new URL(url).hostname;
871 | } catch {
872 | return '';
873 | }
874 | };
875 | const currentHostname = getHostname(tab.url || '');
876 | const ctx = screenshotContextManager.getContext(tab.id!);
877 | const contextHostname = (ctx as any)?.hostname as string | undefined;
878 | if (contextHostname && contextHostname !== currentHostname) {
879 | return createErrorResponse(
880 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during scroll. Capture a new screenshot or use ref/selector.`,
881 | );
882 | }
883 | return null;
884 | })();
885 | if (stale) return stale;
886 | }
887 | const direction = params.scrollDirection || 'down';
888 | const amount = Math.max(1, Math.min(params.scrollAmount || 3, 10));
889 | // Convert to deltas (~100px per tick)
890 | const unit = 100;
891 | let deltaX = 0,
892 | deltaY = 0;
893 | if (direction === 'up') deltaY = -amount * unit;
894 | if (direction === 'down') deltaY = amount * unit;
895 | if (direction === 'left') deltaX = -amount * unit;
896 | if (direction === 'right') deltaX = amount * unit;
897 | try {
898 | await CDPHelper.attach(tab.id);
899 | await CDPHelper.dispatchMouseEvent(tab.id, {
900 | type: 'mouseWheel',
901 | x: coord.x,
902 | y: coord.y,
903 | deltaX,
904 | deltaY,
905 | });
906 | await CDPHelper.detach(tab.id);
907 | return {
908 | content: [
909 | {
910 | type: 'text',
911 | text: JSON.stringify({
912 | success: true,
913 | action: 'scroll',
914 | coordinates: coord,
915 | deltaX,
916 | deltaY,
917 | }),
918 | },
919 | ],
920 | isError: false,
921 | };
922 | } catch (e) {
923 | await CDPHelper.detach(tab.id);
924 | return createErrorResponse(
925 | `Scroll failed: ${e instanceof Error ? e.message : String(e)}`,
926 | );
927 | }
928 | }
929 | case 'type': {
930 | if (!params.text) return createErrorResponse('Text parameter is required for type action');
931 | try {
932 | // Optional focus via ref before typing
933 | if (params.ref) {
934 | await clickTool.execute({
935 | ref: params.ref,
936 | waitForNavigation: false,
937 | timeout: TIMEOUTS.DEFAULT_WAIT * 5,
938 | });
939 | }
940 | await CDPHelper.attach(tab.id);
941 | // Use CDP insertText to avoid complex KeyboardEvent emulation for long text
942 | await CDPHelper.insertText(tab.id, params.text);
943 | await CDPHelper.detach(tab.id);
944 | return {
945 | content: [
946 | {
947 | type: 'text',
948 | text: JSON.stringify({
949 | success: true,
950 | action: 'type',
951 | length: params.text.length,
952 | }),
953 | },
954 | ],
955 | isError: false,
956 | };
957 | } catch (e) {
958 | await CDPHelper.detach(tab.id);
959 | // Fallback to DOM-based keyboard tool
960 | const res = await keyboardTool.execute({
961 | keys: params.text.split('').join(','),
962 | delay: 0,
963 | selector: undefined,
964 | });
965 | return res;
966 | }
967 | }
968 | case 'fill': {
969 | if (!params.ref && !params.selector) {
970 | return createErrorResponse('Provide ref or selector and a value for fill');
971 | }
972 | // Reuse existing fill tool to leverage robust DOM event behavior
973 | const res = await fillTool.execute({
974 | selector: params.selector as any,
975 | selectorType: params.selectorType as any,
976 | ref: params.ref as any,
977 | value: params.value as any,
978 | } as any);
979 | return res;
980 | }
981 | case 'fill_form': {
982 | const elements = (params as any).elements as Array<{
983 | ref: string;
984 | value: string | number | boolean;
985 | }>;
986 | if (!Array.isArray(elements) || elements.length === 0) {
987 | return createErrorResponse('elements must be a non-empty array for fill_form');
988 | }
989 | const results: Array<{ ref: string; ok: boolean; error?: string }> = [];
990 | for (const item of elements) {
991 | if (!item || !item.ref) {
992 | results.push({ ref: String(item?.ref || ''), ok: false, error: 'missing ref' });
993 | continue;
994 | }
995 | try {
996 | const r = await fillTool.execute({
997 | ref: item.ref as any,
998 | value: item.value as any,
999 | } as any);
1000 | const ok = !r.isError;
1001 | results.push({ ref: item.ref, ok, error: ok ? undefined : 'failed' });
1002 | } catch (e) {
1003 | results.push({
1004 | ref: item.ref,
1005 | ok: false,
1006 | error: String(e instanceof Error ? e.message : e),
1007 | });
1008 | }
1009 | }
1010 | const successCount = results.filter((r) => r.ok).length;
1011 | return {
1012 | content: [
1013 | {
1014 | type: 'text',
1015 | text: JSON.stringify({
1016 | success: true,
1017 | action: 'fill_form',
1018 | filled: successCount,
1019 | total: results.length,
1020 | results,
1021 | }),
1022 | },
1023 | ],
1024 | isError: false,
1025 | };
1026 | }
1027 | case 'key': {
1028 | if (!params.text)
1029 | return createErrorResponse(
1030 | 'text is required for key action (e.g., "Backspace Backspace Enter" or "cmd+a")',
1031 | );
1032 | const tokens = params.text.trim().split(/\s+/).filter(Boolean);
1033 | const repeat = params.repeat ?? 1;
1034 | if (!Number.isInteger(repeat) || repeat < 1 || repeat > 100) {
1035 | return createErrorResponse('repeat must be an integer between 1 and 100 for key action');
1036 | }
1037 | try {
1038 | // Optional focus via ref before key events
1039 | if (params.ref) {
1040 | await clickTool.execute({
1041 | ref: params.ref,
1042 | waitForNavigation: false,
1043 | timeout: TIMEOUTS.DEFAULT_WAIT * 5,
1044 | });
1045 | }
1046 | await CDPHelper.attach(tab.id);
1047 | for (let i = 0; i < repeat; i++) {
1048 | for (const t of tokens) {
1049 | if (t.includes('+')) await CDPHelper.dispatchKeyChord(tab.id, t);
1050 | else await CDPHelper.dispatchSimpleKey(tab.id, t);
1051 | }
1052 | }
1053 | await CDPHelper.detach(tab.id);
1054 | return {
1055 | content: [
1056 | {
1057 | type: 'text',
1058 | text: JSON.stringify({ success: true, action: 'key', keys: tokens, repeat }),
1059 | },
1060 | ],
1061 | isError: false,
1062 | };
1063 | } catch (e) {
1064 | await CDPHelper.detach(tab.id);
1065 | // Fallback to DOM keyboard simulation (comma-separated combinations)
1066 | const keysStr = tokens.join(',');
1067 | const repeatedKeys =
1068 | repeat === 1 ? keysStr : Array.from({ length: repeat }, () => keysStr).join(',');
1069 | const res = await keyboardTool.execute({ keys: repeatedKeys });
1070 | return res;
1071 | }
1072 | }
1073 | case 'wait': {
1074 | const hasTextCondition =
1075 | typeof (params as any).text === 'string' && (params as any).text.trim().length > 0;
1076 | if (hasTextCondition) {
1077 | try {
1078 | // Conditional wait for text appearance/disappearance using content script
1079 | await this.injectContentScript(
1080 | tab.id,
1081 | ['inject-scripts/wait-helper.js'],
1082 | false,
1083 | 'ISOLATED',
1084 | true,
1085 | );
1086 | const appear = (params as any).appear !== false; // default to true
1087 | const timeoutMs = Math.max(
1088 | 0,
1089 | Math.min(((params as any).timeout as number) || 10000, 120000),
1090 | );
1091 | const resp = await this.sendMessageToTab(tab.id, {
1092 | action: TOOL_MESSAGE_TYPES.WAIT_FOR_TEXT,
1093 | text: (params as any).text,
1094 | appear,
1095 | timeout: timeoutMs,
1096 | });
1097 | if (!resp || resp.success !== true) {
1098 | return createErrorResponse(
1099 | resp && resp.reason === 'timeout'
1100 | ? `wait_for timed out after ${timeoutMs}ms for text: ${(params as any).text}`
1101 | : `wait_for failed: ${resp && resp.error ? resp.error : 'unknown error'}`,
1102 | );
1103 | }
1104 | return {
1105 | content: [
1106 | {
1107 | type: 'text',
1108 | text: JSON.stringify({
1109 | success: true,
1110 | action: 'wait_for',
1111 | appear,
1112 | text: (params as any).text,
1113 | matched: resp.matched || null,
1114 | tookMs: resp.tookMs,
1115 | }),
1116 | },
1117 | ],
1118 | isError: false,
1119 | };
1120 | } catch (e) {
1121 | return createErrorResponse(
1122 | `wait_for failed: ${e instanceof Error ? e.message : String(e)}`,
1123 | );
1124 | }
1125 | } else {
1126 | const seconds = Math.max(0, Math.min((params as any).duration || 0, 30));
1127 | if (!seconds)
1128 | return createErrorResponse('Duration parameter is required and must be > 0');
1129 | await new Promise((r) => setTimeout(r, seconds * 1000));
1130 | return {
1131 | content: [
1132 | {
1133 | type: 'text',
1134 | text: JSON.stringify({ success: true, action: 'wait', duration: seconds }),
1135 | },
1136 | ],
1137 | isError: false,
1138 | };
1139 | }
1140 | }
1141 | case 'scroll_to': {
1142 | if (!params.ref) {
1143 | return createErrorResponse('ref is required for scroll_to action');
1144 | }
1145 | try {
1146 | await this.injectContentScript(tab.id, ['inject-scripts/accessibility-tree-helper.js']);
1147 | const resp = await this.sendMessageToTab(tab.id, {
1148 | action: 'focusByRef',
1149 | ref: params.ref,
1150 | });
1151 | if (!resp || resp.success !== true) {
1152 | return createErrorResponse(resp?.error || 'scroll_to failed: element not found');
1153 | }
1154 | return {
1155 | content: [
1156 | {
1157 | type: 'text',
1158 | text: JSON.stringify({
1159 | success: true,
1160 | action: 'scroll_to',
1161 | ref: params.ref,
1162 | }),
1163 | },
1164 | ],
1165 | isError: false,
1166 | };
1167 | } catch (e) {
1168 | return createErrorResponse(
1169 | `scroll_to failed: ${e instanceof Error ? e.message : String(e)}`,
1170 | );
1171 | }
1172 | }
1173 | case 'zoom': {
1174 | const region = params.region;
1175 | if (!region) {
1176 | return createErrorResponse('region is required for zoom action');
1177 | }
1178 | const x0 = Number(region.x0);
1179 | const y0 = Number(region.y0);
1180 | const x1 = Number(region.x1);
1181 | const y1 = Number(region.y1);
1182 | if (![x0, y0, x1, y1].every(Number.isFinite)) {
1183 | return createErrorResponse('region must contain finite numbers (x0, y0, x1, y1)');
1184 | }
1185 | if (x0 < 0 || y0 < 0 || x1 <= x0 || y1 <= y0) {
1186 | return createErrorResponse('Invalid region: require x0>=0, y0>=0 and x1>x0, y1>y0');
1187 | }
1188 |
1189 | // Project coordinates from screenshot space to viewport space
1190 | const p0 = project({ x: x0, y: y0 })!;
1191 | const p1 = project({ x: x1, y: y1 })!;
1192 | const rx0 = Math.min(p0.x, p1.x);
1193 | const ry0 = Math.min(p0.y, p1.y);
1194 | const rx1 = Math.max(p0.x, p1.x);
1195 | const ry1 = Math.max(p0.y, p1.y);
1196 | const w = rx1 - rx0;
1197 | const h = ry1 - ry0;
1198 | if (w <= 0 || h <= 0) {
1199 | return createErrorResponse('Invalid region after projection');
1200 | }
1201 |
1202 | // Security check: verify domain hasn't changed since last screenshot
1203 | {
1204 | const getHostname = (url: string): string => {
1205 | try {
1206 | return new URL(url).hostname;
1207 | } catch {
1208 | return '';
1209 | }
1210 | };
1211 | const ctx = screenshotContextManager.getContext(tab.id!);
1212 | const contextHostname = (ctx as any)?.hostname as string | undefined;
1213 | const currentHostname = getHostname(tab.url || '');
1214 | if (contextHostname && contextHostname !== currentHostname) {
1215 | return createErrorResponse(
1216 | `Security check failed: Domain changed since last screenshot (from ${contextHostname} to ${currentHostname}) during zoom. Capture a new screenshot first.`,
1217 | );
1218 | }
1219 | }
1220 |
1221 | try {
1222 | await CDPHelper.attach(tab.id);
1223 | const metrics: any = await CDPHelper.send(tab.id, 'Page.getLayoutMetrics', {});
1224 | const viewport = metrics?.layoutViewport ||
1225 | metrics?.visualViewport || {
1226 | clientWidth: 800,
1227 | clientHeight: 600,
1228 | pageX: 0,
1229 | pageY: 0,
1230 | };
1231 | const vw = Math.round(Number(viewport.clientWidth || 800));
1232 | const vh = Math.round(Number(viewport.clientHeight || 600));
1233 | if (rx1 > vw || ry1 > vh) {
1234 | await CDPHelper.detach(tab.id);
1235 | return createErrorResponse(
1236 | `Region exceeds viewport boundaries (${vw}x${vh}). Choose a region within the visible viewport.`,
1237 | );
1238 | }
1239 | const pageX = Number(viewport.pageX || 0);
1240 | const pageY = Number(viewport.pageY || 0);
1241 |
1242 | const shot: any = await CDPHelper.send(tab.id, 'Page.captureScreenshot', {
1243 | format: 'png',
1244 | captureBeyondViewport: false,
1245 | fromSurface: true,
1246 | clip: {
1247 | x: pageX + rx0,
1248 | y: pageY + ry0,
1249 | width: w,
1250 | height: h,
1251 | scale: 1,
1252 | },
1253 | });
1254 | await CDPHelper.detach(tab.id);
1255 |
1256 | const base64Data = String(shot?.data || '');
1257 | if (!base64Data) {
1258 | return createErrorResponse('Failed to capture zoom screenshot via CDP');
1259 | }
1260 | return {
1261 | content: [
1262 | {
1263 | type: 'text',
1264 | text: JSON.stringify({
1265 | success: true,
1266 | action: 'zoom',
1267 | mimeType: 'image/png',
1268 | base64Data,
1269 | region: { x0: rx0, y0: ry0, x1: rx1, y1: ry1 },
1270 | }),
1271 | },
1272 | ],
1273 | isError: false,
1274 | };
1275 | } catch (e) {
1276 | await CDPHelper.detach(tab.id);
1277 | return createErrorResponse(`zoom failed: ${e instanceof Error ? e.message : String(e)}`);
1278 | }
1279 | }
1280 | case 'screenshot': {
1281 | // Reuse existing screenshot tool; it already supports base64 save option
1282 | const result = await screenshotTool.execute({
1283 | name: 'computer',
1284 | storeBase64: true,
1285 | fullPage: false,
1286 | });
1287 | return result;
1288 | }
1289 | default:
1290 | return createErrorResponse(`Unsupported action: ${params.action}`);
1291 | }
1292 | }
1293 |
1294 | /**
1295 | * DOM-based hover fallback when CDP is unavailable
1296 | * Tries ref-based approach first (works with iframes), falls back to coordinates
1297 | */
1298 | private async domHoverFallback(
1299 | tabId: number,
1300 | coord?: Coordinates,
1301 | resolvedBy?: 'ref' | 'selector' | 'coordinates',
1302 | ref?: string,
1303 | ): Promise<ToolResult> {
1304 | // Try ref-based approach first (handles iframes correctly)
1305 | if (ref) {
1306 | try {
1307 | const resp = await this.sendMessageToTab(tabId, {
1308 | action: TOOL_MESSAGE_TYPES.DISPATCH_HOVER_FOR_REF,
1309 | ref,
1310 | });
1311 | if (resp?.success) {
1312 | return {
1313 | content: [
1314 | {
1315 | type: 'text',
1316 | text: JSON.stringify({
1317 | success: true,
1318 | action: 'hover',
1319 | resolvedBy: 'ref',
1320 | transport: 'dom-ref',
1321 | target: resp.target,
1322 | }),
1323 | },
1324 | ],
1325 | isError: false,
1326 | };
1327 | }
1328 | } catch (error) {
1329 | console.warn('[ComputerTool] DOM ref hover failed, falling back to coordinates', error);
1330 | }
1331 | }
1332 |
1333 | // Fallback to coordinate-based approach
1334 | if (!coord) {
1335 | return createErrorResponse('Hover fallback requires coordinates or ref');
1336 | }
1337 |
1338 | try {
1339 | const [injection] = await chrome.scripting.executeScript({
1340 | target: { tabId },
1341 | world: 'MAIN',
1342 | func: (point) => {
1343 | const target = document.elementFromPoint(point.x, point.y);
1344 | if (!target) {
1345 | return { success: false, error: 'No element found at coordinates' };
1346 | }
1347 |
1348 | // Dispatch hover-related events
1349 | for (const type of ['mousemove', 'mouseover', 'mouseenter']) {
1350 | target.dispatchEvent(
1351 | new MouseEvent(type, {
1352 | bubbles: true,
1353 | cancelable: true,
1354 | clientX: point.x,
1355 | clientY: point.y,
1356 | view: window,
1357 | }),
1358 | );
1359 | }
1360 |
1361 | return {
1362 | success: true,
1363 | target: {
1364 | tagName: target.tagName,
1365 | id: target.id,
1366 | className: target.className,
1367 | text: target.textContent?.trim()?.slice(0, 100) || '',
1368 | },
1369 | };
1370 | },
1371 | args: [coord],
1372 | });
1373 |
1374 | const payload = injection?.result;
1375 | if (!payload?.success) {
1376 | return createErrorResponse(payload?.error || 'DOM hover fallback failed');
1377 | }
1378 |
1379 | return {
1380 | content: [
1381 | {
1382 | type: 'text',
1383 | text: JSON.stringify({
1384 | success: true,
1385 | action: 'hover',
1386 | coordinates: coord,
1387 | resolvedBy,
1388 | transport: 'dom',
1389 | target: payload.target,
1390 | }),
1391 | },
1392 | ],
1393 | isError: false,
1394 | };
1395 | } catch (error) {
1396 | return createErrorResponse(
1397 | `DOM hover fallback failed: ${error instanceof Error ? error.message : String(error)}`,
1398 | );
1399 | }
1400 | }
1401 |
1402 | /**
1403 | * Trigger GIF auto-capture after a successful action.
1404 | * This is a no-op if auto-capture is not active.
1405 | */
1406 | private async triggerAutoCapture(
1407 | tabId: number,
1408 | actionType: ActionType,
1409 | metadata?: Partial<ActionMetadata>,
1410 | ): Promise<void> {
1411 | if (!isAutoCaptureActive(tabId)) {
1412 | return;
1413 | }
1414 |
1415 | try {
1416 | await captureFrameOnAction(tabId, {
1417 | type: actionType,
1418 | ...metadata,
1419 | });
1420 | } catch (error) {
1421 | // Log but don't fail the main action
1422 | console.warn('[ComputerTool] Auto-capture failed:', error);
1423 | }
1424 | }
1425 | }
1426 |
1427 | export const computerTool = new ComputerTool();
1428 |
```