diff --git a/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/AutoComplete.tsx b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/AutoComplete.tsx new file mode 100644 index 000000000..617b04d7b --- /dev/null +++ b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/AutoComplete.tsx @@ -0,0 +1,220 @@ +import { cn } from '@onlook/ui/utils'; +import { forwardRef, useImperativeHandle, useState } from 'react'; +import { coreColors, getContextualSuggestions, searchTailwindClasses } from './twClassGen'; + +export interface SuggestionsListRef { + handleInput: (value: string, cursorPosition: number) => void; + handleKeyDown: (e: React.KeyboardEvent) => void; +} + +export const SuggestionsList = forwardRef< + SuggestionsListRef, + { + setClasses: React.Dispatch>; + showSuggestions: boolean; + setShowSuggestions: React.Dispatch>; + currentInput: string; + setCurrentInput: React.Dispatch>; + } +>(({ setClasses, showSuggestions, setShowSuggestions, currentInput, setCurrentInput }, ref) => { + const [suggestions, setSuggestions] = useState([]); + const [selectedSuggestion, setSelectedSuggestion] = useState(0); + const [currentWordInfo, setCurrentWordInfo] = useState<{ + word: string; + startIndex: number; + endIndex: number; + } | null>(null); + + const getWordAtCursor = (value: string, cursorPosition: number) => { + // Find the start of the current word + let startIndex = cursorPosition; + while (startIndex > 0 && value[startIndex - 1] !== ' ') { + startIndex--; + } + + // Find the end of the current word + let endIndex = cursorPosition; + while (endIndex < value.length && value[endIndex] !== ' ') { + endIndex++; + } + + return { + word: value.slice(startIndex, endIndex), + startIndex, + endIndex, + }; + }; + + const parseModifiers = (input: string): { modifiers: string[]; baseClass: string } => { + const parts = input.split(':'); + const baseClass = parts.pop() || ''; + const modifiers = parts; + return { modifiers, baseClass }; + }; + + const reconstructWithModifiers = (modifiers: string[], newBaseClass: string): string => { + return [...modifiers, newBaseClass].join(':'); + }; + + const handleInput = (value: string, cursorPosition: number) => { + setCurrentInput(value); + const wordInfo = getWordAtCursor(value, cursorPosition); + setCurrentWordInfo(wordInfo); + + const filtered = filterSuggestions(value, wordInfo); + setSuggestions(filtered); + setSelectedSuggestion(0); + setShowSuggestions(filtered.length > 0); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!currentWordInfo) { + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedSuggestion((prev) => (prev < suggestions.length - 1 ? prev + 1 : prev)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedSuggestion((prev) => (prev > 0 ? prev - 1 : 0)); + } else if (e.key === 'Tab' || e.key === 'Enter') { + e.preventDefault(); + if (suggestions[selectedSuggestion]) { + const { modifiers } = parseModifiers(currentWordInfo.word); + const newClass = reconstructWithModifiers( + modifiers, + suggestions[selectedSuggestion], + ); + + // Replace only the current word at cursor position + const newValue = + currentInput.slice(0, currentWordInfo.startIndex) + + newClass + + currentInput.slice(currentWordInfo.endIndex); + + setClasses(newValue); + setShowSuggestions(false); + } + } else if (e.key === 'Escape') { + setShowSuggestions(false); + e.currentTarget.blur(); + } + }; + + useImperativeHandle(ref, () => ({ + handleInput, + handleKeyDown, + })); + + const filterSuggestions = ( + input: string, + wordInfo: { word: string; startIndex: number; endIndex: number }, + ) => { + if (!wordInfo.word.trim()) { + return []; + } + + const { baseClass } = parseModifiers(wordInfo.word); + + // Get direct matches based on base class + const searchResults = searchTailwindClasses(baseClass); + + // Get contextual suggestions based on existing classes + const currentClasses = input + .split(' ') + .filter(Boolean) + .map((cls) => { + const { baseClass } = parseModifiers(cls); + return baseClass; + }); + const contextualSuggestions = getContextualSuggestions(currentClasses); + + // Combine and deduplicate results + const combinedResults = Array.from(new Set([...searchResults, ...contextualSuggestions])); + + return combinedResults.slice(0, 10); + }; + + const handleClick = (suggestion: string) => { + if (!currentWordInfo) { + return; + } + + const { modifiers } = parseModifiers(currentWordInfo.word); + const newClass = reconstructWithModifiers(modifiers, suggestion); + + // Replace only the current word at cursor position + const newValue = + currentInput.slice(0, currentWordInfo.startIndex) + + newClass + + currentInput.slice(currentWordInfo.endIndex); + + setClasses(newValue); + setShowSuggestions(false); + }; + + const getColorPreviewValue = (suggestion: string): string => { + const colorPattern = coreColors.join('|'); + const headPattern = + 'bg|text|border|ring|shadow|divide|placeholder|accent|caret|fill|stroke'; + const shadePattern = '\\d+'; + const regex = new RegExp(`(${headPattern})-(${colorPattern})-(${shadePattern})`); + const match = suggestion.match(regex); + if (!match) { + return ''; + } + + try { + const [, , colorName, shade = '500'] = match; + return `var(--color-${colorName}-${shade})`; + } catch (error) { + console.error('Error computing color:', error); + } + + return ''; + }; + + return ( + showSuggestions && + currentWordInfo && ( +
+ {suggestions.map((suggestion, index) => { + const colorClass = getColorPreviewValue(suggestion); + const { modifiers } = parseModifiers(currentWordInfo.word); + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + handleClick(suggestion); + }} + > + + {colorClass && ( +
+ )} + + {modifiers.length > 0 ? `${modifiers.join(':')}:` : ''} + + {suggestion} + +
+ ); + })} +
+ ) + ); +}); + +SuggestionsList.displayName = 'SuggestionsList'; diff --git a/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput.tsx b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/index.tsx similarity index 74% rename from apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput.tsx rename to apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/index.tsx index c1c81d221..d2f9bff06 100644 --- a/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput.tsx +++ b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/index.tsx @@ -7,9 +7,12 @@ import { Icons } from '@onlook/ui/icons'; import { Textarea } from '@onlook/ui/textarea'; import { observer } from 'mobx-react-lite'; import { useEffect, useRef, useState } from 'react'; +import { SuggestionsList, type SuggestionsListRef } from './AutoComplete'; const TailwindInput = observer(() => { const editorEngine = useEditorEngine(); + const suggestionRef = useRef(null); + const [showSuggestions, setShowSuggestions] = useState(true); const [currentSelector, setSelector] = useState(null); const instanceRef = useRef(null); @@ -85,12 +88,23 @@ const TailwindInput = observer(() => { } }; - function handleKeyDown(e: any) { - if (e.key === 'Enter' || e.key === 'Tab' || e.key === 'Escape') { - e.target.blur(); + const handleInput = ( + e: React.FormEvent, + setClasses: React.Dispatch>, + ) => { + const { value, selectionStart } = e.currentTarget; + setClasses(value); + suggestionRef.current?.handleInput(value, selectionStart); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (showSuggestions) { + suggestionRef.current?.handleKeyDown(e); + } else if (e.key === 'Enter' || e.key === 'Tab' || e.key === 'Escape') { + e.currentTarget.blur(); e.preventDefault(); } - } + }; const adjustHeight = (textarea: HTMLTextAreaElement) => { textarea.style.height = 'auto'; @@ -129,14 +143,25 @@ const TailwindInput = observer(() => { className="w-full text-xs text-foreground-active break-normal bg-background-onlook/75 focus-visible:ring-0" placeholder="Add tailwind classes here" value={instanceClasses} - onInput={(e: any) => setInstanceClasses(e.target.value)} + onInput={(e) => handleInput(e, setInstanceClasses)} onKeyDown={handleKeyDown} onBlur={(e) => { + setShowSuggestions(false); setIsInstanceFocused(false); instance && createCodeDiffRequest(instance, e.target.value); }} onFocus={() => setIsInstanceFocused(true)} /> + {isInstanceFocused && ( + + )}
{isInstanceFocused && } @@ -151,14 +176,25 @@ const TailwindInput = observer(() => { className="w-full text-xs text-foreground-active break-normal bg-background-onlook/75 focus-visible:ring-0 resize-none" placeholder="Add tailwind classes here" value={rootClasses} - onInput={(e: any) => setRootClasses(e.target.value)} + onInput={(e) => handleInput(e, setRootClasses)} onKeyDown={handleKeyDown} onBlur={(e) => { + setShowSuggestions(false); setIsRootFocused(false); root && createCodeDiffRequest(root, e.target.value); }} onFocus={() => setIsRootFocused(true)} /> + {isRootFocused && ( + + )} {isRootFocused && } diff --git a/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/twClassGen.ts b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/twClassGen.ts new file mode 100644 index 000000000..a34a1844c --- /dev/null +++ b/apps/studio/src/routes/editor/EditPanel/StylesTab/single/TailwindInput/twClassGen.ts @@ -0,0 +1,325 @@ +export const coreColors = [ + 'slate', + 'gray', + 'zinc', + 'neutral', + 'stone', + 'red', + 'orange', + 'amber', + 'yellow', + 'lime', + 'green', + 'emerald', + 'teal', + 'cyan', + 'sky', + 'blue', + 'indigo', + 'violet', + 'purple', + 'fuchsia', + 'pink', + 'rose', +]; + +const coreStyles = { + layout: { + display: [ + 'block', + 'inline-block', + 'inline', + 'flex', + 'inline-flex', + 'grid', + 'inline-grid', + 'hidden', + ], + position: ['static', 'fixed', 'absolute', 'relative', 'sticky'], + float: ['float-right', 'float-left', 'float-none'], + clear: ['clear-left', 'clear-right', 'clear-both', 'clear-none'], + }, + spacing: { + padding: generateSpacingClasses('p'), + margin: generateSpacingClasses('m'), + }, + sizing: { + width: generateSizeClasses('w'), + height: generateSizeClasses('h'), + }, + typography: { + fontSize: [ + 'text-xs', + 'text-sm', + 'text-base', + 'text-lg', + 'text-xl', + 'text-2xl', + 'text-3xl', + 'text-4xl', + 'text-5xl', + 'text-6xl', + ], + fontWeight: [ + 'font-thin', + 'font-extralight', + 'font-light', + 'font-normal', + 'font-medium', + 'font-semibold', + 'font-bold', + 'font-extrabold', + 'font-black', + ], + textAlign: ['text-left', 'text-center', 'text-right', 'text-justify'], + textColor: generateColors('text'), + textDecoration: ['underline', 'line-through', 'no-underline'], + }, + backgrounds: { + backgroundColor: generateColors('bg'), + backgroundOpacity: generateOpacityClasses('bg-opacity'), + }, + borders: { + borderWidth: generateBorderClasses(), + borderColor: generateColors('border'), + borderRadius: [ + 'rounded-none', + 'rounded-sm', + 'rounded', + 'rounded-md', + 'rounded-lg', + 'rounded-xl', + 'rounded-2xl', + 'rounded-3xl', + 'rounded-full', + ], + }, + flexbox: { + flexDirection: ['flex-row', 'flex-row-reverse', 'flex-col', 'flex-col-reverse'], + flexWrap: ['flex-wrap', 'flex-wrap-reverse', 'flex-nowrap'], + alignItems: ['items-start', 'items-center', 'items-end', 'items-baseline', 'items-stretch'], + justifyContent: [ + 'justify-start', + 'justify-center', + 'justify-end', + 'justify-between', + 'justify-around', + 'justify-evenly', + ], + }, + grid: { + gridCols: generateGridClasses('grid-cols', 12), + gridRows: generateGridClasses('grid-rows', 6), + gap: generateSpacingClasses('gap'), + }, + effects: { + opacity: generateOpacityClasses('opacity'), + shadow: [ + 'shadow-sm', + 'shadow', + 'shadow-md', + 'shadow-lg', + 'shadow-xl', + 'shadow-2xl', + 'shadow-none', + ], + }, + transitions: { + transitionProperty: [ + 'transition-none', + 'transition-all', + 'transition', + 'transition-colors', + 'transition-opacity', + 'transition-shadow', + 'transition-transform', + ], + transitionDuration: generateDurationClasses(), + }, + interactivity: { + cursor: [ + 'cursor-default', + 'cursor-pointer', + 'cursor-wait', + 'cursor-text', + 'cursor-move', + 'cursor-not-allowed', + ], + userSelect: ['select-none', 'select-text', 'select-all', 'select-auto'], + }, +}; + +function generateSpacingClasses(prefix: string): string[] { + const sizes = [ + '0', + '0.5', + '1', + '1.5', + '2', + '2.5', + '3', + '3.5', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '14', + '16', + '20', + ]; + const directions = ['', 'x', 'y', 't', 'r', 'b', 'l']; + return directions.flatMap((dir) => sizes.map((size) => `${prefix}${dir}-${size}`)); +} + +function generateSizeClasses(prefix: string): string[] { + const sizes = [ + '0', + '0.5', + '1', + '1.5', + '2', + '2.5', + '3', + '3.5', + '4', + '5', + '6', + '7', + '8', + '9', + '10', + '11', + '12', + '14', + '16', + '20', + '24', + '28', + '32', + '36', + '40', + '44', + '48', + '52', + '56', + '60', + '64', + '72', + '80', + '96', + 'auto', + '1/2', + '1/3', + '2/3', + '1/4', + '2/4', + '3/4', + '1/5', + '2/5', + '3/5', + '4/5', + 'full', + 'screen', + 'min', + 'max', + 'fit', + ]; + return sizes.map((size) => `${prefix}-${size}`); +} + +function generateColors(prefix: string): string[] { + const shades = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900']; + return coreColors.flatMap((color) => shades.map((shade) => `${prefix}-${color}-${shade}`)); +} + +function generateOpacityClasses(prefix: string): string[] { + const opacities = [ + '0', + '5', + '10', + '20', + '25', + '30', + '40', + '50', + '60', + '70', + '75', + '80', + '90', + '95', + '100', + ]; + return opacities.map((opacity) => `${prefix}-${opacity}`); +} + +function generateBorderClasses(): string[] { + const sides = ['', 't-', 'r-', 'b-', 'l-']; + const widths = ['0', '2', '4', '8']; + return sides.flatMap((side) => widths.map((width) => `border-${side}${width}`)); +} + +function generateGridClasses(prefix: string, max: number): string[] { + return Array.from({ length: max }, (_, i) => `${prefix}-${i + 1}`); +} + +function generateDurationClasses(): string[] { + const durations = ['75', '100', '150', '200', '300', '500', '700', '1000']; + return durations.map((duration) => `duration-${duration}`); +} + +export function getAllTailwindClasses(): string[] { + const allClasses = new Set(); + + function addClassesFromObject(obj: any) { + for (const key in obj) { + if (Array.isArray(obj[key])) { + obj[key].forEach((cls: string) => allClasses.add(cls)); + } else if (typeof obj[key] === 'object') { + addClassesFromObject(obj[key]); + } + } + } + + addClassesFromObject(coreStyles); + return Array.from(allClasses); +} + +export function searchTailwindClasses(input: string): string[] { + const allClasses = getAllTailwindClasses(); + if (!input.trim()) { + return []; + } + + const searchTerm = input.toLowerCase(); + return allClasses.filter((cls) => cls.toLowerCase().includes(searchTerm)).slice(0, 10); // Limit results for performance +} + +export function getClassesByCategory(category: keyof typeof coreStyles): string[] { + return Array.isArray(coreStyles[category]) + ? coreStyles[category] + : Object.values(coreStyles[category]).flat(); +} + +export function getContextualSuggestions(currentClasses: string[]): string[] { + // Add logic to suggest complementary classes based on what's already used + // For example, if 'flex' is used, suggest 'items-center', 'justify-between', etc. + const suggestions: string[] = []; + + if (currentClasses.includes('flex')) { + suggestions.push(...coreStyles.flexbox.alignItems); + suggestions.push(...coreStyles.flexbox.justifyContent); + } + + if (currentClasses.includes('grid')) { + suggestions.push(...coreStyles.grid.gridCols); + suggestions.push(...coreStyles.grid.gap); + } + + return suggestions.slice(0, 10); +} diff --git a/apps/studio/tailwind.config.ts b/apps/studio/tailwind.config.ts index 5a21b62f9..da7515260 100644 --- a/apps/studio/tailwind.config.ts +++ b/apps/studio/tailwind.config.ts @@ -1,8 +1,36 @@ import baseConfig from '@onlook/ui/tailwind.config'; import type { Config } from 'tailwindcss'; +import colors from 'tailwindcss/colors.js'; + +function flattenColors(colors, prefix = '') { + return Object.keys(colors).reduce((acc, key) => { + const value = colors[key]; + const newKey = prefix ? `${prefix}-${key}` : key; + + if (typeof value === 'string') { + return { ...acc, [newKey]: value }; + } + + if (typeof value === 'object') { + return { ...acc, ...flattenColors(value, newKey) }; + } + + return acc; + }, {}); +} + +function exposeColorsAsCssVariables({ addBase }) { + const flatColors = flattenColors(colors); + + addBase({ + ':root': Object.fromEntries( + Object.entries(flatColors).map(([key, value]) => [`--color-${key}`, value]), + ), + }); +} export default { content: ['./src/**/*.{ts,tsx}', '../../packages/ui/src/**/*.{ts,tsx}'], presets: [baseConfig], - plugins: [require('@tailwindcss/typography')], + plugins: [require('@tailwindcss/typography'), exposeColorsAsCssVariables], } satisfies Config; diff --git a/bun.lockb b/bun.lockb index ac1f365c9..0b02d0a3c 100755 Binary files a/bun.lockb and b/bun.lockb differ