Skip to content

Commit

Permalink
Tailwind autocomplete (#731)
Browse files Browse the repository at this point in the history
  • Loading branch information
Kabiirk authored Nov 11, 2024
1 parent 7b84249 commit 9a14dd0
Show file tree
Hide file tree
Showing 5 changed files with 616 additions and 7 deletions.
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement>) => void;
}

export const SuggestionsList = forwardRef<
SuggestionsListRef,
{
setClasses: React.Dispatch<React.SetStateAction<string>>;
showSuggestions: boolean;
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
currentInput: string;
setCurrentInput: React.Dispatch<React.SetStateAction<string>>;
}
>(({ setClasses, showSuggestions, setShowSuggestions, currentInput, setCurrentInput }, ref) => {
const [suggestions, setSuggestions] = useState<string[]>([]);
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<HTMLTextAreaElement>) => {
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 && (
<div className="z-50 fixed top-50 left-50 w-[90%] mt-1 rounded text-foreground bg-background-onlook overflow-auto">
{suggestions.map((suggestion, index) => {
const colorClass = getColorPreviewValue(suggestion);
const { modifiers } = parseModifiers(currentWordInfo.word);

return (
<div
key={suggestion}
className={cn(
'px-3 py-2 cursor-pointer hover:bg-background-hover hover:font-semibold',
index === selectedSuggestion &&
'bg-background-active font-semibold',
)}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
handleClick(suggestion);
}}
>
<span className="flex items-center">
{colorClass && (
<div
className="w-4 h-4 mr-2 border-[0.5px] border-foreground-tertiary rounded-sm"
style={{ backgroundColor: colorClass }}
/>
)}
<span className="opacity-50 mr-1">
{modifiers.length > 0 ? `${modifiers.join(':')}:` : ''}
</span>
<span>{suggestion}</span>
</span>
</div>
);
})}
</div>
)
);
});

SuggestionsList.displayName = 'SuggestionsList';
Original file line number Diff line number Diff line change
Expand Up @@ -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<SuggestionsListRef>(null);
const [showSuggestions, setShowSuggestions] = useState(true);
const [currentSelector, setSelector] = useState<string | null>(null);

const instanceRef = useRef<HTMLTextAreaElement>(null);
Expand Down Expand Up @@ -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<HTMLTextAreaElement>,
setClasses: React.Dispatch<React.SetStateAction<string>>,
) => {
const { value, selectionStart } = e.currentTarget;
setClasses(value);
suggestionRef.current?.handleInput(value, selectionStart);
};

const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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';
Expand Down Expand Up @@ -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 && (
<SuggestionsList
currentInput={instanceClasses}
showSuggestions={showSuggestions}
setCurrentInput={setInstanceClasses}
ref={suggestionRef}
setShowSuggestions={setShowSuggestions}
setClasses={setInstanceClasses}
/>
)}
</div>
{isInstanceFocused && <EnterIndicator />}
</div>
Expand All @@ -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 && (
<SuggestionsList
ref={suggestionRef}
showSuggestions={showSuggestions}
currentInput={rootClasses}
setCurrentInput={setRootClasses}
setShowSuggestions={setShowSuggestions}
setClasses={setRootClasses}
/>
)}
</div>
{isRootFocused && <EnterIndicator />}
</div>
Expand Down
Loading

0 comments on commit 9a14dd0

Please sign in to comment.