feat: add element inspector with chat integration
- Implement element inspector tool for preview iframe with hover/click detection - Add inspector panel UI to display element details and styles - Integrate selected elements into chat messages for reference - Style improvements for chat messages and scroll behavior - Add inspector script injection to preview iframe - Support element selection and context in chat prompts -Redesign Messgaes, Workbench and Header for a more refined look allowing more workspace in view
This commit is contained in:
126
app/components/workbench/Inspector.tsx
Normal file
126
app/components/workbench/Inspector.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface InspectorProps {
|
||||
isActive: boolean;
|
||||
iframeRef: React.RefObject<HTMLIFrameElement>;
|
||||
onElementSelect: (elementInfo: ElementInfo) => void;
|
||||
}
|
||||
|
||||
export interface ElementInfo {
|
||||
displayText: string;
|
||||
tagName: string;
|
||||
className: string;
|
||||
id: string;
|
||||
textContent: string;
|
||||
styles: Record<string, string>; // Changed from CSSStyleDeclaration
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const Inspector = ({ isActive, iframeRef, onElementSelect }: InspectorProps) => {
|
||||
const [hoveredElement, setHoveredElement] = useState<ElementInfo | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive || !iframeRef.current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
|
||||
// Listen for messages from the iframe
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'INSPECTOR_HOVER') {
|
||||
const elementInfo = event.data.elementInfo;
|
||||
|
||||
// Adjust coordinates relative to iframe position
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
elementInfo.rect.x += iframeRect.x;
|
||||
elementInfo.rect.y += iframeRect.y;
|
||||
elementInfo.rect.top += iframeRect.y;
|
||||
elementInfo.rect.left += iframeRect.x;
|
||||
|
||||
setHoveredElement(elementInfo);
|
||||
} else if (event.data.type === 'INSPECTOR_CLICK') {
|
||||
const elementInfo = event.data.elementInfo;
|
||||
|
||||
// Adjust coordinates relative to iframe position
|
||||
const iframeRect = iframe.getBoundingClientRect();
|
||||
elementInfo.rect.x += iframeRect.x;
|
||||
elementInfo.rect.y += iframeRect.y;
|
||||
elementInfo.rect.top += iframeRect.y;
|
||||
elementInfo.rect.left += iframeRect.x;
|
||||
|
||||
onElementSelect(elementInfo);
|
||||
} else if (event.data.type === 'INSPECTOR_LEAVE') {
|
||||
setHoveredElement(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
// Send activation message to iframe
|
||||
const sendActivationMessage = () => {
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'INSPECTOR_ACTIVATE',
|
||||
active: isActive,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Try to send activation message immediately and on load
|
||||
sendActivationMessage();
|
||||
iframe.addEventListener('load', sendActivationMessage);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
iframe.removeEventListener('load', sendActivationMessage);
|
||||
|
||||
// Deactivate inspector in iframe
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'INSPECTOR_ACTIVATE',
|
||||
active: false,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
}, [isActive, iframeRef, onElementSelect]);
|
||||
|
||||
// Render overlay for hovered element
|
||||
return (
|
||||
<>
|
||||
{isActive && hoveredElement && (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="fixed pointer-events-none z-50 border-2 border-blue-500 bg-blue-500/10"
|
||||
style={{
|
||||
left: hoveredElement.rect.x,
|
||||
top: hoveredElement.rect.y,
|
||||
width: hoveredElement.rect.width,
|
||||
height: hoveredElement.rect.height,
|
||||
}}
|
||||
>
|
||||
{/* Element info tooltip */}
|
||||
<div className="absolute -top-8 left-0 bg-gray-900 text-white text-xs px-2 py-1 rounded whitespace-nowrap">
|
||||
{hoveredElement.tagName.toLowerCase()}
|
||||
{hoveredElement.id && `#${hoveredElement.id}`}
|
||||
{hoveredElement.className && `.${hoveredElement.className.split(' ')[0]}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
146
app/components/workbench/InspectorPanel.tsx
Normal file
146
app/components/workbench/InspectorPanel.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ElementInfo {
|
||||
tagName: string;
|
||||
className: string;
|
||||
id: string;
|
||||
textContent: string;
|
||||
styles: Record<string, string>; // Changed from CSSStyleDeclaration
|
||||
rect: {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
top: number;
|
||||
left: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface InspectorPanelProps {
|
||||
selectedElement: ElementInfo | null;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const InspectorPanel = ({ selectedElement, isVisible, onClose }: InspectorPanelProps) => {
|
||||
const [activeTab, setActiveTab] = useState<'styles' | 'computed' | 'box'>('styles');
|
||||
|
||||
if (!isVisible || !selectedElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getRelevantStyles = (styles: Record<string, string>) => {
|
||||
const relevantProps = [
|
||||
'display',
|
||||
'position',
|
||||
'width',
|
||||
'height',
|
||||
'margin',
|
||||
'padding',
|
||||
'border',
|
||||
'background',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-family',
|
||||
'text-align',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
];
|
||||
|
||||
return relevantProps.reduce(
|
||||
(acc, prop) => {
|
||||
const value = styles[prop];
|
||||
|
||||
if (value) {
|
||||
acc[prop] = value;
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed right-4 top-20 w-80 bg-bolt-elements-bg-depth-1 border border-bolt-elements-borderColor rounded-lg shadow-lg z-40 max-h-[calc(100vh-6rem)] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-3 border-b border-bolt-elements-borderColor">
|
||||
<h3 className="font-medium text-bolt-elements-textPrimary">Element Inspector</h3>
|
||||
<button onClick={onClose} className="text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Element Info */}
|
||||
<div className="p-3 border-b border-bolt-elements-borderColor">
|
||||
<div className="text-sm">
|
||||
<div className="font-mono text-blue-500">
|
||||
{selectedElement.tagName.toLowerCase()}
|
||||
{selectedElement.id && <span className="text-green-500">#{selectedElement.id}</span>}
|
||||
{selectedElement.className && (
|
||||
<span className="text-yellow-500">.{selectedElement.className.split(' ')[0]}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedElement.textContent && (
|
||||
<div className="mt-1 text-bolt-elements-textSecondary text-xs truncate">
|
||||
"{selectedElement.textContent}"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-bolt-elements-borderColor">
|
||||
{(['styles', 'computed', 'box'] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-3 py-2 text-sm capitalize ${
|
||||
activeTab === tab
|
||||
? 'border-b-2 border-blue-500 text-blue-500'
|
||||
: 'text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 overflow-y-auto max-h-96">
|
||||
{activeTab === 'styles' && (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(getRelevantStyles(selectedElement.styles)).map(([prop, value]) => (
|
||||
<div key={prop} className="flex justify-between text-sm">
|
||||
<span className="text-bolt-elements-textSecondary">{prop}:</span>
|
||||
<span className="text-bolt-elements-textPrimary font-mono">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'box' && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-bolt-elements-textSecondary">Width:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.width)}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-bolt-elements-textSecondary">Height:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.height)}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-bolt-elements-textSecondary">Top:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.top)}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-bolt-elements-textSecondary">Left:</span>
|
||||
<span className="text-bolt-elements-textPrimary">{Math.round(selectedElement.rect.left)}px</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,9 +6,14 @@ import { PortDropdown } from './PortDropdown';
|
||||
import { ScreenshotSelector } from './ScreenshotSelector';
|
||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||
import type { ElementInfo } from './Inspector';
|
||||
|
||||
type ResizeSide = 'left' | 'right' | null;
|
||||
|
||||
interface PreviewProps {
|
||||
setSelectedElement?: (element: ElementInfo | null) => void;
|
||||
}
|
||||
|
||||
interface WindowSize {
|
||||
name: string;
|
||||
width: number;
|
||||
@@ -47,11 +52,10 @@ const WINDOW_SIZES: WindowSize[] = [
|
||||
{ name: '4K Display', width: 3840, height: 2160, icon: 'i-ph:monitor', hasFrame: true, frameType: 'desktop' },
|
||||
];
|
||||
|
||||
export const Preview = memo(() => {
|
||||
export const Preview = memo(({ setSelectedElement }: PreviewProps) => {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
@@ -61,11 +65,8 @@ export const Preview = memo(() => {
|
||||
const [displayPath, setDisplayPath] = useState('/');
|
||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
|
||||
// Toggle between responsive mode and device mode
|
||||
const [isInspectorMode, setIsInspectorMode] = useState(false);
|
||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||
|
||||
// Use percentage for width
|
||||
const [widthPercent, setWidthPercent] = useState<number>(37.5);
|
||||
const [currentWidth, setCurrentWidth] = useState<number>(0);
|
||||
|
||||
@@ -618,6 +619,47 @@ export const Preview = memo(() => {
|
||||
};
|
||||
}, [showDeviceFrameInPreview]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.data.type === 'INSPECTOR_READY') {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'INSPECTOR_ACTIVATE',
|
||||
active: isInspectorMode,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
} else if (event.data.type === 'INSPECTOR_CLICK') {
|
||||
const element = event.data.elementInfo;
|
||||
|
||||
navigator.clipboard.writeText(element.displayText).then(() => {
|
||||
setSelectedElement?.(element);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [isInspectorMode]);
|
||||
|
||||
const toggleInspectorMode = () => {
|
||||
const newInspectorMode = !isInspectorMode;
|
||||
setIsInspectorMode(newInspectorMode);
|
||||
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'INSPECTOR_ACTIVATE',
|
||||
active: newInspectorMode,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
|
||||
{isPortDropdownOpen && (
|
||||
@@ -697,7 +739,14 @@ export const Preview = memo(() => {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<IconButton
|
||||
icon="i-ph:cursor-click"
|
||||
onClick={toggleInspectorMode}
|
||||
className={
|
||||
isInspectorMode ? 'bg-bolt-elements-background-depth-3 !text-bolt-elements-item-contentAccent' : ''
|
||||
}
|
||||
title={isInspectorMode ? 'Disable Element Inspector' : 'Enable Element Inspector'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||
onClick={toggleFullscreen}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/comp
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import { usePreviewStore } from '~/lib/stores/previews';
|
||||
import { chatStore } from '~/lib/stores/chat';
|
||||
import type { ElementInfo } from './Inspector';
|
||||
|
||||
interface WorkspaceProps {
|
||||
chatStarted?: boolean;
|
||||
@@ -36,6 +37,7 @@ interface WorkspaceProps {
|
||||
gitUrl?: string;
|
||||
};
|
||||
updateChatMestaData?: (metadata: any) => void;
|
||||
setSelectedElement?: (element: ElementInfo | null) => void;
|
||||
}
|
||||
|
||||
const viewTransition = { ease: cubicEasingFn };
|
||||
@@ -279,7 +281,7 @@ const FileModifiedDropdown = memo(
|
||||
);
|
||||
|
||||
export const Workbench = memo(
|
||||
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => {
|
||||
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
|
||||
renderLogger.trace('Workbench');
|
||||
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
@@ -487,7 +489,7 @@ export const Workbench = memo(
|
||||
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
|
||||
</View>
|
||||
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
||||
<Preview />
|
||||
<Preview setSelectedElement={setSelectedElement} />
|
||||
</View>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user