diff --git a/app/components/chat/BaseChat.tsx b/app/components/chat/BaseChat.tsx index 1b2beea..1d1803f 100644 --- a/app/components/chat/BaseChat.tsx +++ b/app/components/chat/BaseChat.tsx @@ -31,6 +31,8 @@ import { expoUrlAtom } from '~/lib/stores/qrCodeStore'; import { useStore } from '@nanostores/react'; import { StickToBottom, useStickToBottomContext } from '~/lib/hooks'; import { ChatBox } from './ChatBox'; +import type { DesignScheme } from '~/types/design-scheme'; +import type { ElementInfo } from '~/components/workbench/Inspector'; const TEXTAREA_MIN_HEIGHT = 76; @@ -73,6 +75,10 @@ interface BaseChatProps { chatMode?: 'discuss' | 'build'; setChatMode?: (mode: 'discuss' | 'build') => void; append?: (message: Message) => void; + designScheme?: DesignScheme; + setDesignScheme?: (scheme: DesignScheme) => void; + selectedElement?: ElementInfo | null; + setSelectedElement?: (element: ElementInfo | null) => void; } export const BaseChat = React.forwardRef( @@ -114,6 +120,10 @@ export const BaseChat = React.forwardRef( chatMode, setChatMode, append, + designScheme, + setDesignScheme, + selectedElement, + setSelectedElement, }, ref, ) => { @@ -253,6 +263,7 @@ export const BaseChat = React.forwardRef( const handleSendMessage = (event: React.UIEvent, messageInput?: string) => { if (sendMessage) { sendMessage(event, messageInput); + setSelectedElement?.(null); if (recognition) { recognition.abort(); // Stop current recognition @@ -332,7 +343,7 @@ export const BaseChat = React.forwardRef(
{!chatStarted && ( -
+

Where ideas begin

@@ -348,12 +359,12 @@ export const BaseChat = React.forwardRef( resize="smooth" initial="smooth" > - + {() => { return chatStarted ? ( ( ) : null; }} +
( /> )}
- {progressAnnotations && } ( handleFileUpload={handleFileUpload} chatMode={chatMode} setChatMode={setChatMode} + designScheme={designScheme} + setDesignScheme={setDesignScheme} + selectedElement={selectedElement} + setSelectedElement={setSelectedElement} />
@@ -472,6 +487,7 @@ export const BaseChat = React.forwardRef( actionRunner={actionRunner ?? ({} as ActionRunner)} chatStarted={chatStarted} isStreaming={isStreaming} + setSelectedElement={setSelectedElement} /> )} @@ -488,13 +504,16 @@ function ScrollToBottom() { return ( !isAtBottom && ( - + <> +
+ + ) ); } diff --git a/app/components/chat/Chat.client.tsx b/app/components/chat/Chat.client.tsx index 8a9dc70..be5ff42 100644 --- a/app/components/chat/Chat.client.tsx +++ b/app/components/chat/Chat.client.tsx @@ -27,6 +27,8 @@ import { logStore } from '~/lib/stores/logs'; import { streamingState } from '~/lib/stores/streaming'; import { filesToArtifacts } from '~/utils/fileUtils'; import { supabaseConnection } from '~/lib/stores/supabase'; +import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme'; +import type { ElementInfo } from '~/components/workbench/Inspector'; const toastAnimation = cssTransition({ enter: 'animated fadeInRight', @@ -124,6 +126,7 @@ export const ChatImpl = memo( const [searchParams, setSearchParams] = useSearchParams(); const [fakeLoading, setFakeLoading] = useState(false); const files = useStore(workbenchStore.files); + const [designScheme, setDesignScheme] = useState(defaultDesignScheme); const actionAlert = useStore(workbenchStore.alert); const deployAlert = useStore(workbenchStore.deployAlert); const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection @@ -132,7 +135,6 @@ export const ChatImpl = memo( ); const supabaseAlert = useStore(workbenchStore.supabaseAlert); const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings(); - const [model, setModel] = useState(() => { const savedModel = Cookies.get('selectedModel'); return savedModel || DEFAULT_MODEL; @@ -141,14 +143,11 @@ export const ChatImpl = memo( const savedProvider = Cookies.get('selectedProvider'); return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo; }); - const { showChat } = useStore(chatStore); - const [animationScope, animate] = useAnimate(); - const [apiKeys, setApiKeys] = useState>({}); - const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build'); + const [selectedElement, setSelectedElement] = useState(null); const { messages, isLoading, @@ -170,6 +169,7 @@ export const ChatImpl = memo( promptId, contextOptimization: contextOptimizationEnabled, chatMode, + designScheme, supabase: { isConnected: supabaseConn.isConnected, hasSelectedProject: !!selectedProject, @@ -312,8 +312,14 @@ export const ChatImpl = memo( return; } - // If no locked items, proceed normally with the original message - const finalMessageContent = messageContent; + let finalMessageContent = messageContent; + + if (selectedElement) { + console.log('Selected Element:', selectedElement); + + const elementInfo = `
${JSON.stringify(`${selectedElement.displayText}`)}
`; + finalMessageContent = messageContent + elementInfo; + } runAnimation(); @@ -569,6 +575,10 @@ export const ChatImpl = memo( chatMode={chatMode} setChatMode={setChatMode} append={append} + designScheme={designScheme} + setDesignScheme={setDesignScheme} + selectedElement={selectedElement} + setSelectedElement={setSelectedElement} /> ); }, diff --git a/app/components/chat/ChatBox.tsx b/app/components/chat/ChatBox.tsx index 29fa7c5..99aa835 100644 --- a/app/components/chat/ChatBox.tsx +++ b/app/components/chat/ChatBox.tsx @@ -11,11 +11,13 @@ import { SendButton } from './SendButton.client'; import { IconButton } from '~/components/ui/IconButton'; import { toast } from 'react-toastify'; import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; -import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; import { SupabaseConnection } from './SupabaseConnection'; import { ExpoQrModal } from '~/components/workbench/ExpoQrModal'; import styles from './BaseChat.module.scss'; import type { ProviderInfo } from '~/types/model'; +import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog'; +import type { DesignScheme } from '~/types/design-scheme'; +import type { ElementInfo } from '~/components/workbench/Inspector'; interface ChatBoxProps { isModelSettingsCollapsed: boolean; @@ -54,13 +56,17 @@ interface ChatBoxProps { enhancePrompt?: (() => void) | undefined; chatMode?: 'discuss' | 'build'; setChatMode?: (mode: 'discuss' | 'build') => void; + designScheme?: DesignScheme; + setDesignScheme?: (scheme: DesignScheme) => void; + selectedElement?: ElementInfo | null; + setSelectedElement?: ((element: ElementInfo | null) => void) | undefined; } export const ChatBox: React.FC = (props) => { return (
= (props) => { /> )} + {props.selectedElement && ( +
+
+ + {props?.selectedElement?.tagName} + + selected for inspection +
+ +
+ )}
@@ -237,6 +259,7 @@ export const ChatBox: React.FC = (props) => {
+ props.handleFileUpload()}>
@@ -279,7 +302,6 @@ export const ChatBox: React.FC = (props) => { {props.chatMode === 'discuss' ? Discuss : } )} - {props.chatStarted && {() => }} ; } + if (className?.includes('__boltSelectedElement__')) { + const messageId = node?.properties.dataMessageId as string; + const elementDataAttr = node?.properties.dataElement as string; + + // Parse the element data if it exists + let elementData: any = null; + + if (elementDataAttr) { + try { + elementData = JSON.parse(elementDataAttr); + } catch (e) { + console.error('Failed to parse element data:', e); + } + } + + if (!messageId) { + logger.error(`Invalid message id ${messageId}`); + } + + return ( +
+
+ + {elementData?.tagName} + + {elementData?.className && ( + .{elementData.className} + )} +
+ + {elementData?.displayText} + +
+ ); + } + if (className?.includes('__boltThought__')) { return {children}; } diff --git a/app/components/chat/Messages.client.tsx b/app/components/chat/Messages.client.tsx index f33def3..c98aa4d 100644 --- a/app/components/chat/Messages.client.tsx +++ b/app/components/chat/Messages.client.tsx @@ -7,8 +7,6 @@ import { useLocation } from '@remix-run/react'; import { db, chatId } from '~/lib/persistence/useChatHistory'; import { forkChat } from '~/lib/persistence/db'; import { toast } from 'react-toastify'; -import { useStore } from '@nanostores/react'; -import { profileStore } from '~/lib/stores/profile'; import { forwardRef } from 'react'; import type { ForwardedRef } from 'react'; import type { ProviderInfo } from '~/types/model'; @@ -29,7 +27,6 @@ export const Messages = forwardRef( (props: MessagesProps, ref: ForwardedRef | undefined) => { const { id, isStreaming = false, messages = [] } = props; const location = useLocation(); - const profile = useStore(profileStore); const handleRewind = (messageId: string) => { const searchParams = new URLSearchParams(location.search); @@ -58,7 +55,6 @@ export const Messages = forwardRef( const { role, content, id: messageId, annotations } = message; const isUserMessage = role === 'user'; const isFirst = index === 0; - const isLast = index === messages.length - 1; const isHidden = annotations?.includes('hidden'); if (isHidden) { @@ -68,28 +64,10 @@ export const Messages = forwardRef( return (
- {isUserMessage && ( -
- {profile?.avatar ? ( - {profile?.username - ) : ( -
- )} -
- )}
{isUserMessage ? ( diff --git a/app/components/chat/UserMessage.tsx b/app/components/chat/UserMessage.tsx index 4e3d4ad..56ee3fa 100644 --- a/app/components/chat/UserMessage.tsx +++ b/app/components/chat/UserMessage.tsx @@ -4,6 +4,8 @@ */ import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants'; import { Markdown } from './Markdown'; +import { useStore } from '@nanostores/react'; +import { profileStore } from '~/lib/stores/profile'; interface UserMessageProps { content: string | Array<{ type: string; text?: string; image?: string }>; @@ -14,10 +16,29 @@ export function UserMessage({ content }: UserMessageProps) { const textItem = content.find((item) => item.type === 'text'); const textContent = stripMetadata(textItem?.text || ''); const images = content.filter((item) => item.type === 'image' && item.image); + const profile = useStore(profileStore); return ( -
-
+
+
+ {profile?.avatar || profile?.username ? ( +
+ {profile?.username + + {profile?.username ? profile.username : ''} + +
+ ) : ( +
+ )} +
+
{textContent && {textContent}} {images.map((item, index) => ( void }) => { return ( - - exportChat?.()}> -
-
-
+
+ + + Export + + + + { + workbenchStore.downloadZip(); + }} + > +
+ Download Code +
+ exportChat?.()} + > +
+ Export Chat +
+
+
+
); }; diff --git a/app/components/deploy/DeployButton.tsx b/app/components/deploy/DeployButton.tsx new file mode 100644 index 0000000..50c06d3 --- /dev/null +++ b/app/components/deploy/DeployButton.tsx @@ -0,0 +1,146 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { useStore } from '@nanostores/react'; +import { netlifyConnection } from '~/lib/stores/netlify'; +import { vercelConnection } from '~/lib/stores/vercel'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { streamingState } from '~/lib/stores/streaming'; +import { classNames } from '~/utils/classNames'; +import { useState } from 'react'; +import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; +import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; +import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; +import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; + +interface DeployButtonProps { + onVercelDeploy?: () => Promise; + onNetlifyDeploy?: () => Promise; +} + +export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonProps) => { + const netlifyConn = useStore(netlifyConnection); + const vercelConn = useStore(vercelConnection); + const [activePreviewIndex] = useState(0); + const previews = useStore(workbenchStore.previews); + const activePreview = previews[activePreviewIndex]; + const [isDeploying, setIsDeploying] = useState(false); + const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null); + const isStreaming = useStore(streamingState); + const { handleVercelDeploy } = useVercelDeploy(); + const { handleNetlifyDeploy } = useNetlifyDeploy(); + + const handleVercelDeployClick = async () => { + setIsDeploying(true); + setDeployingTo('vercel'); + + try { + if (onVercelDeploy) { + await onVercelDeploy(); + } else { + await handleVercelDeploy(); + } + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + + const handleNetlifyDeployClick = async () => { + setIsDeploying(true); + setDeployingTo('netlify'); + + try { + if (onNetlifyDeploy) { + await onNetlifyDeploy(); + } else { + await handleNetlifyDeploy(); + } + } finally { + setIsDeploying(false); + setDeployingTo(null); + } + }; + + return ( +
+ + + {isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'} + + + + + + {!netlifyConn.user ? 'No Netlify Account Connected' : 'Deploy to Netlify'} + {netlifyConn.user && } + + + + vercel + {!vercelConn.user ? 'No Vercel Account Connected' : 'Deploy to Vercel'} + {vercelConn.user && } + + + + cloudflare + Deploy to Cloudflare (Coming Soon) + + + +
+ ); +}; diff --git a/app/components/header/Header.tsx b/app/components/header/Header.tsx index ce46702..1d509ce 100644 --- a/app/components/header/Header.tsx +++ b/app/components/header/Header.tsx @@ -10,7 +10,7 @@ export function Header() { return (
{() => ( -
- +
+
)} diff --git a/app/components/header/HeaderActionButtons.client.tsx b/app/components/header/HeaderActionButtons.client.tsx index ff211f3..5fe19a5 100644 --- a/app/components/header/HeaderActionButtons.client.tsx +++ b/app/components/header/HeaderActionButtons.client.tsx @@ -1,206 +1,28 @@ import { useStore } from '@nanostores/react'; -import useViewport from '~/lib/hooks'; -import { chatStore } from '~/lib/stores/chat'; -import { netlifyConnection } from '~/lib/stores/netlify'; -import { vercelConnection } from '~/lib/stores/vercel'; import { workbenchStore } from '~/lib/stores/workbench'; -import { classNames } from '~/utils/classNames'; -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { streamingState } from '~/lib/stores/streaming'; -import { NetlifyDeploymentLink } from '~/components/chat/NetlifyDeploymentLink.client'; -import { VercelDeploymentLink } from '~/components/chat/VercelDeploymentLink.client'; -import { useVercelDeploy } from '~/components/deploy/VercelDeploy.client'; -import { useNetlifyDeploy } from '~/components/deploy/NetlifyDeploy.client'; +import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; +import { useChatHistory } from '~/lib/persistence'; +import { DeployButton } from '~/components/deploy/DeployButton'; -interface HeaderActionButtonsProps {} +interface HeaderActionButtonsProps { + chatStarted: boolean; +} -export function HeaderActionButtons({}: HeaderActionButtonsProps) { - const showWorkbench = useStore(workbenchStore.showWorkbench); - const { showChat } = useStore(chatStore); - const netlifyConn = useStore(netlifyConnection); - const vercelConn = useStore(vercelConnection); +export function HeaderActionButtons({ chatStarted }: HeaderActionButtonsProps) { const [activePreviewIndex] = useState(0); const previews = useStore(workbenchStore.previews); const activePreview = previews[activePreviewIndex]; - const [isDeploying, setIsDeploying] = useState(false); - const [deployingTo, setDeployingTo] = useState<'netlify' | 'vercel' | null>(null); - const isSmallViewport = useViewport(1024); - const canHideChat = showWorkbench || !showChat; - const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const dropdownRef = useRef(null); const isStreaming = useStore(streamingState); - const { handleVercelDeploy } = useVercelDeploy(); - const { handleNetlifyDeploy } = useNetlifyDeploy(); + const { exportChat } = useChatHistory(); - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { - setIsDropdownOpen(false); - } - } - document.addEventListener('mousedown', handleClickOutside); - - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - - const onVercelDeploy = async () => { - setIsDeploying(true); - setDeployingTo('vercel'); - - try { - await handleVercelDeploy(); - } finally { - setIsDeploying(false); - setDeployingTo(null); - } - }; - - const onNetlifyDeploy = async () => { - setIsDeploying(true); - setDeployingTo('netlify'); - - try { - await handleNetlifyDeploy(); - } finally { - setIsDeploying(false); - setDeployingTo(null); - } - }; + const shouldShowButtons = !isStreaming && activePreview; return ( -
-
-
- -
- - {isDropdownOpen && ( -
- - - -
- )} -
-
- -
- -
+
+ {chatStarted && shouldShowButtons && } + {shouldShowButtons && }
); } - -interface ButtonProps { - active?: boolean; - disabled?: boolean; - children?: any; - onClick?: VoidFunction; - className?: string; -} - -function Button({ active = false, disabled = false, children, onClick, className }: ButtonProps) { - return ( - - ); -} diff --git a/app/components/sidebar/Menu.client.tsx b/app/components/sidebar/Menu.client.tsx index 953dfbd..f0e975e 100644 --- a/app/components/sidebar/Menu.client.tsx +++ b/app/components/sidebar/Menu.client.tsx @@ -279,8 +279,8 @@ export const Menu = () => { }, [open, selectionMode]); useEffect(() => { - const enterThreshold = 40; - const exitThreshold = 40; + const enterThreshold = 20; + const exitThreshold = 20; function onMouseMove(event: MouseEvent) { if (isSettingsOpen) { @@ -331,13 +331,13 @@ export const Menu = () => { variants={menuVariants} style={{ width: '340px' }} className={classNames( - 'flex selection-accent flex-col side-menu fixed top-0 h-full', - 'bg-white dark:bg-gray-950 border-r border-gray-100 dark:border-gray-800/50', + 'flex selection-accent flex-col side-menu fixed top-0 h-full rounded-r-2xl', + 'bg-white dark:bg-gray-950 border-r border-bolt-elements-borderColor', 'shadow-sm text-sm', isSettingsOpen ? 'z-40' : 'z-sidebar', )} > -
+
diff --git a/app/components/ui/ColorSchemeDialog.tsx b/app/components/ui/ColorSchemeDialog.tsx new file mode 100644 index 0000000..674edc5 --- /dev/null +++ b/app/components/ui/ColorSchemeDialog.tsx @@ -0,0 +1,378 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogTitle, DialogDescription, DialogRoot } from './Dialog'; +import { Button } from './Button'; +import { IconButton } from './IconButton'; +import type { DesignScheme } from '~/types/design-scheme'; +import { defaultDesignScheme, designFeatures, designFonts, paletteRoles } from '~/types/design-scheme'; + +export interface ColorSchemeDialogProps { + designScheme?: DesignScheme; + setDesignScheme?: (scheme: DesignScheme) => void; +} + +export const ColorSchemeDialog: React.FC = ({ setDesignScheme, designScheme }) => { + const [palette, setPalette] = useState<{ [key: string]: string }>(() => { + if (designScheme?.palette) { + return { ...defaultDesignScheme.palette, ...designScheme.palette }; + } + + return defaultDesignScheme.palette; + }); + + const [features, setFeatures] = useState(designScheme?.features || defaultDesignScheme.features); + const [font, setFont] = useState(designScheme?.font || defaultDesignScheme.font); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [activeSection, setActiveSection] = useState<'colors' | 'typography' | 'features'>('colors'); + + useEffect(() => { + if (designScheme) { + setPalette(() => ({ ...defaultDesignScheme.palette, ...designScheme.palette })); + setFeatures(designScheme.features || defaultDesignScheme.features); + setFont(designScheme.font || defaultDesignScheme.font); + } else { + setPalette(defaultDesignScheme.palette); + setFeatures(defaultDesignScheme.features); + setFont(defaultDesignScheme.font); + } + }, [designScheme]); + + const handleColorChange = (role: string, value: string) => { + setPalette((prev) => ({ ...prev, [role]: value })); + }; + + const handleFeatureToggle = (key: string) => { + setFeatures((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key])); + }; + + const handleFontToggle = (key: string) => { + setFont((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key])); + }; + + const handleSave = () => { + setDesignScheme?.({ palette, features, font }); + setIsDialogOpen(false); + }; + + const handleReset = () => { + setPalette(defaultDesignScheme.palette); + setFeatures(defaultDesignScheme.features); + setFont(defaultDesignScheme.font); + }; + + const renderColorSection = () => ( +
+
+

+
+ Color Palette +

+ +
+ +
+ {paletteRoles.map((role) => ( +
+
+
document.getElementById(`color-input-${role.key}`)?.click()} + role="button" + tabIndex={0} + aria-label={`Change ${role.label} color`} + /> + handleColorChange(role.key, e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + tabIndex={-1} + /> +
+ +
+
+
+
{role.label}
+
+ {role.description} +
+
+ {palette[role.key]} +
+
+
+ ))} +
+
+ ); + + const renderTypographySection = () => ( +
+

+
+ Typography +

+ +
+ {designFonts.map((f) => ( + + ))} +
+
+ ); + + const renderFeaturesSection = () => ( +
+

+
+ Design Features +

+ +
+ {designFeatures.map((f) => { + const isSelected = features.includes(f.key); + + return ( +
+ +
+ ); + })} +
+
+ ); + + return ( +
+ setIsDialogOpen(!isDialogOpen)}> +
+
+ + + +
+
+ + Design Palette & Features + + + Customize your color palette, typography, and design features. These preferences will guide the AI in + creating designs that match your style. + +
+ + {/* Navigation Tabs */} +
+ {[ + { key: 'colors', label: 'Colors', icon: 'i-ph:palette' }, + { key: 'typography', label: 'Typography', icon: 'i-ph:text-aa' }, + { key: 'features', label: 'Features', icon: 'i-ph:magic-wand' }, + ].map((tab) => ( + + ))} +
+ + {/* Content Area */} +
+ {activeSection === 'colors' && renderColorSection()} + {activeSection === 'typography' && renderTypographySection()} + {activeSection === 'features' && renderFeaturesSection()} +
+ + {/* Action Buttons */} +
+
+ {Object.keys(palette).length} colors • {font.length} fonts • {features.length} features +
+
+ + +
+
+
+
+
+ + +
+ ); +}; diff --git a/app/components/workbench/Inspector.tsx b/app/components/workbench/Inspector.tsx new file mode 100644 index 0000000..198380e --- /dev/null +++ b/app/components/workbench/Inspector.tsx @@ -0,0 +1,126 @@ +import { useEffect, useRef, useState } from 'react'; + +interface InspectorProps { + isActive: boolean; + iframeRef: React.RefObject; + onElementSelect: (elementInfo: ElementInfo) => void; +} + +export interface ElementInfo { + displayText: string; + tagName: string; + className: string; + id: string; + textContent: string; + styles: Record; // 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(null); + const overlayRef = useRef(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 && ( +
+ {/* Element info tooltip */} +
+ {hoveredElement.tagName.toLowerCase()} + {hoveredElement.id && `#${hoveredElement.id}`} + {hoveredElement.className && `.${hoveredElement.className.split(' ')[0]}`} +
+
+ )} + + ); +}; diff --git a/app/components/workbench/InspectorPanel.tsx b/app/components/workbench/InspectorPanel.tsx new file mode 100644 index 0000000..e2de986 --- /dev/null +++ b/app/components/workbench/InspectorPanel.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; + +interface ElementInfo { + tagName: string; + className: string; + id: string; + textContent: string; + styles: Record; // 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) => { + 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, + ); + }; + + return ( +
+ {/* Header */} +
+

Element Inspector

+ +
+ + {/* Element Info */} +
+
+
+ {selectedElement.tagName.toLowerCase()} + {selectedElement.id && #{selectedElement.id}} + {selectedElement.className && ( + .{selectedElement.className.split(' ')[0]} + )} +
+ {selectedElement.textContent && ( +
+ "{selectedElement.textContent}" +
+ )} +
+
+ + {/* Tabs */} +
+ {(['styles', 'computed', 'box'] as const).map((tab) => ( + + ))} +
+ + {/* Content */} +
+ {activeTab === 'styles' && ( +
+ {Object.entries(getRelevantStyles(selectedElement.styles)).map(([prop, value]) => ( +
+ {prop}: + {value} +
+ ))} +
+ )} + + {activeTab === 'box' && ( +
+
+ Width: + {Math.round(selectedElement.rect.width)}px +
+
+ Height: + {Math.round(selectedElement.rect.height)}px +
+
+ Top: + {Math.round(selectedElement.rect.top)}px +
+
+ Left: + {Math.round(selectedElement.rect.left)}px +
+
+ )} +
+
+ ); +}; diff --git a/app/components/workbench/Preview.tsx b/app/components/workbench/Preview.tsx index 0d41571..a9917de 100644 --- a/app/components/workbench/Preview.tsx +++ b/app/components/workbench/Preview.tsx @@ -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(null); const containerRef = useRef(null); const inputRef = useRef(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(); 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(37.5); const [currentWidth, setCurrentWidth] = useState(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 (
{isPortDropdownOpen && ( @@ -697,7 +739,14 @@ export const Preview = memo(() => { /> )} - + void; + setSelectedElement?: (element: ElementInfo | null) => void; } const viewTransition = { ease: cubicEasingFn }; @@ -278,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); @@ -294,6 +297,8 @@ export const Workbench = memo( const unsavedFiles = useStore(workbenchStore.unsavedFiles); const files = useStore(workbenchStore.files); const selectedView = useStore(workbenchStore.currentView); + const { showChat } = useStore(chatStore); + const canHideChat = showWorkbench || !showChat; const isSmallViewport = useViewport(1024); @@ -370,7 +375,7 @@ export const Workbench = memo( >
-
+
-
+
+
); } diff --git a/app/lib/persistence/ChatDescription.client.tsx b/app/lib/persistence/ChatDescription.client.tsx index f07c361..b548467 100644 --- a/app/lib/persistence/ChatDescription.client.tsx +++ b/app/lib/persistence/ChatDescription.client.tsx @@ -49,16 +49,14 @@ export function ChatDescription() { {currentDescription} -
-
+