feat: add integrated web preview with element selection (fixes #5971)

- Add WebPreviewProvider to manage preview panel in sidebar
- Create React-based UI with URL navigation and viewport controls
- Implement element selection with context extraction (HTML, CSS, XPath)
- Add web_preview tool for AI integration
- Register commands and update package.json
- Update type definitions and tool configurations

This implementation provides:
- Web preview panel in VSCode sidebar
- Click-to-select elements with visual highlighting
- Element context extraction for AI assistance
- Responsive viewport controls
- Integration with Roo Code AI through web_preview tool
This commit is contained in:
Roo Code 2025-07-20 04:07:08 +00:00
parent de13d8a5cf
commit ab7ccf1852
16 changed files with 1026 additions and 0 deletions

View file

@ -34,6 +34,7 @@ export const toolNames = [
"fetch_instructions",
"codebase_search",
"update_todo_list",
"web_preview",
] as const
export const toolNamesSchema = z.enum(toolNames)

View file

@ -53,6 +53,8 @@ export const commandIds = [
"focusInput",
"acceptInput",
"focusPanel",
"openWebPreview",
"getSelectedElement",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -7,6 +7,7 @@ import { TelemetryService } from "@roo-code/telemetry"
import { Package } from "../shared/package"
import { getCommand } from "../utils/commands"
import { ClineProvider } from "../core/webview/ClineProvider"
import { WebPreviewProvider } from "../core/webview/WebPreviewProvider"
import { ContextProxy } from "../core/config/ContextProxy"
import { focusPanel } from "../utils/focusPanel"
@ -32,6 +33,7 @@ export function getVisibleProviderOrLog(outputChannel: vscode.OutputChannel): Cl
// Store panel references in both modes
let sidebarPanel: vscode.WebviewView | undefined = undefined
let tabPanel: vscode.WebviewPanel | undefined = undefined
let webPreviewProvider: WebPreviewProvider | undefined = undefined
/**
* Get the currently active panel
@ -57,6 +59,10 @@ export function setPanel(
}
}
export function setWebPreviewProvider(provider: WebPreviewProvider) {
webPreviewProvider = provider
}
export type RegisterCommandOptions = {
context: vscode.ExtensionContext
outputChannel: vscode.OutputChannel
@ -218,6 +224,27 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
visibleProvider.postMessageToWebview({ type: "acceptInput" })
},
openWebPreview: async (url?: string) => {
if (!webPreviewProvider) {
outputChannel.appendLine("Web Preview Provider not initialized")
return
}
if (url) {
await webPreviewProvider.openUrl(url)
} else {
// Show the web preview panel without navigating
await vscode.commands.executeCommand("roo-code.webPreview.focus")
}
},
getSelectedElement: async () => {
if (!webPreviewProvider) {
outputChannel.appendLine("Web Preview Provider not initialized")
return null
}
return webPreviewProvider.getSelectedElementContext()
},
})
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

View file

@ -24,6 +24,7 @@ import { askFollowupQuestionTool } from "../tools/askFollowupQuestionTool"
import { switchModeTool } from "../tools/switchModeTool"
import { attemptCompletionTool } from "../tools/attemptCompletionTool"
import { newTaskTool } from "../tools/newTaskTool"
import { webPreviewTool } from "../tools/webPreviewTool"
import { checkpointSave } from "../checkpoints"
import { updateTodoListTool } from "../tools/updateTodoListTool"
@ -214,6 +215,8 @@ export async function presentAssistantMessage(cline: Task) {
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
return `[${block.name} in ${modeName} mode: '${message}']`
}
case "web_preview":
return `[${block.name} for '${block.params.action}'${block.params.url ? ` - ${block.params.url}` : ""}]`
}
}
@ -522,6 +525,9 @@ export async function presentAssistantMessage(cline: Task) {
askFinishSubTaskApproval,
)
break
case "web_preview":
await webPreviewTool(cline, block, askApproval, handleError, pushToolResult)
break
}
break

View file

@ -0,0 +1,91 @@
import * as vscode from "vscode"
import { Task } from "../task/Task"
import { ClineSayTool } from "../../shared/ExtensionMessage"
import { formatResponse } from "../prompts/responses"
import { ToolUse, AskApproval, HandleError, PushToolResult } from "../../shared/tools"
export async function webPreviewTool(
cline: Task,
block: ToolUse,
askApproval: AskApproval,
handleError: HandleError,
pushToolResult: PushToolResult,
) {
const action = block.params.action
const url = block.params.url
if (!action) {
cline.consecutiveMistakeCount++
const errorMsg = await cline.sayAndCreateMissingParamError("web_preview", "action")
pushToolResult(formatResponse.toolError(errorMsg))
return
}
if (action === "open" && !url) {
cline.consecutiveMistakeCount++
const errorMsg = await cline.sayAndCreateMissingParamError("web_preview", "url")
pushToolResult(formatResponse.toolError(errorMsg))
return
}
try {
// Handle partial message
if (block.partial) {
const partialMessage = JSON.stringify({
tool: "web_preview",
action,
url,
} satisfies ClineSayTool)
await cline.ask("tool", partialMessage, block.partial).catch(() => {})
return
}
// Ask for approval
const completeMessage = JSON.stringify({
tool: "web_preview",
action,
url,
} satisfies ClineSayTool)
const { response, text, images } = await cline.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
if (text) {
await cline.say("user_feedback", text, images)
}
cline.didRejectTool = true
pushToolResult(formatResponse.toolDenied())
return
}
if (text) {
await cline.say("user_feedback", text, images)
}
// Execute the web preview action
if (action === "open") {
// Open the web preview panel with the URL
await vscode.commands.executeCommand("roo-code.openWebPreview", url)
pushToolResult(formatResponse.toolResult(`Opened web preview for: ${url}`))
} else if (action === "select") {
// Get the current selected element context from the preview
const selectedContext = await vscode.commands.executeCommand("roo-code.getSelectedElement")
if (selectedContext) {
pushToolResult(
formatResponse.toolResult(`Selected element context:\n${JSON.stringify(selectedContext, null, 2)}`),
)
} else {
pushToolResult(
formatResponse.toolResult(
"No element is currently selected. Click on an element in the preview to select it.",
),
)
}
} else {
pushToolResult(formatResponse.toolError(`Unknown action: ${action}. Valid actions are: open, select`))
}
} catch (error) {
await handleError("web preview operation", error instanceof Error ? error : new Error(String(error)))
pushToolResult(formatResponse.toolError(error instanceof Error ? error.message : String(error)))
}
}

View file

@ -0,0 +1,235 @@
import * as vscode from "vscode"
import * as path from "path"
import { getNonce } from "./getNonce"
import { getUri } from "./getUri"
import { ClineProvider } from "./ClineProvider"
import { ExtensionMessage } from "../../shared/ExtensionMessage"
import { WebviewMessage } from "../../shared/WebviewMessage"
export interface ElementContext {
html: string
css: string
xpath: string
selector: string
position: {
x: number
y: number
width: number
height: number
}
computedStyles?: Record<string, string>
attributes?: Record<string, string>
}
export interface PreviewState {
url?: string
isLoading: boolean
selectedElement?: ElementContext
viewportSize: { width: number; height: number }
deviceMode: "desktop" | "tablet" | "mobile" | "custom"
}
export class WebPreviewProvider implements vscode.WebviewViewProvider {
public static readonly viewId = "roo-code.webPreview"
private _view?: vscode.WebviewView
private _extensionUri: vscode.Uri
private _clineProvider?: ClineProvider
private _state: PreviewState = {
isLoading: false,
viewportSize: { width: 1200, height: 800 },
deviceMode: "desktop",
}
// Common device presets
private readonly devicePresets = {
desktop: { width: 1200, height: 800, name: "Desktop" },
tablet: { width: 768, height: 1024, name: "Tablet" },
mobile: { width: 375, height: 667, name: "Mobile" },
}
constructor(private readonly _context: vscode.ExtensionContext) {
this._extensionUri = _context.extensionUri
}
public setClineProvider(provider: ClineProvider) {
this._clineProvider = provider
}
public resolveWebviewView(
webviewView: vscode.WebviewView,
context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken,
) {
this._view = webviewView
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri],
}
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview)
// Handle messages from the webview
webviewView.webview.onDidReceiveMessage(async (message: WebviewMessage) => {
switch (message.type) {
case "elementSelected":
await this._handleElementSelection(message.elementContext)
break
case "navigateToUrl":
await this._handleNavigation(message.url!)
break
case "setViewportSize":
this._handleViewportResize(message.width!, message.height!)
break
case "setDeviceMode":
this._handleDeviceModeChange(message.deviceMode as PreviewState["deviceMode"])
break
case "refreshPreview":
await this._refreshPreview()
break
}
})
// Handle visibility changes
webviewView.onDidChangeVisibility(() => {
if (webviewView.visible) {
this._updateWebview()
}
})
// Handle disposal
webviewView.onDidDispose(() => {
this._view = undefined
})
}
private async _handleElementSelection(elementContext: ElementContext) {
this._state.selectedElement = elementContext
// Send element context to Cline
if (this._clineProvider) {
const contextMessage = this._formatElementContext(elementContext)
await this._clineProvider.postMessageToWebview({
type: "webPreviewElementSelected",
elementContext: contextMessage,
})
}
this._updateWebview()
}
private _formatElementContext(context: ElementContext): string {
const lines = [
"Selected Element Context:",
`- HTML: ${context.html}`,
`- CSS Selector: ${context.selector}`,
`- XPath: ${context.xpath}`,
`- Position: ${context.position.x}x${context.position.y} (${context.position.width}x${context.position.height})`,
]
if (context.attributes && Object.keys(context.attributes).length > 0) {
lines.push(`- Attributes: ${JSON.stringify(context.attributes, null, 2)}`)
}
if (context.computedStyles && Object.keys(context.computedStyles).length > 0) {
const importantStyles = ["display", "position", "width", "height", "color", "background-color", "font-size"]
const styles = importantStyles
.filter((style) => context.computedStyles![style])
.map((style) => `${style}: ${context.computedStyles![style]}`)
if (styles.length > 0) {
lines.push(`- Key Styles: ${styles.join(", ")}`)
}
}
return lines.join("\n")
}
private async _handleNavigation(url: string) {
this._state.url = url
this._state.isLoading = true
this._updateWebview()
// Simulate loading completion after a delay
setTimeout(() => {
this._state.isLoading = false
this._updateWebview()
}, 1000)
}
private _handleViewportResize(width: number, height: number) {
this._state.viewportSize = { width, height }
this._state.deviceMode = "custom"
this._updateWebview()
}
private _handleDeviceModeChange(mode: PreviewState["deviceMode"]) {
this._state.deviceMode = mode
if (mode !== "custom" && this.devicePresets[mode]) {
const preset = this.devicePresets[mode]
this._state.viewportSize = { width: preset.width, height: preset.height }
}
this._updateWebview()
}
private async _refreshPreview() {
if (this._state.url) {
await this._handleNavigation(this._state.url)
}
}
private _updateWebview() {
if (this._view) {
this._view.webview.postMessage({
type: "updateState",
state: this._state,
})
}
}
public async openUrl(url: string) {
this._state.url = url
await this._handleNavigation(url)
// Show the preview panel
if (this._view) {
this._view.show(true)
}
}
public getSelectedElementContext(): ElementContext | undefined {
return this._state.selectedElement
}
private _getHtmlForWebview(webview: vscode.Webview): string {
const scriptUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "assets", "webPreview.js"])
const styleUri = getUri(webview, this._extensionUri, ["webview-ui", "build", "assets", "index.css"])
const codiconsUri = getUri(webview, this._extensionUri, ["assets", "codicons", "codicon.css"])
const nonce = getNonce()
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none';
style-src ${webview.cspSource} 'unsafe-inline';
script-src 'nonce-${nonce}';
font-src ${webview.cspSource};
frame-src http: https: file:;
img-src ${webview.cspSource} https: http: data:;
connect-src http: https: ws: wss:;">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="${styleUri}" rel="stylesheet">
<link href="${codiconsUri}" rel="stylesheet">
<title>Web Preview</title>
</head>
<body>
<div id="root"></div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
window.vscode = vscode;
</script>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`
}
}

View file

@ -22,6 +22,7 @@ import { Package } from "./shared/package"
import { formatLanguage } from "./shared/language"
import { ContextProxy } from "./core/config/ContextProxy"
import { ClineProvider } from "./core/webview/ClineProvider"
import { WebPreviewProvider } from "./core/webview/WebPreviewProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { McpServerManager } from "./services/mcp/McpServerManager"
@ -121,6 +122,15 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
// Register the Web Preview Provider
const webPreviewProvider = new WebPreviewProvider(context)
webPreviewProvider.setClineProvider(provider)
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(WebPreviewProvider.viewId, webPreviewProvider, {
webviewOptions: { retainContextWhenHidden: true },
}),
)
// Auto-import configuration if specified in settings
try {
await autoImportSettings(outputChannel, {
@ -136,6 +146,10 @@ export async function activate(context: vscode.ExtensionContext) {
registerCommands({ context, outputChannel, provider })
// Set the web preview provider in registerCommands
const { setWebPreviewProvider } = await import("./activate/registerCommands")
setWebPreviewProvider(webPreviewProvider)
/**
* We use the text document content provider API to show the left side for diff
* view by creating a virtual document for the original content. This makes it

View file

@ -66,6 +66,12 @@
"type": "webview",
"id": "roo-cline.SidebarProvider",
"name": "%views.sidebar.name%"
},
{
"type": "webview",
"id": "roo-code.webPreview",
"name": "Web Preview",
"visibility": "collapsed"
}
]
},
@ -174,6 +180,16 @@
"command": "roo-cline.acceptInput",
"title": "%command.acceptInput.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.openWebPreview",
"title": "Open Web Preview",
"category": "%configuration.title%"
},
{
"command": "roo-cline.getSelectedElement",
"title": "Get Selected Element",
"category": "%configuration.title%"
}
],
"menus": {

View file

@ -107,6 +107,7 @@ export interface ExtensionMessage {
| "codeIndexSecretStatus"
| "showDeleteMessageDialog"
| "showEditMessageDialog"
| "webPreviewElementSelected"
text?: string
payload?: any // Add a generic payload for now, can refine later
action?:
@ -161,6 +162,7 @@ export interface ExtensionMessage {
settings?: any
messageTs?: number
context?: string
elementContext?: string
}
export type ExtensionState = Pick<
@ -302,6 +304,7 @@ export interface ClineSayTool {
| "finishTask"
| "searchAndReplace"
| "insertContent"
| "web_preview"
path?: string
diff?: string
content?: string
@ -338,6 +341,8 @@ export interface ClineSayTool {
}>
}>
question?: string
action?: string
url?: string
}
// Must keep in sync with system prompt.

View file

@ -198,6 +198,11 @@ export interface WebviewMessage {
| "checkRulesDirectoryResult"
| "saveCodeIndexSettingsAtomic"
| "requestCodeIndexSecretStatus"
| "elementSelected"
| "navigateToUrl"
| "setViewportSize"
| "setDeviceMode"
| "refreshPreview"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "account"
@ -260,6 +265,11 @@ export interface WebviewMessage {
codebaseIndexGeminiApiKey?: string
codebaseIndexMistralApiKey?: string
}
// Web preview specific fields
elementContext?: any
width?: number
height?: number
deviceMode?: string
}
export const checkoutDiffPayloadSchema = z.object({

View file

@ -190,6 +190,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
search_and_replace: "search and replace",
codebase_search: "codebase search",
update_todo_list: "update todo list",
web_preview: "web preview",
} as const
// Define available tool groups.

View file

@ -0,0 +1,180 @@
.web-preview-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background-color: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.web-preview-toolbar {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
background-color: var(--vscode-editorWidget-background);
border-bottom: 1px solid var(--vscode-widget-border);
flex-wrap: wrap;
}
.toolbar-section {
display: flex;
align-items: center;
gap: 8px;
}
.url-input {
flex: 1;
min-width: 300px;
padding: 4px 8px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 2px;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.url-input:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.custom-size-inputs {
display: flex;
align-items: center;
gap: 4px;
}
.size-input {
width: 80px;
padding: 4px 8px;
background-color: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 2px;
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
}
.size-input:focus {
outline: 1px solid var(--vscode-focusBorder);
outline-offset: -1px;
}
.web-preview-content {
flex: 1;
position: relative;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--vscode-editor-background);
padding: 16px;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: var(--vscode-editor-foreground);
z-index: 10;
}
.preview-iframe {
background-color: white;
border: 1px solid var(--vscode-widget-border);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition:
width 0.3s ease,
height 0.3s ease;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
color: var(--vscode-descriptionForeground);
}
.empty-state .codicon {
font-size: 48px;
opacity: 0.5;
}
.element-info {
position: absolute;
bottom: 16px;
right: 16px;
background-color: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-widget-border);
border-radius: 4px;
padding: 12px;
max-width: 400px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.element-info h4 {
margin: 0 0 8px 0;
font-size: 14px;
font-weight: 600;
color: var(--vscode-editor-foreground);
}
.info-item {
margin: 4px 0;
font-size: 12px;
color: var(--vscode-descriptionForeground);
}
.info-item strong {
color: var(--vscode-editor-foreground);
margin-right: 4px;
}
.info-item code {
background-color: var(--vscode-textCodeBlock-background);
padding: 2px 4px;
border-radius: 2px;
font-family: var(--vscode-editor-font-family);
font-size: 11px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.web-preview-toolbar {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.toolbar-section {
width: 100%;
}
.url-input {
min-width: unset;
}
}
/* Animation for loading spinner */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.codicon-loading.codicon-modifier-spin {
animation: spin 1s linear infinite;
}

View file

@ -0,0 +1,411 @@
import React, { useEffect, useRef, useState, useCallback } from "react"
import { VSCodeButton, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
import { vscode } from "../../utils/vscode"
import "./WebPreview.css"
interface ElementContext {
html: string
css: string
xpath: string
selector: string
position: {
x: number
y: number
width: number
height: number
}
computedStyles?: Record<string, string>
attributes?: Record<string, string>
}
interface PreviewState {
url?: string
isLoading: boolean
selectedElement?: ElementContext
viewportSize: { width: number; height: number }
deviceMode: "desktop" | "tablet" | "mobile" | "custom"
}
const WebPreview: React.FC = () => {
const iframeRef = useRef<HTMLIFrameElement>(null)
const [state, setState] = useState<PreviewState>({
isLoading: false,
viewportSize: { width: 1200, height: 800 },
deviceMode: "desktop",
})
const [urlInput, setUrlInput] = useState("")
const [isSelectionMode, setIsSelectionMode] = useState(false)
const [highlightedElement, setHighlightedElement] = useState<HTMLElement | null>(null)
// Handle messages from extension
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
switch (message.type) {
case "updateState":
setState(message.state)
if (message.state.url) {
setUrlInput(message.state.url)
}
break
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
// Navigate to URL
const handleNavigate = useCallback(() => {
if (urlInput) {
vscode.postMessage({ type: "navigateToUrl", url: urlInput })
}
}, [urlInput])
// Handle device mode change
const handleDeviceModeChange = useCallback((e: any) => {
const mode = e.target.value as PreviewState["deviceMode"]
vscode.postMessage({ type: "setDeviceMode", deviceMode: mode })
}, [])
// Handle viewport size change
const handleViewportResize = useCallback((width: number, height: number) => {
vscode.postMessage({ type: "setViewportSize", width, height })
}, [])
// Refresh preview
const handleRefresh = useCallback(() => {
vscode.postMessage({ type: "refreshPreview" })
}, [])
// Extract element context
const extractElementContext = useCallback((element: HTMLElement): ElementContext => {
const rect = element.getBoundingClientRect()
const computedStyles = window.getComputedStyle(element)
// Get important computed styles
const importantStyles: Record<string, string> = {}
const stylesToCapture = [
"display",
"position",
"width",
"height",
"margin",
"padding",
"color",
"background-color",
"font-size",
"font-family",
"font-weight",
"border",
"z-index",
"opacity",
"visibility",
]
stylesToCapture.forEach((style) => {
importantStyles[style] = computedStyles.getPropertyValue(style)
})
// Get attributes
const attributes: Record<string, string> = {}
Array.from(element.attributes).forEach((attr) => {
attributes[attr.name] = attr.value
})
// Generate CSS selector
const generateSelector = (el: HTMLElement): string => {
const path: string[] = []
let current: HTMLElement | null = el
while (current && current !== document.body) {
let selector = current.tagName.toLowerCase()
if (current.id) {
selector = `#${current.id}`
path.unshift(selector)
break
} else if (current.className) {
const classes = Array.from(current.classList)
.filter((c) => c)
.join(".")
if (classes) {
selector += `.${classes}`
}
}
// Add nth-child if needed
const parent = current.parentElement
if (parent) {
const siblings = Array.from(parent.children).filter((child) => child.tagName === current!.tagName)
if (siblings.length > 1) {
const index = siblings.indexOf(current) + 1
selector += `:nth-child(${index})`
}
}
path.unshift(selector)
current = current.parentElement
}
return path.join(" > ")
}
// Generate XPath
const generateXPath = (el: HTMLElement): string => {
const path: string[] = []
let current: HTMLElement | null = el
while (current && current !== document.body) {
let index = 0
let sibling = current.previousSibling
while (sibling) {
if (
sibling.nodeType === Node.ELEMENT_NODE &&
(sibling as HTMLElement).tagName === current.tagName
) {
index++
}
sibling = sibling.previousSibling
}
const tagName = current.tagName.toLowerCase()
const xpathIndex = index > 0 ? `[${index + 1}]` : ""
path.unshift(`${tagName}${xpathIndex}`)
current = current.parentElement
}
return `//${path.join("/")}`
}
return {
html: element.outerHTML.substring(0, 200) + (element.outerHTML.length > 200 ? "..." : ""),
css: generateSelector(element),
xpath: generateXPath(element),
selector: generateSelector(element),
position: {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
},
computedStyles: importantStyles,
attributes,
}
}, [])
// Handle element selection in iframe
const setupIframeInteraction = useCallback(() => {
const iframe = iframeRef.current
if (!iframe || !iframe.contentWindow) return
try {
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document
// Remove any existing highlight
const existingHighlight = iframeDoc.getElementById("roo-preview-highlight")
if (existingHighlight) {
existingHighlight.remove()
}
// Add styles for highlighting
if (!iframeDoc.getElementById("roo-preview-styles")) {
const style = iframeDoc.createElement("style")
style.id = "roo-preview-styles"
style.textContent = `
.roo-preview-highlight {
outline: 2px solid #007ACC !important;
outline-offset: 2px !important;
background-color: rgba(0, 122, 204, 0.1) !important;
cursor: pointer !important;
}
.roo-preview-selection-mode * {
cursor: crosshair !important;
}
`
iframeDoc.head.appendChild(style)
}
// Toggle selection mode class on body
if (isSelectionMode) {
iframeDoc.body.classList.add("roo-preview-selection-mode")
} else {
iframeDoc.body.classList.remove("roo-preview-selection-mode")
}
// Mouse move handler for highlighting
const handleMouseMove = (e: MouseEvent) => {
if (!isSelectionMode) return
const target = e.target as HTMLElement
if (target === highlightedElement) return
// Remove previous highlight
if (highlightedElement) {
highlightedElement.classList.remove("roo-preview-highlight")
}
// Add new highlight
target.classList.add("roo-preview-highlight")
setHighlightedElement(target)
}
// Click handler for selection
const handleClick = (e: MouseEvent) => {
if (!isSelectionMode) return
e.preventDefault()
e.stopPropagation()
const target = e.target as HTMLElement
const context = extractElementContext(target)
// Send element context to extension
vscode.postMessage({ type: "elementSelected", elementContext: context })
// Exit selection mode
setIsSelectionMode(false)
if (highlightedElement) {
highlightedElement.classList.remove("roo-preview-highlight")
setHighlightedElement(null)
}
}
// Add event listeners
iframeDoc.addEventListener("mousemove", handleMouseMove)
iframeDoc.addEventListener("click", handleClick, true)
// Cleanup function
return () => {
iframeDoc.removeEventListener("mousemove", handleMouseMove)
iframeDoc.removeEventListener("click", handleClick, true)
iframeDoc.body.classList.remove("roo-preview-selection-mode")
if (highlightedElement) {
highlightedElement.classList.remove("roo-preview-highlight")
}
}
} catch (_error) {
// Cross-origin iframe, can't access content
console.warn("Cannot access iframe content due to cross-origin restrictions")
}
}, [isSelectionMode, highlightedElement, extractElementContext])
// Setup iframe interaction when selection mode changes or iframe loads
useEffect(() => {
if (iframeRef.current && state.url) {
const cleanup = setupIframeInteraction()
return cleanup
}
}, [isSelectionMode, state.url, setupIframeInteraction])
return (
<div className="web-preview-container">
<div className="web-preview-toolbar">
<div className="toolbar-section">
<input
type="text"
className="url-input"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleNavigate()}
placeholder="Enter URL..."
/>
<VSCodeButton onClick={handleNavigate} disabled={state.isLoading}>
Go
</VSCodeButton>
<VSCodeButton onClick={handleRefresh} disabled={state.isLoading}>
<span className="codicon codicon-refresh"></span>
</VSCodeButton>
</div>
<div className="toolbar-section">
<VSCodeDropdown value={state.deviceMode} onChange={handleDeviceModeChange}>
<VSCodeOption value="desktop">Desktop (1200x800)</VSCodeOption>
<VSCodeOption value="tablet">Tablet (768x1024)</VSCodeOption>
<VSCodeOption value="mobile">Mobile (375x667)</VSCodeOption>
<VSCodeOption value="custom">Custom</VSCodeOption>
</VSCodeDropdown>
{state.deviceMode === "custom" && (
<div className="custom-size-inputs">
<input
type="number"
className="size-input"
value={state.viewportSize.width}
onChange={(e) =>
handleViewportResize(parseInt(e.target.value), state.viewportSize.height)
}
placeholder="Width"
/>
<span>×</span>
<input
type="number"
className="size-input"
value={state.viewportSize.height}
onChange={(e) =>
handleViewportResize(state.viewportSize.width, parseInt(e.target.value))
}
placeholder="Height"
/>
</div>
)}
</div>
<div className="toolbar-section">
<VSCodeButton
onClick={() => setIsSelectionMode(!isSelectionMode)}
appearance={isSelectionMode ? "primary" : "secondary"}>
<span className="codicon codicon-inspect"></span>
{isSelectionMode ? "Selecting..." : "Select Element"}
</VSCodeButton>
</div>
</div>
<div className="web-preview-content">
{state.isLoading && (
<div className="loading-overlay">
<span className="codicon codicon-loading codicon-modifier-spin"></span>
Loading...
</div>
)}
{state.url ? (
<iframe
ref={iframeRef}
src={state.url}
className="preview-iframe"
style={{
width: `${state.viewportSize.width}px`,
height: `${state.viewportSize.height}px`,
}}
onLoad={() => {
setState((prev) => ({ ...prev, isLoading: false }))
setupIframeInteraction()
}}
/>
) : (
<div className="empty-state">
<span className="codicon codicon-globe"></span>
<p>Enter a URL to preview your web application</p>
</div>
)}
</div>
{state.selectedElement && (
<div className="element-info">
<h4>Selected Element</h4>
<div className="info-item">
<strong>Selector:</strong> <code>{state.selectedElement.selector}</code>
</div>
<div className="info-item">
<strong>Position:</strong> {state.selectedElement.position.x}x{state.selectedElement.position.y}
({state.selectedElement.position.width}x{state.selectedElement.position.height})
</div>
</div>
)}
</div>
)
}
export default WebPreview

View file

@ -0,0 +1,11 @@
import React from "react"
import ReactDOM from "react-dom/client"
import WebPreview from "./components/web-preview/WebPreview"
import "./index.css"
const root = ReactDOM.createRoot(document.getElementById("root") as HTMLElement)
root.render(
<React.StrictMode>
<WebPreview />
</React.StrictMode>,
)

View file

@ -96,6 +96,10 @@ export default defineConfig(({ mode }) => {
reportCompressedSize: false,
sourcemap: true,
rollupOptions: {
input: {
index: resolve(__dirname, "index.html"),
webPreview: resolve(__dirname, "webPreview.html"),
},
output: {
entryFileNames: `assets/[name].js`,
chunkFileNames: (chunkInfo) => {

View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Preview</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/webPreview.tsx"></script>
</body>
</html>