From 34e835b076c22c0ed8520646cf27c1ffabac4471 Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Mon, 13 Oct 2025 16:05:41 +1100 Subject: [PATCH 1/4] Project breadcrumb + loading text --- .../[id]/_components/canvas/frame/index.tsx | 12 ++++- .../_components/top-bar/new-project-menu.tsx | 15 ++++--- .../top-bar/project-breadcrumb.tsx | 45 +++++++++++-------- .../_components/top-bar/recent-projects.tsx | 18 ++++---- .../[id]/_hooks/use-project-loading.tsx | 31 +++++++++++++ .../src/app/projects/_components/top-bar.tsx | 11 ++++- 6 files changed, 96 insertions(+), 36 deletions(-) create mode 100644 apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index 33d1040f04..ebbc83321d 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -74,10 +74,18 @@ export const FrameView = observer(({ frame, isInDragSelection = false }: { frame }} >
+

+ Starting up your project... this may take a minute or two... +

)} diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx index 9e5f292110..9d1778473e 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/new-project-menu.tsx @@ -19,12 +19,16 @@ import { observer } from 'mobx-react-lite'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { ProjectLoadingType } from '../../_hooks/use-project-loading'; interface NewProjectMenuProps { onShowCloneDialog: (open: boolean) => void; + setLoading: (type: ProjectLoadingType) => void; + clearLoading: () => void; + loadingType: ProjectLoadingType; } -export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuProps) => { +export const NewProjectMenu = observer(({ onShowCloneDialog, setLoading, clearLoading, loadingType }: NewProjectMenuProps) => { const editorEngine = useEditorEngine(); const { data: user } = api.user.get.useQuery(); const { mutateAsync: forkSandbox } = api.sandbox.fork.useMutation(); @@ -32,7 +36,6 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro const { setIsAuthModalOpen } = useAuthContext(); const t = useTranslations(); const router = useRouter(); - const [isCreatingProject, setIsCreatingProject] = useState(false); const handleStartBlankProject = async () => { if (!user?.id) { @@ -42,7 +45,7 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro return; } - setIsCreatingProject(true); + setLoading('creating-blank-project'); try { // Capture screenshot of current project before cleanup try { @@ -88,7 +91,7 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro }); } } finally { - setIsCreatingProject(false); + clearLoading(); } }; @@ -112,11 +115,11 @@ export const NewProjectMenu = observer(({ onShowCloneDialog }: NewProjectMenuPro
- {isCreatingProject ? ( + {loadingType === 'creating-blank-project' ? ( ) : ( diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx index 591577d25f..a98669b513 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/project-breadcrumb.tsx @@ -21,6 +21,7 @@ import { redirect } from 'next/navigation'; import { usePostHog } from 'posthog-js/react'; import { useRef, useState } from 'react'; import { CloneProjectDialog } from '../clone-project-dialog'; +import { useProjectLoading } from '../../_hooks/use-project-loading'; import { NewProjectMenu } from './new-project-menu'; import { RecentProjectsMenu } from './recent-projects'; @@ -34,20 +35,19 @@ export const ProjectBreadcrumb = observer(() => { const t = useTranslations(); const closeTimeoutRef = useRef(null); const [isDropdownOpen, setIsDropdownOpen] = useState(false); - const [isClosingProject, setIsClosingProject] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); const [showCloneDialog, setShowCloneDialog] = useState(false); + const { loadingType, isLoading, setLoading, clearLoading } = useProjectLoading(); async function handleNavigateToProjects(_route?: 'create' | 'import') { try { - setIsClosingProject(true); + setLoading('closing-project'); editorEngine.screenshot.captureScreenshot(); } catch (error) { console.error('Failed to take screenshots:', error); } finally { setTimeout(() => { - setIsClosingProject(false); + clearLoading(); redirect('/projects'); }, 100); } @@ -66,7 +66,7 @@ export const ProjectBreadcrumb = observer(() => { } try { - setIsDownloading(true); + setLoading('downloading-code'); const result = await editorEngine.activeSandbox.downloadFiles(project.name); @@ -93,7 +93,7 @@ export const ProjectBreadcrumb = observer(() => { error: error instanceof Error ? error.message : 'Unknown error', }); } finally { - setIsDownloading(false); + clearLoading(); } } @@ -105,14 +105,19 @@ export const ProjectBreadcrumb = observer(() => { variant='ghost' className="ml-1 px-0 gap-2 text-foreground-onlook text-small hover:text-foreground-active hover:!bg-transparent cursor-pointer group" > -
- + - +
- - {isDownloading + {loadingType === 'downloading-code' ? ( + + ) : ( + + )} + {loadingType === 'downloading-code' ? t(transKeys.projects.actions.downloadingCode) : t(transKeys.projects.actions.downloadCode)}
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx index 1b82f0d6b2..53e3d194cc 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/recent-projects.tsx @@ -16,13 +16,18 @@ import { observer } from 'mobx-react-lite'; import { useTranslations } from 'next-intl'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; +import { ProjectLoadingType } from '../../_hooks/use-project-loading'; -export const RecentProjectsMenu = observer(() => { +interface RecentProjectsMenuProps { + setLoading: (type: ProjectLoadingType) => void; + clearLoading: () => void; +} + +export const RecentProjectsMenu = observer(({ setLoading, clearLoading }: RecentProjectsMenuProps) => { const editorEngine = useEditorEngine(); const currentProjectId = editorEngine.projectId; const router = useRouter(); const t = useTranslations(); - const [loadingProjectId, setLoadingProjectId] = useState(null); const { data: projects, isLoading: isLoadingProjects } = api.project.list.useQuery({ limit: 5, @@ -34,7 +39,7 @@ export const RecentProjectsMenu = observer(() => { || []; const handleProjectClick = async (projectId: string) => { - setLoadingProjectId(projectId); + setLoading('switching-project'); router.push(`${Routes.PROJECT}/${projectId}`); }; @@ -100,15 +105,10 @@ export const RecentProjectsMenu = observer(() => { handleProjectClick(project.id)} - disabled={loadingProjectId === project.id} className="cursor-pointer" >
- {loadingProjectId === project.id ? ( - - ) : ( - - )} + {project.name} diff --git a/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx b/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx new file mode 100644 index 0000000000..4aa9228d04 --- /dev/null +++ b/apps/web/client/src/app/project/[id]/_hooks/use-project-loading.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { useState, useCallback } from 'react'; + +export type ProjectLoadingType = + | 'creating-blank-project' + | 'switching-project' + | 'closing-project' + | 'downloading-code' + | null; + +export const useProjectLoading = () => { + const [loadingType, setLoadingType] = useState(null); + + const setLoading = useCallback((type: ProjectLoadingType) => { + setLoadingType(type); + }, []); + + const clearLoading = useCallback(() => { + setLoadingType(null); + }, []); + + const isLoading = loadingType !== null; + + return { + loadingType, + isLoading, + setLoading, + clearLoading, + }; +}; diff --git a/apps/web/client/src/app/projects/_components/top-bar.tsx b/apps/web/client/src/app/projects/_components/top-bar.tsx index c2fd801a45..2a2006cf90 100644 --- a/apps/web/client/src/app/projects/_components/top-bar.tsx +++ b/apps/web/client/src/app/projects/_components/top-bar.tsx @@ -212,8 +212,17 @@ export const TopBar = ({ searchQuery, onSearchChange }: TopBarProps) => { From e7398d52378215bc9cf129bb44e5197e0186693b Mon Sep 17 00:00:00 2001 From: Daniel R Farrell Date: Mon, 13 Oct 2025 16:58:52 +1100 Subject: [PATCH 2/4] Improve AI scroll arrow styling + Loading messages --- .../[id]/_components/canvas/frame/index.tsx | 39 ++++++++++++++- .../chat-tab/chat-input/chat-context.tsx | 36 +++++++------- .../right-panel/chat-tab/controls.tsx | 48 +++++++++++-------- .../components/ai-elements/conversation.tsx | 4 +- 4 files changed, 88 insertions(+), 39 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx index ebbc83321d..7aae191ebc 100644 --- a/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx +++ b/apps/web/client/src/app/project/[id]/_components/canvas/frame/index.tsx @@ -4,7 +4,7 @@ import { type Frame } from '@onlook/models'; import { Icons } from '@onlook/ui/icons'; import { colors } from '@onlook/ui/tokens'; import { observer } from 'mobx-react-lite'; -import { useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { RightClickMenu } from '../../right-click-menu'; import { GestureScreen } from './gesture'; import { ResizeHandles } from './resize-handles'; @@ -13,10 +13,37 @@ import { useFrameReload } from './use-frame-reload'; import { useSandboxTimeout } from './use-sandbox-timeout'; import { FrameComponent, type IFrameView } from './view'; +const LOADING_MESSAGES = [ + 'Starting up your project...', + 'This may take a minute or two...', + 'Initializing development environment...', + 'Tip: Use SHIFT+Click to add multiple elements on the canvas to your prompt', + 'If you have a large project, it may take a while...', + 'Tip: Click the "Branch" icon to create a new version of your project on the canvas', + 'Preparing the visual editor...', + 'Tip: Double-click on an element to open it up in the code editor', + 'Hang in there... seems like a large project...', + 'Thanks for your patience... standby...', + 'Loading your components and assets...', + 'Tip: Select multiple windows by clicking and dragging on the canvas', + 'Getting everything ready for you...', + 'Give it another minute...', + 'Hmmmmm...', + 'You may want to try refreshing your tab...', + 'Still not loading? Try refreshing your browser...', + 'If you\'re seeing this message, it\'s probably because your project is large...', + 'Onlook is still working on it...', + 'If you\'re seeing this message, it\'s probably because your project is large...', + 'If it\'s still not loading, contact support with the ? button in the bottom left corner', + 'If it\'s still not loading, contact support with the ? button in the bottom left corner', + 'If it\'s still not loading, contact support with the ? button in the bottom left corner', +]; + export const FrameView = observer(({ frame, isInDragSelection = false }: { frame: Frame; isInDragSelection?: boolean }) => { const editorEngine = useEditorEngine(); const iFrameRef = useRef(null); const [isResizing, setIsResizing] = useState(false); + const [messageIndex, setMessageIndex] = useState(0); const { reloadKey, @@ -33,6 +60,14 @@ export const FrameView = observer(({ frame, isInDragSelection = false }: { frame const preloadScriptReady = branchData?.sandbox?.preloadScriptState === PreloadScriptState.INJECTED; const isFrameReady = preloadScriptReady && !(isConnecting && !hasTimedOut); + useEffect(() => { + const interval = setInterval(() => { + setMessageIndex((prev) => (prev + 1) % LOADING_MESSAGES.length); + }, 12000); + + return () => clearInterval(interval); + }, []); + return (

- Starting up your project... this may take a minute or two... + {LOADING_MESSAGES[messageIndex]}

diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx index 6508c00c03..6b3ba378dd 100644 --- a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/chat-input/chat-context.tsx @@ -3,21 +3,17 @@ import { MODEL_MAX_TOKENS, OPENROUTER_MODELS } from '@onlook/models'; import { Context, - ContextCacheUsage, ContextContent, - ContextContentBody, - ContextContentFooter, - ContextContentHeader, - ContextInputUsage, - ContextOutputUsage, - ContextReasoningUsage, ContextTrigger } from '@onlook/ui/ai-elements/context'; +import { Button } from '@onlook/ui/button'; import type { LanguageModelUsage } from 'ai'; import { useMemo } from 'react'; +import { useEditorEngine } from '@/components/store/editor'; export const ChatContextWindow = ({ usage }: { usage: LanguageModelUsage }) => { - const showCost = false; + const editorEngine = useEditorEngine(); + // Hardcoded for now, but should be dynamic based on the model used const maxTokens = MODEL_MAX_TOKENS[OPENROUTER_MODELS.CLAUDE_4_5_SONNET]; const usedTokens = useMemo(() => { @@ -27,6 +23,11 @@ export const ChatContextWindow = ({ usage }: { usage: LanguageModelUsage }) => { return input + cached; }, [usage]); + const handleNewChat = () => { + editorEngine.chat.conversation.startNewConversation(); + editorEngine.chat.focusChatInput(); + }; + return ( { > - - - - - - - - {showCost && } +
+

Context available

+ +
); diff --git a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/controls.tsx b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/controls.tsx index 202f5efabc..ddd4d986a2 100644 --- a/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/controls.tsx +++ b/apps/web/client/src/app/project/[id]/_components/right-panel/chat-tab/controls.tsx @@ -3,11 +3,14 @@ import { Button } from '@onlook/ui/button'; import { Icons } from '@onlook/ui/icons'; import { Tooltip, TooltipContent, TooltipTrigger } from '@onlook/ui/tooltip'; import { observer } from 'mobx-react-lite'; +import { useState } from 'react'; export const ChatControls = observer(() => { const editorEngine = useEditorEngine(); + const [isHovering, setIsHovering] = useState(false); const isStartingNewConversation = editorEngine.chat.conversation.creatingConversation; + const isDisabled = editorEngine.chat.isStreaming || isStartingNewConversation; const handleNewChat = () => { editorEngine.chat.conversation.startNewConversation(); @@ -16,28 +19,35 @@ export const ChatControls = observer(() => { return (
- + - + + + AI is still loading
); diff --git a/packages/ui/src/components/ai-elements/conversation.tsx b/packages/ui/src/components/ai-elements/conversation.tsx index 5573336ca5..22e2da7d39 100644 --- a/packages/ui/src/components/ai-elements/conversation.tsx +++ b/packages/ui/src/components/ai-elements/conversation.tsx @@ -74,13 +74,13 @@ export const ConversationScrollButton = ({ !isAtBottom && ( - +
- + { - const [activeTab, setActiveTab] = useState('all'); const { boxState, handleBoxChange, handleUnitChange, handleIndividualChange } = useBoxControl('radius'); + const areAllCornersEqual = useMemo((): boolean => { + const corners = { + topLeft: boxState.borderTopLeftRadius.num ?? 0, + topRight: boxState.borderTopRightRadius.num ?? 0, + bottomRight: boxState.borderBottomRightRadius.num ?? 0, + bottomLeft: boxState.borderBottomLeftRadius.num ?? 0, + }; + + const values = Object.values(corners); + + return values.every(val => val === values[0]); + }, [boxState.borderTopLeftRadius.num, boxState.borderTopRightRadius.num, boxState.borderBottomRightRadius.num, boxState.borderBottomLeftRadius.num]); + + const [activeTab, setActiveTab] = useState(areAllCornersEqual ? 'all' : 'individual'); + const { isOpen, onOpenChange } = useDropdownControl({ id: 'radius-dropdown' }); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === 'all' && !areAllCornersEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleRadiusChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('borderRadius', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: string) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getRadiusIcon = () => { const topLeft = boxState.borderTopLeftRadius.num ?? 0; const topRight = boxState.borderTopRightRadius.num ?? 0; @@ -101,7 +140,7 @@ export const Radius = observer(() => { const radiusValue = getRadiusDisplay(); return ( - + {
{activeTab === 'all' ? ( handleBoxChange('borderRadius', value.toString())} + value={shouldShowMixed ? 0 : (boxState.borderRadius.num ?? 0)} + onChange={handleRadiusChange} unit={boxState.borderRadius.unit} onUnitChange={(unit) => handleUnitChange('borderRadius', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( void) => { +export const useInputControl = (value: number, onChange?: (value: number) => void, allowNegative: boolean = false, minValue?: number, maxValue?: number, stepSize: number = 0.1) => { const [localValue, setLocalValue] = useState(String(value)); + const isEditingRef = useRef(false); useEffect(() => { - setLocalValue(String(value)); + // Only update local value from prop if not actively editing + if (!isEditingRef.current) { + setLocalValue(String(value)); + } }, [value]); const handleIncrement = (step: number) => { - const currentValue = Number(localValue); - if (!isNaN(currentValue)) { - const newValue = currentValue + step; - setLocalValue(String(newValue)); - debouncedOnChange(newValue); - } + isEditingRef.current = true; + const currentValue = parseFloat(localValue) || 0; + let newValue = allowNegative ? currentValue + step : Math.max(0, currentValue + step); + + // Apply bounds if specified + if (minValue !== undefined) newValue = Math.max(minValue, newValue); + if (maxValue !== undefined) newValue = Math.min(maxValue, newValue); + + // Round to 2 decimal places for display + const roundedValue = Math.round(newValue * 100) / 100; + setLocalValue(String(roundedValue)); + debouncedOnChange(roundedValue); }; const handleChange = (inputValue: string) => { + isEditingRef.current = true; setLocalValue(inputValue); - const numValue = Number(inputValue); + const numValue = parseFloat(inputValue); if (!isNaN(numValue)) { - debouncedOnChange(numValue); + let roundedValue = Math.round(numValue * 100) / 100; + + // Apply bounds if specified + if (minValue !== undefined) roundedValue = Math.max(minValue, roundedValue); + if (maxValue !== undefined) roundedValue = Math.min(maxValue, roundedValue); + + debouncedOnChange(roundedValue); } }; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); - const step = e.shiftKey ? 10 : 1; + const step = e.shiftKey ? stepSize * 10 : stepSize; const direction = e.key === 'ArrowUp' ? 1 : -1; handleIncrement(step * direction); + } else if (e.key === 'Enter') { + e.preventDefault(); + e.currentTarget.blur(); } }; const debouncedOnChange = useMemo( () => debounce((newValue: number) => { onChange?.(newValue); - }, 500), + // Reset editing flag after a delay to allow external updates again + setTimeout(() => { + isEditingRef.current = false; + }, 100); + }, 300), [onChange] ); diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts b/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts index 53c1d049c0..5b438ab3cf 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/hooks/use-text-control.ts @@ -50,17 +50,26 @@ export const useTextControl = () => { textColor: editorEngine.style.selectedStyle?.styles.computed.color ?? DefaultState.textColor, letterSpacing: - editorEngine.style.selectedStyle?.styles.computed.letterSpacing?.toString() ?? - DefaultState.letterSpacing, + parseFloat(editorEngine.style.selectedStyle?.styles.computed.letterSpacing?.toString() ?? DefaultState.letterSpacing).toString() || DefaultState.letterSpacing, capitalization: editorEngine.style.selectedStyle?.styles.computed.textTransform?.toString() ?? DefaultState.capitalization, textDecorationLine: editorEngine.style.selectedStyle?.styles.computed.textDecorationLine?.toString() ?? DefaultState.textDecorationLine, - lineHeight: - editorEngine.style.selectedStyle?.styles.computed.lineHeight?.toString() ?? - DefaultState.lineHeight, + lineHeight: (() => { + const rawValue = editorEngine.style.selectedStyle?.styles.computed.lineHeight?.toString() ?? DefaultState.lineHeight; + // Handle both unitless values (1.5) and pixel values (24px) + let parsed = parseFloat(rawValue); + // If it's a pixel value (like 24px), convert to unitless by dividing by font size + if (rawValue.includes('px')) { + const fontSize = parseInt(editorEngine.style.selectedStyle?.styles.computed.fontSize?.toString() ?? '16'); + parsed = parsed / fontSize; + } + // Clamp to reasonable line height range (0.5 to 5.0) + const clamped = Math.max(0.5, Math.min(5.0, parsed)); + return isNaN(parsed) ? DefaultState.lineHeight : clamped.toString(); + })(), }; }; @@ -71,6 +80,10 @@ export const useTextControl = () => { }, [editorEngine.style.selectedStyle]); const handleFontFamilyChange = (fontFamily: Font) => { + setTextState((prev) => ({ + ...prev, + fontFamily: fontFamily.id, + })); editorEngine.style.updateFontFamily('fontFamily', fontFamily); // Reload all views after a delay to ensure the font is applied setTimeout(async () => { diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-color.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-color.tsx index 695a850cb7..54b89cfcfa 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-color.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-color.tsx @@ -34,7 +34,6 @@ export const InputColor = ({ color, elementStyleKey, onColorChange }: InputColor
-
void; onUnitChange?: (unit: string) => void; + allowNegative?: boolean; + minValue?: number; + maxValue?: number; + stepSize?: number; } -export const InputIcon = ({ value, unit = 'px', icon, onChange, onUnitChange }: InputIconProps) => { +export const InputIcon = ({ value, unit = 'px', icon, onChange, onUnitChange, allowNegative = false, minValue, maxValue, stepSize = 0.1 }: InputIconProps) => { const [unitValue, setUnitValue] = useState(unit); - const { localValue, handleKeyDown, handleChange } = useInputControl(value, onChange); + const { localValue, handleKeyDown, handleChange } = useInputControl(value, onChange, allowNegative, minValue, maxValue, stepSize); const IconComponent = icon ? Icons[icon] : null; diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx index 670fefa872..a7cf46f2ce 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/input-range.tsx @@ -15,6 +15,7 @@ interface InputRangeProps { unit?: string; onChange?: (value: number) => void; onUnitChange?: (unit: string) => void; + displayValue?: string; } export const InputRange = ({ @@ -23,8 +24,10 @@ export const InputRange = ({ unit = 'px', onChange, onUnitChange, + displayValue, }: InputRangeProps) => { const [localValue, setLocalValue] = useState(String(value)); + const [isTyping, setIsTyping] = useState(false); const rangeRef = useRef(null); const [isDragging, setIsDragging] = useState(false); @@ -32,6 +35,7 @@ export const InputRange = ({ const debouncedOnChange = useMemo( () => debounce((newValue: number) => { onChange?.(newValue); + setIsTyping(false); // Reset typing state after onChange fires }, 500), [onChange] ); @@ -53,6 +57,7 @@ export const InputRange = ({ const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; setLocalValue(newValue); + setIsTyping(true); }; const handleBlur = () => { @@ -61,6 +66,7 @@ export const InputRange = ({ debouncedOnChange(numValue); } else { setLocalValue(String(value)); + setIsTyping(false); } }; @@ -77,6 +83,7 @@ export const InputRange = ({ } } else if (e.key === 'Enter') { handleBlur(); + e.currentTarget.blur(); // Unfocus the input } }; @@ -119,23 +126,35 @@ export const InputRange = ({ debouncedOnChange(newValue); }} onMouseDown={handleMouseDown} - className="flex-1 h-3 bg-background-tertiary/50 rounded-full appearance-none cursor-pointer relative + className={`flex-1 h-3 bg-background-tertiary/50 rounded-full appearance-none relative [&::-webkit-slider-runnable-track]:bg-background-tertiary/50 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:h-3 [&::-moz-range-track]:bg-background-tertiary/50 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:h-3 - [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:mt-[-2px] [&::-webkit-slider-thumb]:cursor-grab hover:[&::-webkit-slider-thumb]:bg-white/90 active:[&::-webkit-slider-thumb]:cursor-grabbing - [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:cursor-grab hover:[&::-moz-range-thumb]:bg-white/90 active:[&::-moz-range-thumb]:cursor-grabbing - [&::-ms-thumb]:appearance-none [&::-ms-thumb]:w-4 [&::-ms-thumb]:h-4 [&::-ms-thumb]:rounded-full [&::-ms-thumb]:bg-white [&::-ms-thumb]:cursor-grab hover:[&::-ms-thumb]:bg-white/90 active:[&::-ms-thumb]:cursor-grabbing" + [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:mt-[-2px] [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:cursor-grab hover:[&::-webkit-slider-thumb]:bg-white/90 active:[&::-webkit-slider-thumb]:cursor-grabbing + [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:cursor-grab hover:[&::-moz-range-thumb]:bg-white/90 active:[&::-moz-range-thumb]:cursor-grabbing + [&::-ms-thumb]:appearance-none [&::-ms-thumb]:w-4 [&::-ms-thumb]:h-4 [&::-ms-thumb]:rounded-full [&::-ms-thumb]:bg-white [&::-ms-thumb]:cursor-grab hover:[&::-ms-thumb]:bg-white/90 active:[&::-ms-thumb]:cursor-grabbing + ${displayValue === 'Mixed' ? 'opacity-50 hover:opacity-100 [&::-webkit-slider-thumb]:opacity-0 hover:[&::-webkit-slider-thumb]:opacity-100 [&::-webkit-slider-thumb]:bg-muted-foreground hover:[&::-webkit-slider-thumb]:bg-foreground-primary [&::-moz-range-thumb]:opacity-0 hover:[&::-moz-range-thumb]:opacity-100 [&::-moz-range-thumb]:bg-muted-foreground hover:[&::-moz-range-thumb]:bg-foreground-primary [&::-ms-thumb]:opacity-0 hover:[&::-ms-thumb]:opacity-100 [&::-ms-thumb]:bg-muted-foreground hover:[&::-ms-thumb]:bg-foreground-primary' : 'cursor-pointer'}`} />
{ + if (displayValue) { + e.target.select(); + setLocalValue(''); + setIsTyping(true); + } + }} + className={`min-w-[40px] max-w-[40px] bg-transparent text-sm focus:outline-none input-range-text ${ + displayValue === 'Mixed' + ? 'text-muted-foreground hover:text-white' + : 'text-white' + }`} /> diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx index 1c3c418fb2..a7c438f091 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/inputs/spacing-inputs.tsx @@ -61,6 +61,7 @@ export const SpacingInputs = ({ type, values, onChange }: SpacingInputsProps) => icon={icons[pos]} value={values[pos as keyof typeof values] ?? 0} onChange={(value) => onChange(value, pos)} + stepSize={1} /> ))}
diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx index c770ce7bab..2c5af63544 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/advanced-typography.tsx @@ -77,7 +77,7 @@ export const AdvancedTypography = () => {
-
+
Color
@@ -88,8 +88,11 @@ export const AdvancedTypography = () => { Line
handleLineHeightChange(value.toString())} + minValue={0.2} + maxValue={15.0} + stepSize={0.1} />
@@ -99,6 +102,8 @@ export const AdvancedTypography = () => { handleLetterSpacingChange(value.toString())} + allowNegative={true} + stepSize={0.1} />
diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/font/font-family-selector.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/font/font-family-selector.tsx index 362d7e7af2..882dd011ba 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/font/font-family-selector.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/text-inputs/font/font-family-selector.tsx @@ -7,7 +7,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@onlook/ import { Icons } from '@onlook/ui/icons'; import { toNormalCase } from '@onlook/utility'; import { observer } from 'mobx-react-lite'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { useDropdownControl } from '../../hooks/use-dropdown-manager'; import { useTextControl } from '../../hooks/use-text-control'; import { HoverOnlyTooltip } from '../../hover-tooltip'; @@ -16,16 +16,11 @@ import { FontFamily } from './font-family'; export const FontFamilySelector = observer(() => { const editorEngine = useEditorEngine(); - const [search, setSearch] = useState(''); const { handleFontFamilyChange, textState } = useTextControl(); const { isOpen, onOpenChange } = useDropdownControl({ id: 'font-family-dropdown', }); - const filteredFonts = editorEngine.font.fonts.filter((font) => - font.family.toLowerCase().includes(search.toLowerCase()), - ); - // TODO: use file system like code tab useEffect(() => { if (!editorEngine.activeSandbox.session.provider) { @@ -40,7 +35,6 @@ export const FontFamilySelector = observer(() => { if (editorEngine.state.leftPanelTab === LeftPanelTabValue.BRAND) { editorEngine.state.leftPanelTab = null; } - setSearch(''); }; return ( @@ -69,39 +63,17 @@ export const FontFamilySelector = observer(() => { -
-

Fonts

- -
-
- setSearch(e.target.value)} - placeholder="Search fonts..." - className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" - aria-label="Search fonts" - tabIndex={0} - /> -
Brand fonts
-
-
- {filteredFonts.length === 0 ? ( -
- No fonts found +
+ {editorEngine.font.fonts.length === 0 ? ( +
+ + No fonts found
Add fonts from the Brand Tab
) : ( - filteredFonts.map((font) => ( + editorEngine.font.fonts.map((font) => (
{ onOpenChange(false); }} > - Manage Brand fonts + Browse more fonts
diff --git a/apps/web/client/src/app/project/[id]/_components/top-bar/branch.tsx b/apps/web/client/src/app/project/[id]/_components/top-bar/branch.tsx index 8883e5764c..8cb09d15c1 100644 --- a/apps/web/client/src/app/project/[id]/_components/top-bar/branch.tsx +++ b/apps/web/client/src/app/project/[id]/_components/top-bar/branch.tsx @@ -40,7 +40,7 @@ export const BranchDisplay = observer(() => { - + Date: Mon, 13 Oct 2025 18:22:16 +1100 Subject: [PATCH 4/4] Padding / margin / border multi-side --- .../editor-bar/dropdowns/border.tsx | 36 +++++++++++++++--- .../editor-bar/dropdowns/margin.tsx | 38 ++++++++++++++++--- .../editor-bar/dropdowns/padding.tsx | 38 ++++++++++++++++--- 3 files changed, 95 insertions(+), 17 deletions(-) diff --git a/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx b/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx index 41409d9f4c..edafe53ae6 100644 --- a/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx +++ b/apps/web/client/src/app/project/[id]/_components/editor-bar/dropdowns/border.tsx @@ -40,6 +40,31 @@ export const Border = observer(() => { const [activeTab, setActiveTab] = useState(areAllBordersEqual ? BorderTab.ALL : BorderTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === BorderTab.ALL && !areAllBordersEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleBorderChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('borderWidth', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: BorderTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getBorderDisplay = () => { const top = boxState.borderTopWidth.num ?? 0; const right = boxState.borderRightWidth.num ?? 0; @@ -64,7 +89,7 @@ export const Border = observer(() => { const borderValue = getBorderDisplay() return ( - + { >
{activeTab === BorderTab.ALL ? ( handleBoxChange('borderWidth', value.toString())} + value={shouldShowMixed ? 0 : (boxState.borderWidth.num ?? 0)} + onChange={handleBorderChange} unit={boxState.borderWidth.unit} onUnitChange={(unit) => handleUnitChange('borderWidth', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( { const [activeTab, setActiveTab] = useState(areAllMarginsEqual ? MarginTab.ALL : MarginTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === MarginTab.ALL && !areAllMarginsEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handleMarginChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('margin', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: MarginTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getMarginIcon = () => { const margins = { top: boxState.marginTop.num ?? 0, @@ -148,7 +173,7 @@ export const Margin = observer(() => { const marginValue = getMarginDisplay(); return ( - + { >
{activeTab === MarginTab.ALL ? ( handleBoxChange('margin', value.toString())} + value={shouldShowMixed ? 0 : (boxState.margin.num ?? 0)} + onChange={handleMarginChange} unit={boxState.margin.unit} onUnitChange={(unit) => handleUnitChange('margin', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : ( { const [activeTab, setActiveTab] = useState(areAllPaddingsEqual ? PaddingTab.ALL : PaddingTab.INDIVIDUAL); + // Track if user is actively interacting with the input + const [isUserInteracting, setIsUserInteracting] = useState(false); + + // Determine if we should show "Mixed" in the input when on "All sides" tab + const shouldShowMixed = activeTab === PaddingTab.ALL && !areAllPaddingsEqual && !isUserInteracting; + + // Custom onChange handler that tracks user interaction + const handlePaddingChange = (value: number) => { + setIsUserInteracting(true); + handleBoxChange('padding', value.toString()); + }; + + // Reset interaction state when switching tabs or closing dropdown + const handleTabChange = (tab: PaddingTab) => { + setActiveTab(tab); + setIsUserInteracting(false); + }; + + const handleOpenChange = (open: boolean) => { + onOpenChange(open); + if (!open) { + setIsUserInteracting(false); + } + }; + const getPaddingIcon = () => { const paddings = { top: boxState.paddingTop.num ?? 0, @@ -123,7 +148,7 @@ export const Padding = observer(() => { const paddingValue = getPaddingDisplay(); return ( - + {
{activeTab === PaddingTab.ALL ? ( handleBoxChange('padding', value.toString())} + value={shouldShowMixed ? 0 : (boxState.padding.num ?? 0)} + onChange={handlePaddingChange} unit={boxState.padding.unit} onUnitChange={(unit) => handleUnitChange('padding', unit)} + displayValue={shouldShowMixed ? 'Mixed' : undefined} /> ) : (