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:
@@ -83,7 +83,6 @@ export const AssistantMessage = memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden w-full">
|
<div className="overflow-hidden w-full">
|
||||||
<>
|
|
||||||
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
|
<div className=" flex gap-2 items-center text-sm text-bolt-elements-textSecondary mb-2">
|
||||||
{(codeContext || chatSummary) && (
|
{(codeContext || chatSummary) && (
|
||||||
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
|
<Popover side="right" align="start" trigger={<div className="i-ph:info" />}>
|
||||||
@@ -154,7 +153,6 @@ export const AssistantMessage = memo(
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
<Markdown append={append} chatMode={chatMode} setChatMode={setChatMode} model={model} provider={provider} html>
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Markdown>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { useStore } from '@nanostores/react';
|
|||||||
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
import { StickToBottom, useStickToBottomContext } from '~/lib/hooks';
|
||||||
import { ChatBox } from './ChatBox';
|
import { ChatBox } from './ChatBox';
|
||||||
import type { DesignScheme } from '~/types/design-scheme';
|
import type { DesignScheme } from '~/types/design-scheme';
|
||||||
|
import type { ElementInfo } from '~/components/workbench/Inspector';
|
||||||
|
|
||||||
const TEXTAREA_MIN_HEIGHT = 76;
|
const TEXTAREA_MIN_HEIGHT = 76;
|
||||||
|
|
||||||
@@ -76,6 +77,8 @@ interface BaseChatProps {
|
|||||||
append?: (message: Message) => void;
|
append?: (message: Message) => void;
|
||||||
designScheme?: DesignScheme;
|
designScheme?: DesignScheme;
|
||||||
setDesignScheme?: (scheme: DesignScheme) => void;
|
setDesignScheme?: (scheme: DesignScheme) => void;
|
||||||
|
selectedElement?: ElementInfo | null;
|
||||||
|
setSelectedElement?: (element: ElementInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||||
@@ -119,6 +122,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
append,
|
append,
|
||||||
designScheme,
|
designScheme,
|
||||||
setDesignScheme,
|
setDesignScheme,
|
||||||
|
selectedElement,
|
||||||
|
setSelectedElement,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
@@ -258,6 +263,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
const handleSendMessage = (event: React.UIEvent, messageInput?: string) => {
|
||||||
if (sendMessage) {
|
if (sendMessage) {
|
||||||
sendMessage(event, messageInput);
|
sendMessage(event, messageInput);
|
||||||
|
setSelectedElement?.(null);
|
||||||
|
|
||||||
if (recognition) {
|
if (recognition) {
|
||||||
recognition.abort(); // Stop current recognition
|
recognition.abort(); // Stop current recognition
|
||||||
@@ -353,7 +359,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
resize="smooth"
|
resize="smooth"
|
||||||
initial="smooth"
|
initial="smooth"
|
||||||
>
|
>
|
||||||
<StickToBottom.Content className="flex flex-col gap-4">
|
<StickToBottom.Content className="flex flex-col gap-4 relative ">
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => {
|
{() => {
|
||||||
return chatStarted ? (
|
return chatStarted ? (
|
||||||
@@ -370,6 +376,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
) : null;
|
) : null;
|
||||||
}}
|
}}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
<ScrollToBottom />
|
||||||
</StickToBottom.Content>
|
</StickToBottom.Content>
|
||||||
<div
|
<div
|
||||||
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
className={classNames('my-auto flex flex-col gap-2 w-full max-w-chat mx-auto z-prompt mb-6', {
|
||||||
@@ -408,7 +415,6 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ScrollToBottom />
|
|
||||||
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
{progressAnnotations && <ProgressCompilation data={progressAnnotations} />}
|
||||||
<ChatBox
|
<ChatBox
|
||||||
isModelSettingsCollapsed={isModelSettingsCollapsed}
|
isModelSettingsCollapsed={isModelSettingsCollapsed}
|
||||||
@@ -449,6 +455,8 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
setChatMode={setChatMode}
|
setChatMode={setChatMode}
|
||||||
designScheme={designScheme}
|
designScheme={designScheme}
|
||||||
setDesignScheme={setDesignScheme}
|
setDesignScheme={setDesignScheme}
|
||||||
|
selectedElement={selectedElement}
|
||||||
|
setSelectedElement={setSelectedElement}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</StickToBottom>
|
</StickToBottom>
|
||||||
@@ -479,6 +487,7 @@ export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
|||||||
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
actionRunner={actionRunner ?? ({} as ActionRunner)}
|
||||||
chatStarted={chatStarted}
|
chatStarted={chatStarted}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
setSelectedElement={setSelectedElement}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -495,13 +504,16 @@ function ScrollToBottom() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
!isAtBottom && (
|
!isAtBottom && (
|
||||||
|
<>
|
||||||
|
<div className="sticky bottom-0 left-0 right-0 bg-gradient-to-t from-bolt-elements-background-depth-1 to-transparent h-20 z-10" />
|
||||||
<button
|
<button
|
||||||
className="absolute z-50 top-[0%] translate-y-[-100%] text-4xl rounded-lg left-[50%] translate-x-[-50%] px-1.5 py-0.5 flex items-center gap-2 bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
|
className="sticky z-50 bottom-0 left-0 right-0 text-4xl rounded-lg px-1.5 py-0.5 flex items-center justify-center mx-auto gap-2 bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor text-bolt-elements-textPrimary text-sm"
|
||||||
onClick={() => scrollToBottom()}
|
onClick={() => scrollToBottom()}
|
||||||
>
|
>
|
||||||
Go to last message
|
Go to last message
|
||||||
<span className="i-ph:arrow-down animate-bounce" />
|
<span className="i-ph:arrow-down animate-bounce" />
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { streamingState } from '~/lib/stores/streaming';
|
|||||||
import { filesToArtifacts } from '~/utils/fileUtils';
|
import { filesToArtifacts } from '~/utils/fileUtils';
|
||||||
import { supabaseConnection } from '~/lib/stores/supabase';
|
import { supabaseConnection } from '~/lib/stores/supabase';
|
||||||
import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
|
import { defaultDesignScheme, type DesignScheme } from '~/types/design-scheme';
|
||||||
|
import type { ElementInfo } from '~/components/workbench/Inspector';
|
||||||
|
|
||||||
const toastAnimation = cssTransition({
|
const toastAnimation = cssTransition({
|
||||||
enter: 'animated fadeInRight',
|
enter: 'animated fadeInRight',
|
||||||
@@ -126,9 +127,6 @@ export const ChatImpl = memo(
|
|||||||
const [fakeLoading, setFakeLoading] = useState(false);
|
const [fakeLoading, setFakeLoading] = useState(false);
|
||||||
const files = useStore(workbenchStore.files);
|
const files = useStore(workbenchStore.files);
|
||||||
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
|
const [designScheme, setDesignScheme] = useState<DesignScheme>(defaultDesignScheme);
|
||||||
|
|
||||||
console.log(designScheme);
|
|
||||||
|
|
||||||
const actionAlert = useStore(workbenchStore.alert);
|
const actionAlert = useStore(workbenchStore.alert);
|
||||||
const deployAlert = useStore(workbenchStore.deployAlert);
|
const deployAlert = useStore(workbenchStore.deployAlert);
|
||||||
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
|
const supabaseConn = useStore(supabaseConnection); // Add this line to get Supabase connection
|
||||||
@@ -137,7 +135,6 @@ export const ChatImpl = memo(
|
|||||||
);
|
);
|
||||||
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
|
const supabaseAlert = useStore(workbenchStore.supabaseAlert);
|
||||||
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
|
const { activeProviders, promptId, autoSelectTemplate, contextOptimizationEnabled } = useSettings();
|
||||||
|
|
||||||
const [model, setModel] = useState(() => {
|
const [model, setModel] = useState(() => {
|
||||||
const savedModel = Cookies.get('selectedModel');
|
const savedModel = Cookies.get('selectedModel');
|
||||||
return savedModel || DEFAULT_MODEL;
|
return savedModel || DEFAULT_MODEL;
|
||||||
@@ -146,14 +143,11 @@ export const ChatImpl = memo(
|
|||||||
const savedProvider = Cookies.get('selectedProvider');
|
const savedProvider = Cookies.get('selectedProvider');
|
||||||
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
|
return (PROVIDER_LIST.find((p) => p.name === savedProvider) || DEFAULT_PROVIDER) as ProviderInfo;
|
||||||
});
|
});
|
||||||
|
|
||||||
const { showChat } = useStore(chatStore);
|
const { showChat } = useStore(chatStore);
|
||||||
|
|
||||||
const [animationScope, animate] = useAnimate();
|
const [animationScope, animate] = useAnimate();
|
||||||
|
|
||||||
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build');
|
const [chatMode, setChatMode] = useState<'discuss' | 'build'>('build');
|
||||||
|
const [selectedElement, setSelectedElement] = useState<ElementInfo | null>(null);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -318,8 +312,14 @@ export const ChatImpl = memo(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no locked items, proceed normally with the original message
|
let finalMessageContent = messageContent;
|
||||||
const finalMessageContent = messageContent;
|
|
||||||
|
if (selectedElement) {
|
||||||
|
console.log('Selected Element:', selectedElement);
|
||||||
|
|
||||||
|
const elementInfo = `<div class=\"__boltSelectedElement__\" data-element='${JSON.stringify(selectedElement)}'>${JSON.stringify(`${selectedElement.displayText}`)}</div>`;
|
||||||
|
finalMessageContent = messageContent + elementInfo;
|
||||||
|
}
|
||||||
|
|
||||||
runAnimation();
|
runAnimation();
|
||||||
|
|
||||||
@@ -577,6 +577,8 @@ export const ChatImpl = memo(
|
|||||||
append={append}
|
append={append}
|
||||||
designScheme={designScheme}
|
designScheme={designScheme}
|
||||||
setDesignScheme={setDesignScheme}
|
setDesignScheme={setDesignScheme}
|
||||||
|
selectedElement={selectedElement}
|
||||||
|
setSelectedElement={setSelectedElement}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import styles from './BaseChat.module.scss';
|
|||||||
import type { ProviderInfo } from '~/types/model';
|
import type { ProviderInfo } from '~/types/model';
|
||||||
import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog';
|
import { ColorSchemeDialog } from '~/components/ui/ColorSchemeDialog';
|
||||||
import type { DesignScheme } from '~/types/design-scheme';
|
import type { DesignScheme } from '~/types/design-scheme';
|
||||||
|
import type { ElementInfo } from '~/components/workbench/Inspector';
|
||||||
|
|
||||||
interface ChatBoxProps {
|
interface ChatBoxProps {
|
||||||
isModelSettingsCollapsed: boolean;
|
isModelSettingsCollapsed: boolean;
|
||||||
@@ -57,6 +58,8 @@ interface ChatBoxProps {
|
|||||||
setChatMode?: (mode: 'discuss' | 'build') => void;
|
setChatMode?: (mode: 'discuss' | 'build') => void;
|
||||||
designScheme?: DesignScheme;
|
designScheme?: DesignScheme;
|
||||||
setDesignScheme?: (scheme: DesignScheme) => void;
|
setDesignScheme?: (scheme: DesignScheme) => void;
|
||||||
|
selectedElement?: ElementInfo | null;
|
||||||
|
setSelectedElement?: ((element: ElementInfo | null) => void) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatBox: React.FC<ChatBoxProps> = (props) => {
|
export const ChatBox: React.FC<ChatBoxProps> = (props) => {
|
||||||
@@ -146,6 +149,22 @@ export const ChatBox: React.FC<ChatBoxProps> = (props) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
|
{props.selectedElement && (
|
||||||
|
<div className="flex mx-1.5 gap-2 items-center justify-between rounded-lg rounded-b-none border border-b-none border-bolt-elements-borderColor text-bolt-elements-textPrimary flex py-1 px-2.5 font-medium text-xs">
|
||||||
|
<div className="flex gap-2 items-center lowercase">
|
||||||
|
<code className="bg-accent-500 rounded-4px px-1.5 py-1 mr-0.5 text-white">
|
||||||
|
{props?.selectedElement?.tagName}
|
||||||
|
</code>
|
||||||
|
selected for inspection
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="bg-transparent text-accent-500 pointer-auto"
|
||||||
|
onClick={() => props.setSelectedElement?.(null)}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
|
className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -42,6 +42,42 @@ export const Markdown = memo(
|
|||||||
return <Artifact messageId={messageId} />;
|
return <Artifact messageId={messageId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="bg-bolt-elements-background-depth-3 border border-bolt-elements-borderColor rounded-lg p-3 my-2">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="text-xs font-mono bg-bolt-elements-background-depth-2 px-2 py-1 rounded text-bolt-elements-textTer">
|
||||||
|
{elementData?.tagName}
|
||||||
|
</span>
|
||||||
|
{elementData?.className && (
|
||||||
|
<span className="text-xs text-bolt-elements-textSecondary">.{elementData.className}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<code className="block text-sm !text-bolt-elements-textSecondary !bg-bolt-elements-background-depth-2 border border-bolt-elements-borderColor p-2 rounded">
|
||||||
|
{elementData?.displayText}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (className?.includes('__boltThought__')) {
|
if (className?.includes('__boltThought__')) {
|
||||||
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
|
return <ThoughtBox title="Thought process">{children}</ThoughtBox>;
|
||||||
}
|
}
|
||||||
@@ -110,7 +146,12 @@ export const Markdown = memo(
|
|||||||
} else if (type === 'message' && append) {
|
} else if (type === 'message' && append) {
|
||||||
append({
|
append({
|
||||||
id: `quick-action-message-${Date.now()}`,
|
id: `quick-action-message-${Date.now()}`,
|
||||||
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
|
||||||
|
},
|
||||||
|
] as any, // Type assertion to bypass compiler check
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
console.log('Message appended:', message);
|
console.log('Message appended:', message);
|
||||||
@@ -118,7 +159,12 @@ export const Markdown = memo(
|
|||||||
setChatMode('build');
|
setChatMode('build');
|
||||||
append({
|
append({
|
||||||
id: `quick-action-implement-${Date.now()}`,
|
id: `quick-action-implement-${Date.now()}`,
|
||||||
content: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `[Model: ${model}]\n\n[Provider: ${provider?.name}]\n\n${message}`,
|
||||||
|
},
|
||||||
|
] as any, // Type assertion to bypass compiler check
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
} else if (type === 'link' && typeof href === 'string') {
|
} else if (type === 'link' && typeof href === 'string') {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import { useLocation } from '@remix-run/react';
|
|||||||
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
import { db, chatId } from '~/lib/persistence/useChatHistory';
|
||||||
import { forkChat } from '~/lib/persistence/db';
|
import { forkChat } from '~/lib/persistence/db';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useStore } from '@nanostores/react';
|
|
||||||
import { profileStore } from '~/lib/stores/profile';
|
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef } from 'react';
|
||||||
import type { ProviderInfo } from '~/types/model';
|
import type { ProviderInfo } from '~/types/model';
|
||||||
@@ -29,7 +27,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
|||||||
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
|
(props: MessagesProps, ref: ForwardedRef<HTMLDivElement> | undefined) => {
|
||||||
const { id, isStreaming = false, messages = [] } = props;
|
const { id, isStreaming = false, messages = [] } = props;
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const profile = useStore(profileStore);
|
|
||||||
|
|
||||||
const handleRewind = (messageId: string) => {
|
const handleRewind = (messageId: string) => {
|
||||||
const searchParams = new URLSearchParams(location.search);
|
const searchParams = new URLSearchParams(location.search);
|
||||||
@@ -58,7 +55,6 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
|||||||
const { role, content, id: messageId, annotations } = message;
|
const { role, content, id: messageId, annotations } = message;
|
||||||
const isUserMessage = role === 'user';
|
const isUserMessage = role === 'user';
|
||||||
const isFirst = index === 0;
|
const isFirst = index === 0;
|
||||||
const isLast = index === messages.length - 1;
|
|
||||||
const isHidden = annotations?.includes('hidden');
|
const isHidden = annotations?.includes('hidden');
|
||||||
|
|
||||||
if (isHidden) {
|
if (isHidden) {
|
||||||
@@ -68,28 +64,10 @@ export const Messages = forwardRef<HTMLDivElement, MessagesProps>(
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={classNames('flex gap-4 p-3 py-3 w-full rounded-lg', {
|
className={classNames('flex gap-4 py-3 w-full rounded-lg', {
|
||||||
'bg-bolt-elements-messages-background': isUserMessage || !isStreaming || (isStreaming && !isLast),
|
|
||||||
'bg-gradient-to-b from-bolt-elements-messages-background from-30% to-transparent':
|
|
||||||
isStreaming && isLast,
|
|
||||||
'mt-4': !isFirst,
|
'mt-4': !isFirst,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isUserMessage && (
|
|
||||||
<div className="flex items-center justify-center w-[40px] h-[40px] overflow-hidden bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-500 rounded-full shrink-0 self-start">
|
|
||||||
{profile?.avatar ? (
|
|
||||||
<img
|
|
||||||
src={profile.avatar}
|
|
||||||
alt={profile?.username || 'User'}
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
loading="eager"
|
|
||||||
decoding="sync"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="i-ph:user-fill text-2xl" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-col-1 w-full">
|
<div className="grid grid-col-1 w-full">
|
||||||
{isUserMessage ? (
|
{isUserMessage ? (
|
||||||
<UserMessage content={content} />
|
<UserMessage content={content} />
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
import { MODEL_REGEX, PROVIDER_REGEX } from '~/utils/constants';
|
||||||
import { Markdown } from './Markdown';
|
import { Markdown } from './Markdown';
|
||||||
|
import { useStore } from '@nanostores/react';
|
||||||
|
import { profileStore } from '~/lib/stores/profile';
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string | Array<{ type: string; text?: string; image?: string }>;
|
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 textItem = content.find((item) => item.type === 'text');
|
||||||
const textContent = stripMetadata(textItem?.text || '');
|
const textContent = stripMetadata(textItem?.text || '');
|
||||||
const images = content.filter((item) => item.type === 'image' && item.image);
|
const images = content.filter((item) => item.type === 'image' && item.image);
|
||||||
|
const profile = useStore(profileStore);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden flex items-center">
|
<div className="overflow-hidden flex flex-col gap-3 items-center ">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-row items-start justify-center overflow-hidden shrink-0 self-start">
|
||||||
|
{profile?.avatar || profile?.username ? (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<img
|
||||||
|
src={profile.avatar}
|
||||||
|
alt={profile?.username || 'User'}
|
||||||
|
className="w-[25px] h-[25px] object-cover rounded-full"
|
||||||
|
loading="eager"
|
||||||
|
decoding="sync"
|
||||||
|
/>
|
||||||
|
<span className="text-bolt-elements-textPrimary text-sm">
|
||||||
|
{profile?.username ? profile.username : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="i-ph:user-fill text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 bg-accent-500/10 backdrop-blur-sm p-3 py-3 w-auto rounded-lg mr-auto">
|
||||||
{textContent && <Markdown html>{textContent}</Markdown>}
|
{textContent && <Markdown html>{textContent}</Markdown>}
|
||||||
{images.map((item, index) => (
|
{images.map((item, index) => (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const DeployButton = ({ onVercelDeploy, onNetlifyDeploy }: DeployButtonPr
|
|||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
disabled={isDeploying || !activePreview || isStreaming}
|
disabled={isDeploying || !activePreview || isStreaming}
|
||||||
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-bolt-elements-item-contentAccent text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
className="rounded-md items-center justify-center [&:is(:disabled,.disabled)]:cursor-not-allowed [&:is(:disabled,.disabled)]:opacity-60 px-3 py-1.5 text-xs bg-accent-500 text-white hover:text-bolt-elements-item-contentAccent [&:not(:disabled,.disabled)]:hover:bg-bolt-elements-button-primary-backgroundHover outline-accent-500 flex gap-1.7"
|
||||||
>
|
>
|
||||||
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
|
{isDeploying ? `Deploying to ${deployingTo}...` : 'Deploy'}
|
||||||
<span className={classNames('i-ph:caret-down transition-transform')} />
|
<span className={classNames('i-ph:caret-down transition-transform')} />
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [features, setFeatures] = useState<string[]>(designScheme?.features || defaultDesignScheme.features);
|
const [features, setFeatures] = useState<string[]>(designScheme?.features || defaultDesignScheme.features);
|
||||||
|
|
||||||
const [font, setFont] = useState<string[]>(designScheme?.font || defaultDesignScheme.font);
|
const [font, setFont] = useState<string[]>(designScheme?.font || defaultDesignScheme.font);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [activeSection, setActiveSection] = useState<'colors' | 'typography' | 'features'>('colors');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (designScheme) {
|
if (designScheme) {
|
||||||
@@ -29,7 +30,6 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
setFeatures(designScheme.features || defaultDesignScheme.features);
|
setFeatures(designScheme.features || defaultDesignScheme.features);
|
||||||
setFont(designScheme.font || defaultDesignScheme.font);
|
setFont(designScheme.font || defaultDesignScheme.font);
|
||||||
} else {
|
} else {
|
||||||
// Reset to defaults if no designScheme provided
|
|
||||||
setPalette(defaultDesignScheme.palette);
|
setPalette(defaultDesignScheme.palette);
|
||||||
setFeatures(defaultDesignScheme.features);
|
setFeatures(defaultDesignScheme.features);
|
||||||
setFont(defaultDesignScheme.font);
|
setFont(defaultDesignScheme.font);
|
||||||
@@ -37,10 +37,7 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
}, [designScheme]);
|
}, [designScheme]);
|
||||||
|
|
||||||
const handleColorChange = (role: string, value: string) => {
|
const handleColorChange = (role: string, value: string) => {
|
||||||
setPalette((prev) => ({
|
setPalette((prev) => ({ ...prev, [role]: value }));
|
||||||
...prev,
|
|
||||||
[role]: value,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFeatureToggle = (key: string) => {
|
const handleFeatureToggle = (key: string) => {
|
||||||
@@ -51,8 +48,6 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
setFont((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
|
setFont((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
setDesignScheme?.({ palette, features, font });
|
setDesignScheme?.({ palette, features, font });
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
@@ -64,42 +59,36 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
setFont(defaultDesignScheme.font);
|
setFont(defaultDesignScheme.font);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const renderColorSection = () => (
|
||||||
<div>
|
<div className="space-y-4">
|
||||||
<IconButton title="Upload file" className="transition-all" onClick={() => setIsDialogOpen(!isDialogOpen)}>
|
<div className="flex justify-between items-center">
|
||||||
<div className="i-ph:palette text-xl"></div>
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
</IconButton>
|
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
|
||||||
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
Color Palette
|
||||||
<Dialog>
|
</h3>
|
||||||
<div className="p-8 min-w-[380px] max-w-[95vw]">
|
|
||||||
<DialogTitle className="mb-2 text-lg font-bold">Design Palette & Features</DialogTitle>
|
|
||||||
<DialogDescription className="mb-6 text-sm text-bolt-elements-textPrimary">
|
|
||||||
Choose your color palette, typography, and key design features. These will be used as design instructions
|
|
||||||
for the LLM.
|
|
||||||
</DialogDescription>
|
|
||||||
|
|
||||||
<div className="mb-5">
|
|
||||||
<div className="w-full flex justify-between items-center mb-3">
|
|
||||||
<span className="font-semibold text-sm text-bolt-elements-textPrimary">Color Palette</span>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleReset}
|
onClick={handleReset}
|
||||||
className="text-xs bg-transparent text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary flex items-center gap-1 transition-colors"
|
className="text-sm bg-transparent hover:bg-bolt-elements-bg-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary rounded-lg flex items-center gap-2 transition-all duration-200"
|
||||||
>
|
>
|
||||||
<span className="i-ph:arrow-clockwise" />
|
<span className="i-ph:arrow-clockwise text-sm" />
|
||||||
Reset to defaults
|
Reset
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 max-h-48 overflow-y-auto">
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
{paletteRoles.map((role) => (
|
{paletteRoles.map((role) => (
|
||||||
<div
|
<div
|
||||||
key={role.key}
|
key={role.key}
|
||||||
className="flex items-center gap-3 p-2 rounded-lg bg-bolt-elements-background-depth-3 hover:bg-bolt-elements-background-depth-2"
|
className="group flex items-center gap-4 p-4 rounded-xl bg-bolt-elements-bg-depth-3 hover:bg-bolt-elements-bg-depth-2 border border-transparent hover:border-bolt-elements-borderColor transition-all duration-200"
|
||||||
>
|
>
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
className="w-10 h-10 rounded-lg shadow-sm cursor-pointer hover:scale-105 transition-transform"
|
className="w-12 h-12 rounded-xl shadow-md cursor-pointer transition-all duration-200 hover:scale-110 ring-2 ring-transparent hover:ring-bolt-elements-borderColorActive"
|
||||||
style={{ backgroundColor: palette[role.key] }}
|
style={{ backgroundColor: palette[role.key] }}
|
||||||
onClick={() => document.getElementById(`color-input-${role.key}`)?.click()}
|
onClick={() => document.getElementById(`color-input-${role.key}`)?.click()}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`Change ${role.label} color`}
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
id={`color-input-${role.key}`}
|
id={`color-input-${role.key}`}
|
||||||
@@ -109,11 +98,16 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
/>
|
/>
|
||||||
|
<div className="absolute -bottom-1 -right-1 w-4 h-4 bg-bolt-elements-bg-depth-1 rounded-full flex items-center justify-center shadow-sm">
|
||||||
|
<span className="i-ph:pencil-simple text-xs text-bolt-elements-textSecondary" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium text-sm text-bolt-elements-textPrimary">{role.label}</div>
|
<div className="font-semibold text-bolt-elements-textPrimary transition-colors">{role.label}</div>
|
||||||
<div className="text-xs text-bolt-elements-textSecondary truncate">{role.description}</div>
|
<div className="text-sm text-bolt-elements-textSecondary line-clamp-2 leading-relaxed">
|
||||||
<div className="text-xs text-bolt-elements-textSecondary opacity-50 font-mono">
|
{role.description}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-bolt-elements-textTertiary font-mono mt-1 px-2 py-1 bg-bolt-elements-bg-depth-1 rounded-md inline-block">
|
||||||
{palette[role.key]}
|
{palette[role.key]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,153 +115,250 @@ export const ColorSchemeDialog: React.FC<ColorSchemeDialogProps> = ({ setDesignS
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="mb-5">
|
const renderTypographySection = () => (
|
||||||
<div className="w-full flex justify-between items-center mb-3">
|
<div className="space-y-4">
|
||||||
<span className="font-semibold text-sm text-bolt-elements-textPrimary">Typography</span>
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
<span className="text-xs text-bolt-elements-textSecondary flex items-center gap-1">
|
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
|
||||||
<span className="i-ph:arrow-right" />
|
Typography
|
||||||
Scroll for more
|
</h3>
|
||||||
</span>
|
|
||||||
</div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2 px-0.5">
|
|
||||||
{designFonts.map((f) => (
|
{designFonts.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
key={f.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFontToggle(f.key)}
|
onClick={() => handleFontToggle(f.key)}
|
||||||
className={`flex-shrink-0 px-4 py-2 rounded-lg border text-xs font-medium transition-colors shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-300 flex items-center gap-2 min-w-[120px] ${font.includes(f.key) ? 'bg-blue-100 border-blue-400 text-blue-700' : 'bg-gray-50 border-gray-200 text-gray-500 hover:bg-blue-50 hover:border-blue-300'}`}
|
className={`group p-4 rounded-xl border-2 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-bolt-elements-borderColorActive ${
|
||||||
|
font.includes(f.key)
|
||||||
|
? 'bg-bolt-elements-item-backgroundAccent border-bolt-elements-borderColorActive shadow-lg'
|
||||||
|
: 'bg-bolt-elements-background-depth-3 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive hover:bg-bolt-elements-bg-depth-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<div
|
||||||
|
className={`text-2xl font-medium transition-colors ${
|
||||||
|
font.includes(f.key) ? 'text-bolt-elements-item-contentAccent' : 'text-bolt-elements-textPrimary'
|
||||||
|
}`}
|
||||||
style={{ fontFamily: f.key }}
|
style={{ fontFamily: f.key }}
|
||||||
>
|
>
|
||||||
<span className="text-lg" style={{ fontFamily: f.key }}>
|
|
||||||
{f.preview}
|
{f.preview}
|
||||||
</span>
|
</div>
|
||||||
<span>{f.label}</span>
|
<div
|
||||||
|
className={`text-sm font-medium transition-colors ${
|
||||||
|
font.includes(f.key) ? 'text-bolt-elements-item-contentAccent' : 'text-bolt-elements-textSecondary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</div>
|
||||||
|
{font.includes(f.key) && (
|
||||||
|
<div className="w-6 h-6 mx-auto bg-bolt-elements-item-contentAccent rounded-full flex items-center justify-center">
|
||||||
|
<span className="i-ph:check text-white text-sm" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="mb-6">
|
const renderFeaturesSection = () => (
|
||||||
<div className="w-full flex justify-between items-center mb-3">
|
<div className="space-y-4">
|
||||||
<span className="font-semibold text-sm text-bolt-elements-textPrimary">Design Features</span>
|
<h3 className="text-lg font-semibold text-bolt-elements-textPrimary flex items-center gap-2">
|
||||||
<span className="text-xs text-bolt-elements-textSecondary flex items-center gap-1">
|
<div className="w-2 h-2 rounded-full bg-bolt-elements-item-contentAccent"></div>
|
||||||
<span className="i-ph:arrow-right" />
|
Design Features
|
||||||
Scroll for more
|
</h3>
|
||||||
</span>
|
|
||||||
</div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-80 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
<div className="flex gap-4 overflow-x-auto pb-2 px-0.5">
|
|
||||||
{designFeatures.map((f) => {
|
{designFeatures.map((f) => {
|
||||||
const isSelected = features.includes(f.key);
|
const isSelected = features.includes(f.key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={f.key} className="feature-card-container p-2">
|
||||||
<button
|
<button
|
||||||
key={f.key}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleFeatureToggle(f.key)}
|
onClick={() => handleFeatureToggle(f.key)}
|
||||||
className={`
|
className={`group relative w-full p-6 text-sm font-medium transition-all duration-200 bg-bolt-elements-background-depth-3 text-bolt-elements-item-textSecondary ${
|
||||||
group relative px-4 py-2 text-xs font-medium transition-all duration-300
|
|
||||||
focus:outline-none focus:ring-2 focus:ring-purple-300 cursor-pointer
|
|
||||||
transform hover:scale-105 active:scale-95 flex-shrink-0 min-w-[140px]
|
|
||||||
${
|
|
||||||
f.key === 'rounded'
|
f.key === 'rounded'
|
||||||
? isSelected
|
? isSelected
|
||||||
? 'rounded-2xl'
|
? 'rounded-3xl'
|
||||||
: 'rounded-lg hover:rounded-xl'
|
: 'rounded-xl'
|
||||||
: f.key === 'border'
|
: f.key === 'border'
|
||||||
? 'rounded-md'
|
? 'rounded-lg'
|
||||||
: 'rounded-lg'
|
: 'rounded-xl'
|
||||||
}
|
} ${
|
||||||
${
|
|
||||||
f.key === 'border'
|
f.key === 'border'
|
||||||
? isSelected
|
? isSelected
|
||||||
? 'border-2 border-purple-400 bg-purple-50'
|
? 'border-3 border-bolt-elements-borderColorActive bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent'
|
||||||
: 'border-2 border-gray-200 hover:border-purple-300 bg-white'
|
: 'border-2 border-bolt-elements-borderColor hover:border-bolt-elements-borderColorActive text-bolt-elements-textSecondary'
|
||||||
: f.key === 'gradient'
|
: f.key === 'gradient'
|
||||||
? ''
|
? ''
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'bg-purple-100 text-purple-700'
|
? 'bg-bolt-elements-item-backgroundAccent text-bolt-elements-item-contentAccent shadow-lg'
|
||||||
: 'bg-gray-50 hover:bg-purple-50 text-gray-600 hover:text-purple-600'
|
: 'bg-bolt-elements-bg-depth-3 hover:bg-bolt-elements-bg-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary'
|
||||||
}
|
} ${f.key === 'shadow' ? (isSelected ? 'shadow-xl' : 'shadow-lg') : 'shadow-md'}`}
|
||||||
${
|
|
||||||
f.key === 'shadow'
|
|
||||||
? isSelected
|
|
||||||
? 'shadow-md shadow-purple-200'
|
|
||||||
: 'shadow-md hover:shadow-lg'
|
|
||||||
: 'shadow-sm hover:shadow-md'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
...(f.key === 'gradient' && {
|
...(f.key === 'gradient' && {
|
||||||
background: isSelected
|
background: isSelected
|
||||||
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
||||||
: 'linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)',
|
: 'var(--bolt-elements-bg-depth-3)',
|
||||||
color: isSelected ? 'white' : '#6b7280',
|
color: isSelected ? 'white' : 'var(--bolt-elements-textSecondary)',
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Feature preview area */}
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center justify-center w-12 h-12 rounded-xl bg-bolt-elements-bg-depth-1 bg-opacity-20">
|
||||||
{/* Visual preview */}
|
|
||||||
<div className="flex items-center justify-center w-6 h-6">
|
|
||||||
{f.key === 'rounded' && (
|
{f.key === 'rounded' && (
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 bg-current transition-all duration-300 ${
|
className={`w-6 h-6 bg-current transition-all duration-200 ${
|
||||||
isSelected ? 'rounded-full' : 'rounded-sm group-hover:rounded-lg'
|
isSelected ? 'rounded-full' : 'rounded'
|
||||||
} opacity-70`}
|
} opacity-80`}
|
||||||
></div>
|
/>
|
||||||
)}
|
)}
|
||||||
{f.key === 'border' && (
|
{f.key === 'border' && (
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 rounded transition-all duration-300 ${
|
className={`w-6 h-6 rounded-lg transition-all duration-200 ${
|
||||||
isSelected
|
isSelected ? 'border-3 border-current opacity-90' : 'border-2 border-current opacity-70'
|
||||||
? 'border-2 border-current opacity-80'
|
|
||||||
: 'border border-current opacity-60 group-hover:border-2'
|
|
||||||
}`}
|
}`}
|
||||||
></div>
|
/>
|
||||||
)}
|
)}
|
||||||
{f.key === 'gradient' && (
|
{f.key === 'gradient' && (
|
||||||
<div className="w-4 h-4 rounded-sm bg-gradient-to-br from-purple-400 via-pink-400 to-indigo-400 opacity-90 transition-all duration-300 group-hover:scale-110"></div>
|
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-purple-400 via-pink-400 to-indigo-400 opacity-90" />
|
||||||
)}
|
)}
|
||||||
{f.key === 'shadow' && (
|
{f.key === 'shadow' && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className={`w-4 h-4 bg-current rounded transition-all duration-300 ${
|
className={`w-6 h-6 bg-current rounded-lg transition-all duration-200 ${
|
||||||
isSelected ? 'opacity-80' : 'opacity-60'
|
isSelected ? 'opacity-90' : 'opacity-70'
|
||||||
}`}
|
}`}
|
||||||
></div>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-current rounded transition-all duration-300 ${
|
className={`absolute top-1 left-1 w-6 h-6 bg-current rounded-lg transition-all duration-200 ${
|
||||||
isSelected ? 'opacity-30' : 'opacity-20'
|
isSelected ? 'opacity-40' : 'opacity-30'
|
||||||
}`}
|
}`}
|
||||||
></div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Label */}
|
<div className="text-center">
|
||||||
<span className="transition-all duration-300">{f.label}</span>
|
<div className="font-semibold">{f.label}</div>
|
||||||
|
{isSelected && <div className="mt-2 w-8 h-1 bg-current rounded-full mx-auto opacity-60" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hover effect overlay (might replace this) */}
|
|
||||||
<div className="absolute inset-0 bg-purple-400 opacity-0 group-hover:opacity-5 transition-opacity duration-300 rounded-inherit"></div>
|
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
<div className="flex justify-end gap-2">
|
return (
|
||||||
|
<div>
|
||||||
|
<IconButton title="Design Palette" className="transition-all" onClick={() => setIsDialogOpen(!isDialogOpen)}>
|
||||||
|
<div className="i-ph:palette text-xl"></div>
|
||||||
|
</IconButton>
|
||||||
|
|
||||||
|
<DialogRoot open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<Dialog>
|
||||||
|
<div className="py-4 px-4 min-w-[480px] max-w-[90vw] max-h-[85vh] flex flex-col gap-6 overflow-hidden">
|
||||||
|
<div className="">
|
||||||
|
<DialogTitle className="text-2xl font-bold text-bolt-elements-textPrimary">
|
||||||
|
Design Palette & Features
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-bolt-elements-textSecondary leading-relaxed">
|
||||||
|
Customize your color palette, typography, and design features. These preferences will guide the AI in
|
||||||
|
creating designs that match your style.
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Tabs */}
|
||||||
|
<div className="flex gap-1 p-1 bg-bolt-elements-bg-depth-3 rounded-xl">
|
||||||
|
{[
|
||||||
|
{ 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) => (
|
||||||
|
<button
|
||||||
|
key={tab.key}
|
||||||
|
onClick={() => setActiveSection(tab.key as any)}
|
||||||
|
className={`flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||||
|
activeSection === tab.key
|
||||||
|
? 'bg-bolt-elements-background-depth-3 text-bolt-elements-textPrimary shadow-md'
|
||||||
|
: 'bg-bolt-elements-background-depth-2 text-bolt-elements-textSecondary hover:text-bolt-elements-textPrimary hover:bg-bolt-elements-bg-depth-2'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`${tab.icon} text-lg`} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className=" min-h-92 overflow-y-auto">
|
||||||
|
{activeSection === 'colors' && renderColorSection()}
|
||||||
|
{activeSection === 'typography' && renderTypographySection()}
|
||||||
|
{activeSection === 'features' && renderFeaturesSection()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-sm text-bolt-elements-textSecondary">
|
||||||
|
{Object.keys(palette).length} colors • {font.length} fonts • {features.length} features
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
<Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
|
<Button variant="secondary" onClick={() => setIsDialogOpen(false)}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" onClick={handleSave}>
|
<Button
|
||||||
Save
|
variant="ghost"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-bolt-elements-button-primary-background hover:bg-bolt-elements-button-primary-backgroundHover text-bolt-elements-button-primary-text"
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bolt-elements-textTertiary) transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--bolt-elements-textTertiary);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--bolt-elements-textSecondary);
|
||||||
|
}
|
||||||
|
.line-clamp-2 {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.feature-card-container {
|
||||||
|
min-height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.feature-card-container button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
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 { ScreenshotSelector } from './ScreenshotSelector';
|
||||||
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
import { expoUrlAtom } from '~/lib/stores/qrCodeStore';
|
||||||
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
import { ExpoQrModal } from '~/components/workbench/ExpoQrModal';
|
||||||
|
import type { ElementInfo } from './Inspector';
|
||||||
|
|
||||||
type ResizeSide = 'left' | 'right' | null;
|
type ResizeSide = 'left' | 'right' | null;
|
||||||
|
|
||||||
|
interface PreviewProps {
|
||||||
|
setSelectedElement?: (element: ElementInfo | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface WindowSize {
|
interface WindowSize {
|
||||||
name: string;
|
name: string;
|
||||||
width: number;
|
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' },
|
{ 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 iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
const [activePreviewIndex, setActivePreviewIndex] = useState(0);
|
||||||
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
const [isPortDropdownOpen, setIsPortDropdownOpen] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
@@ -61,11 +65,8 @@ export const Preview = memo(() => {
|
|||||||
const [displayPath, setDisplayPath] = useState('/');
|
const [displayPath, setDisplayPath] = useState('/');
|
||||||
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
const [iframeUrl, setIframeUrl] = useState<string | undefined>();
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
|
const [isInspectorMode, setIsInspectorMode] = useState(false);
|
||||||
// Toggle between responsive mode and device mode
|
|
||||||
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
const [isDeviceModeOn, setIsDeviceModeOn] = useState(false);
|
||||||
|
|
||||||
// Use percentage for width
|
|
||||||
const [widthPercent, setWidthPercent] = useState<number>(37.5);
|
const [widthPercent, setWidthPercent] = useState<number>(37.5);
|
||||||
const [currentWidth, setCurrentWidth] = useState<number>(0);
|
const [currentWidth, setCurrentWidth] = useState<number>(0);
|
||||||
|
|
||||||
@@ -618,6 +619,47 @@ export const Preview = memo(() => {
|
|||||||
};
|
};
|
||||||
}, [showDeviceFrameInPreview]);
|
}, [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 (
|
return (
|
||||||
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
|
<div ref={containerRef} className={`w-full h-full flex flex-col relative`}>
|
||||||
{isPortDropdownOpen && (
|
{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
|
<IconButton
|
||||||
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
icon={isFullscreen ? 'i-ph:arrows-in' : 'i-ph:arrows-out'}
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { PushToGitHubDialog } from '~/components/@settings/tabs/connections/comp
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||||
import { usePreviewStore } from '~/lib/stores/previews';
|
import { usePreviewStore } from '~/lib/stores/previews';
|
||||||
import { chatStore } from '~/lib/stores/chat';
|
import { chatStore } from '~/lib/stores/chat';
|
||||||
|
import type { ElementInfo } from './Inspector';
|
||||||
|
|
||||||
interface WorkspaceProps {
|
interface WorkspaceProps {
|
||||||
chatStarted?: boolean;
|
chatStarted?: boolean;
|
||||||
@@ -36,6 +37,7 @@ interface WorkspaceProps {
|
|||||||
gitUrl?: string;
|
gitUrl?: string;
|
||||||
};
|
};
|
||||||
updateChatMestaData?: (metadata: any) => void;
|
updateChatMestaData?: (metadata: any) => void;
|
||||||
|
setSelectedElement?: (element: ElementInfo | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewTransition = { ease: cubicEasingFn };
|
const viewTransition = { ease: cubicEasingFn };
|
||||||
@@ -279,7 +281,7 @@ const FileModifiedDropdown = memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const Workbench = memo(
|
export const Workbench = memo(
|
||||||
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData }: WorkspaceProps) => {
|
({ chatStarted, isStreaming, actionRunner, metadata, updateChatMestaData, setSelectedElement }: WorkspaceProps) => {
|
||||||
renderLogger.trace('Workbench');
|
renderLogger.trace('Workbench');
|
||||||
|
|
||||||
const [isSyncing, setIsSyncing] = useState(false);
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
@@ -487,7 +489,7 @@ export const Workbench = memo(
|
|||||||
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
|
<DiffView fileHistory={fileHistory} setFileHistory={setFileHistory} actionRunner={actionRunner} />
|
||||||
</View>
|
</View>
|
||||||
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
<View initial={{ x: '100%' }} animate={{ x: selectedView === 'preview' ? '0%' : '100%' }}>
|
||||||
<Preview />
|
<Preview setSelectedElement={setSelectedElement} />
|
||||||
</View>
|
</View>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -326,14 +326,6 @@ The year is 2025.
|
|||||||
<design_instructions>
|
<design_instructions>
|
||||||
When creating designs or UIs for applications, follow these guidelines indefinitely this is non-negotiable:
|
When creating designs or UIs for applications, follow these guidelines indefinitely this is non-negotiable:
|
||||||
|
|
||||||
<user_provided_design>
|
|
||||||
USER PROVIDED DESIGN SCHEME:
|
|
||||||
- ALWAYS use the user provided design scheme when creating designs unless the user specifically requests otherwise.
|
|
||||||
FONT: ${JSON.stringify(designScheme?.font)}
|
|
||||||
COLOR PALETTE: ${JSON.stringify(designScheme?.palette)}
|
|
||||||
FEATURES: ${JSON.stringify(designScheme?.features)}
|
|
||||||
</user_provided_design>
|
|
||||||
|
|
||||||
CRITICAL:
|
CRITICAL:
|
||||||
- Always strive for professional, beautiful, and unique designs
|
- Always strive for professional, beautiful, and unique designs
|
||||||
- All designs should be fully featured and worthy of production use
|
- All designs should be fully featured and worthy of production use
|
||||||
@@ -438,6 +430,14 @@ The year is 2025.
|
|||||||
- Use CSS Grid and Flexbox for layouts
|
- Use CSS Grid and Flexbox for layouts
|
||||||
- Implement appropriate container queries when needed
|
- Implement appropriate container queries when needed
|
||||||
- Structure mobile-first designs that progressively enhance for larger screens
|
- Structure mobile-first designs that progressively enhance for larger screens
|
||||||
|
|
||||||
|
<user_provided_design>
|
||||||
|
USER PROVIDED DESIGN SCHEME:
|
||||||
|
- ALWAYS use the user provided design scheme when creating designs ensuring it complies with the professionalism of design instructions below, unless the user specifically requests otherwise.
|
||||||
|
FONT: ${JSON.stringify(designScheme?.font)}
|
||||||
|
COLOR PALETTE: ${JSON.stringify(designScheme?.palette)}
|
||||||
|
FEATURES: ${JSON.stringify(designScheme?.features)}
|
||||||
|
</user_provided_design>
|
||||||
</design_instructions>
|
</design_instructions>
|
||||||
|
|
||||||
<mobile_app_instructions>
|
<mobile_app_instructions>
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ function Content({ children, ...props }: StickToBottomContentProps) {
|
|||||||
<div {...props} ref={context.contentRef}>
|
<div {...props} ref={context.contentRef}>
|
||||||
{typeof children === 'function' ? children(context) : children}
|
{typeof children === 'function' ? children(context) : children}
|
||||||
</div>
|
</div>
|
||||||
|
{/* Blur effect overlay */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ if (!import.meta.env.SSR) {
|
|||||||
|
|
||||||
const { workbenchStore } = await import('~/lib/stores/workbench');
|
const { workbenchStore } = await import('~/lib/stores/workbench');
|
||||||
|
|
||||||
|
const response = await fetch('/inspector-script.js');
|
||||||
|
const inspectorScript = await response.text();
|
||||||
|
await webcontainer.setPreviewScript(inspectorScript);
|
||||||
|
|
||||||
// Listen for preview errors
|
// Listen for preview errors
|
||||||
webcontainer.on('preview-message', (message) => {
|
webcontainer.on('preview-message', (message) => {
|
||||||
console.log('WebContainer preview message:', message);
|
console.log('WebContainer preview message:', message);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ html,
|
|||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background-color: var(--bolt-elements-bg-depth-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ export const defaultDesignScheme: DesignScheme = {
|
|||||||
font: ['sans-serif'],
|
font: ['sans-serif'],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Define the semantic color roles for the UI
|
|
||||||
export const paletteRoles = [
|
export const paletteRoles = [
|
||||||
{
|
{
|
||||||
key: 'primary',
|
key: 'primary',
|
||||||
@@ -84,17 +83,10 @@ export const designFeatures = [
|
|||||||
{ key: 'shadow', label: 'Soft Shadow' },
|
{ key: 'shadow', label: 'Soft Shadow' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add font options for easy reference
|
|
||||||
export const designFonts = [
|
export const designFonts = [
|
||||||
{ key: 'sans-serif', label: 'Sans Serif', preview: 'Aa' },
|
{ key: 'sans-serif', label: 'Sans Serif', preview: 'Aa' },
|
||||||
{ key: 'serif', label: 'Serif', preview: 'Aa' },
|
{ key: 'serif', label: 'Serif', preview: 'Aa' },
|
||||||
{ key: 'monospace', label: 'Monospace', preview: 'Aa' },
|
{ key: 'monospace', label: 'Monospace', preview: 'Aa' },
|
||||||
{ key: 'cursive', label: 'Cursive', preview: 'Aa' },
|
{ key: 'cursive', label: 'Cursive', preview: 'Aa' },
|
||||||
{ key: 'fantasy', label: 'Fantasy', preview: 'Aa' },
|
{ key: 'fantasy', label: 'Fantasy', preview: 'Aa' },
|
||||||
|
|
||||||
/*
|
|
||||||
* Add custom fonts here if needed
|
|
||||||
* { key: 'Inter', label: 'Inter', preview: 'Aa' },
|
|
||||||
* { key: 'Roboto', label: 'Roboto', preview: 'Aa' },
|
|
||||||
*/
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const allowedHTMLElements = [
|
|||||||
'ul',
|
'ul',
|
||||||
'var',
|
'var',
|
||||||
'think',
|
'think',
|
||||||
|
'header',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add custom rehype plugin
|
// Add custom rehype plugin
|
||||||
@@ -85,7 +86,7 @@ const rehypeSanitizeOptions: RehypeSanitizeOptions = {
|
|||||||
div: [
|
div: [
|
||||||
...(defaultSchema.attributes?.div ?? []),
|
...(defaultSchema.attributes?.div ?? []),
|
||||||
'data*',
|
'data*',
|
||||||
['className', '__boltArtifact__', '__boltThought__', '__boltQuickAction'],
|
['className', '__boltArtifact__', '__boltThought__', '__boltQuickAction', '__boltSelectedElement__'],
|
||||||
|
|
||||||
// ['className', '__boltThought__']
|
// ['className', '__boltThought__']
|
||||||
],
|
],
|
||||||
|
|||||||
292
public/inspector-script.js
Normal file
292
public/inspector-script.js
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
(function() {
|
||||||
|
let isInspectorActive = false;
|
||||||
|
let inspectorStyle = null;
|
||||||
|
let currentHighlight = null;
|
||||||
|
|
||||||
|
// Function to get relevant styles
|
||||||
|
function getRelevantStyles(element) {
|
||||||
|
const computedStyles = window.getComputedStyle(element);
|
||||||
|
const relevantProps = [
|
||||||
|
'display', 'position', 'width', 'height', 'margin', 'padding',
|
||||||
|
'border', 'background', 'color', 'font-size', 'font-family',
|
||||||
|
'text-align', 'flex-direction', 'justify-content', 'align-items'
|
||||||
|
];
|
||||||
|
|
||||||
|
const styles = {};
|
||||||
|
relevantProps.forEach(prop => {
|
||||||
|
const value = computedStyles.getPropertyValue(prop);
|
||||||
|
if (value) styles[prop] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create a readable element selector
|
||||||
|
function createReadableSelector(element) {
|
||||||
|
let selector = element.tagName.toLowerCase();
|
||||||
|
|
||||||
|
// Add ID if present
|
||||||
|
if (element.id) {
|
||||||
|
selector += `#${element.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add classes if present
|
||||||
|
let className = '';
|
||||||
|
if (element.className) {
|
||||||
|
if (typeof element.className === 'string') {
|
||||||
|
className = element.className;
|
||||||
|
} else if (element.className.baseVal !== undefined) {
|
||||||
|
className = element.className.baseVal;
|
||||||
|
} else {
|
||||||
|
className = element.className.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (className.trim()) {
|
||||||
|
const classes = className.trim().split(/\s+/).slice(0, 3); // Limit to first 3 classes
|
||||||
|
selector += `.${classes.join('.')}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create element display text
|
||||||
|
function createElementDisplayText(element) {
|
||||||
|
const tagName = element.tagName.toLowerCase();
|
||||||
|
let displayText = `<${tagName}`;
|
||||||
|
|
||||||
|
// Add ID attribute
|
||||||
|
if (element.id) {
|
||||||
|
displayText += ` id="${element.id}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add class attribute (limit to first 3 classes for readability)
|
||||||
|
let className = '';
|
||||||
|
if (element.className) {
|
||||||
|
if (typeof element.className === 'string') {
|
||||||
|
className = element.className;
|
||||||
|
} else if (element.className.baseVal !== undefined) {
|
||||||
|
className = element.className.baseVal;
|
||||||
|
} else {
|
||||||
|
className = element.className.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (className.trim()) {
|
||||||
|
const classes = className.trim().split(/\s+/);
|
||||||
|
const displayClasses = classes.length > 3 ?
|
||||||
|
classes.slice(0, 3).join(' ') + '...' :
|
||||||
|
classes.join(' ');
|
||||||
|
displayText += ` class="${displayClasses}"`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add other important attributes
|
||||||
|
const importantAttrs = ['type', 'name', 'href', 'src', 'alt', 'title'];
|
||||||
|
importantAttrs.forEach(attr => {
|
||||||
|
const value = element.getAttribute(attr);
|
||||||
|
if (value) {
|
||||||
|
const truncatedValue = value.length > 30 ? value.substring(0, 30) + '...' : value;
|
||||||
|
displayText += ` ${attr}="${truncatedValue}"`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
displayText += '>';
|
||||||
|
|
||||||
|
// Add text content preview for certain elements
|
||||||
|
const textElements = ['span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'button', 'a', 'label'];
|
||||||
|
if (textElements.includes(tagName) && element.textContent) {
|
||||||
|
const textPreview = element.textContent.trim().substring(0, 50);
|
||||||
|
if (textPreview) {
|
||||||
|
displayText += textPreview.length < element.textContent.trim().length ?
|
||||||
|
textPreview + '...' : textPreview;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displayText += `</${tagName}>`;
|
||||||
|
|
||||||
|
return displayText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to create element info
|
||||||
|
function createElementInfo(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
tagName: element.tagName,
|
||||||
|
className: getElementClassName(element),
|
||||||
|
id: element.id || '',
|
||||||
|
textContent: element.textContent?.slice(0, 100) || '',
|
||||||
|
styles: getRelevantStyles(element),
|
||||||
|
rect: {
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
top: rect.top,
|
||||||
|
left: rect.left
|
||||||
|
},
|
||||||
|
// Add new readable formats
|
||||||
|
selector: createReadableSelector(element),
|
||||||
|
displayText: createElementDisplayText(element),
|
||||||
|
elementPath: getElementPath(element)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to get element class name consistently
|
||||||
|
function getElementClassName(element) {
|
||||||
|
if (!element.className) return '';
|
||||||
|
|
||||||
|
if (typeof element.className === 'string') {
|
||||||
|
return element.className;
|
||||||
|
} else if (element.className.baseVal !== undefined) {
|
||||||
|
return element.className.baseVal;
|
||||||
|
} else {
|
||||||
|
return element.className.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get element path (breadcrumb)
|
||||||
|
function getElementPath(element) {
|
||||||
|
const path = [];
|
||||||
|
let current = element;
|
||||||
|
|
||||||
|
while (current && current !== document.body && current !== document.documentElement) {
|
||||||
|
let pathSegment = current.tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (current.id) {
|
||||||
|
pathSegment += `#${current.id}`;
|
||||||
|
} else if (current.className) {
|
||||||
|
const className = getElementClassName(current);
|
||||||
|
if (className.trim()) {
|
||||||
|
const firstClass = className.trim().split(/\s+/)[0];
|
||||||
|
pathSegment += `.${firstClass}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.unshift(pathSegment);
|
||||||
|
current = current.parentElement;
|
||||||
|
|
||||||
|
// Limit path length
|
||||||
|
if (path.length >= 5) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(' > ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
function handleMouseMove(e) {
|
||||||
|
if (!isInspectorActive) return;
|
||||||
|
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || target === document.body || target === document.documentElement) return;
|
||||||
|
|
||||||
|
// Remove previous highlight
|
||||||
|
if (currentHighlight) {
|
||||||
|
currentHighlight.classList.remove('inspector-highlight');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add highlight to current element
|
||||||
|
target.classList.add('inspector-highlight');
|
||||||
|
currentHighlight = target;
|
||||||
|
|
||||||
|
const elementInfo = createElementInfo(target);
|
||||||
|
|
||||||
|
// Send message to parent
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'INSPECTOR_HOVER',
|
||||||
|
elementInfo: elementInfo
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
if (!isInspectorActive) return;
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || target === document.body || target === document.documentElement) return;
|
||||||
|
|
||||||
|
const elementInfo = createElementInfo(target);
|
||||||
|
|
||||||
|
// Send message to parent
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'INSPECTOR_CLICK',
|
||||||
|
elementInfo: elementInfo
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseLeave() {
|
||||||
|
if (!isInspectorActive) return;
|
||||||
|
|
||||||
|
// Remove highlight
|
||||||
|
if (currentHighlight) {
|
||||||
|
currentHighlight.classList.remove('inspector-highlight');
|
||||||
|
currentHighlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message to parent
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'INSPECTOR_LEAVE'
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to activate/deactivate inspector
|
||||||
|
function setInspectorActive(active) {
|
||||||
|
isInspectorActive = active;
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
// Add inspector styles
|
||||||
|
if (!inspectorStyle) {
|
||||||
|
inspectorStyle = document.createElement('style');
|
||||||
|
inspectorStyle.textContent = `
|
||||||
|
.inspector-active * {
|
||||||
|
cursor: crosshair !important;
|
||||||
|
}
|
||||||
|
.inspector-highlight {
|
||||||
|
outline: 2px solid #3b82f6 !important;
|
||||||
|
outline-offset: -2px !important;
|
||||||
|
background-color: rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(inspectorStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.classList.add('inspector-active');
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.addEventListener('click', handleClick, true);
|
||||||
|
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||||
|
} else {
|
||||||
|
document.body.classList.remove('inspector-active');
|
||||||
|
|
||||||
|
// Remove highlight
|
||||||
|
if (currentHighlight) {
|
||||||
|
currentHighlight.classList.remove('inspector-highlight');
|
||||||
|
currentHighlight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove event listeners
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||||
|
document.removeEventListener('click', handleClick, true);
|
||||||
|
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||||
|
|
||||||
|
// Remove styles
|
||||||
|
if (inspectorStyle) {
|
||||||
|
inspectorStyle.remove();
|
||||||
|
inspectorStyle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for messages from parent
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.data.type === 'INSPECTOR_ACTIVATE') {
|
||||||
|
setInspectorActive(event.data.active);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-inject if inspector is already active
|
||||||
|
window.parent.postMessage({ type: 'INSPECTOR_READY' }, '*');
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user