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:
KevIsDev
2025-05-30 13:16:53 +01:00
parent 6c4b4204e3
commit 5838d7121a
20 changed files with 1114 additions and 333 deletions

View 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>
)}
</>
);
};

View 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>
);
};

View File

@@ -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}

View File

@@ -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>