import { ChevronDoubleRightIcon, MinusCircleIcon, PencilIcon } from '@heroicons/react/solid' import animateScrollTo from 'animated-scroll-to' import fuzzySort from 'fuzzysort' import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai' import { CSSProperties, Ref, useEffect, useMemo, useRef, useState } from 'react' import { elementGetAbsolutePosition } from './dom' import { TailwindClassDescription, TailwindClasses } from './tailwind' import { makeTransformers } from './transformers' const classEditorTmpClass = '__impulse__class-editor-tmp-class' export type ClassEditorState = ClassEditorStateActive export type ClassEditorStateActive = { type: 'active' inputValue: string inputFocused: boolean } export function useClassEditor() { const stateAtomRef = useRef( atom({ type: 'active', inputValue: '', inputFocused: false, }), ) const setState = useSetAtom(stateAtomRef.current) const focus = () => { setState((state) => ({ ...state, inputFocused: true, })) } const blur = () => { setState((state) => ({ ...state, inputFocused: false, })) } return { stateAtom: stateAtomRef.current, focus, blur, } } type ClassEditorProps = { selectedNode?: Node stateAtom: PrimitiveAtom tailwindClasses: TailwindClasses transformers: ReturnType rerender: () => void focus: () => void blur: () => void } export function ClassEditor({ selectedNode, stateAtom, tailwindClasses, transformers, rerender, focus, blur, }: ClassEditorProps) { const [state, setState] = useAtom(stateAtom) useEffect(() => { if (!(selectedNode instanceof Element)) { return } if (selectedNode.classList.contains(classEditorTmpClass)) { return } selectedNode.classList.add(classEditorTmpClass) return () => selectedNode.classList.remove(classEditorTmpClass) }, [state]) const inputRef = useRef(null) const tailwindClassesArray = useMemo(() => Object.keys(tailwindClasses), [tailwindClasses]) const existingClasses = selectedNode instanceof Element ? [...selectedNode.classList].filter((className) => !className.startsWith('__impulse__')) : [] const fuzzyMatches = useMemo(() => { const matches = fuzzySort.go( state.inputValue, Object.entries(tailwindClasses).map(([className, props]) => ({ fullSearchString: `${className} ${props.nodes .map(({ prop, value }) => `${prop} ${value}`) .join(' ')}`, className, props, })), { keys: ['className', 'fullSearchString'], limit: 50, threshold: -50000, }, ) if (matches.length === 0) { return [ { target: state.inputValue, score: 0, obj: { className: state.inputValue }, }, ] } return matches }, [state.inputValue]) const tailwindClassCandidates = state.inputValue === '' ? existingClasses : [...fuzzyMatches] .sort((a, b) => { const indexDifference = tailwindClassesArray.indexOf(b.obj.className) - tailwindClassesArray.indexOf(a.obj.className) const scoreDifference = Math.abs(Math.abs(a.score) - Math.abs(b.score)) const scoreDifferenceCoof = Math.abs(scoreDifference) / Math.max(Math.abs(a.score), Math.abs(b.score)) // if the difference between the two options is not that big, choose based on which is listed the first // in the list of all tailwind classes // TODO: lookup the value of css props they change and sort according to the values if (scoreDifferenceCoof < 0.25) { return indexDifference * -1 } return b.score - a.score }) .map((m) => m.obj.className) type ListSelectionState = { selectedKey: string | null } const [listSelectionState, setListSelectionState] = useState({ selectedKey: null, }) const tailwindClassMatched = tailwindClasses[listSelectionState.selectedKey ?? ''] const classesToReplace = useMemo(() => { if (!tailwindClassMatched) { return [] } const matchedClassEditProps = tailwindClassMatched.nodes .map(({ prop }) => prop) .sort() .join(' ') return existingClasses.filter((existingClassName) => { const existingClass = tailwindClasses[existingClassName] if (!existingClass) { return false } const existingClassEditsProps = existingClass.nodes .map(({ prop }) => prop) .sort() .join(' ') return existingClassEditsProps === matchedClassEditProps }) }, [tailwindClassMatched, existingClasses]) // reset selection to the first item each time the input value changes useEffect(() => { if (!state.inputFocused) { return } const selectedKey = tailwindClassCandidates.length > 0 ? tailwindClassCandidates[0] : null setListSelectionState({ selectedKey, }) }, [state.inputValue, state.inputFocused]) const onClassSelected = async (className: string) => { if (!(selectedNode instanceof Element)) { return } if (existingClasses.includes(className)) { await transformers.removeClass(selectedNode, className) } else { await transformers.addClass(selectedNode, className, classesToReplace) } setState((prev) => ({ ...prev, inputValue: '', })) } useEffect(() => { if (!inputRef.current) { return } const onKeyDown = async (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() setState((prev) => ({ ...prev, inputValue: '', inputFocused: false, })) inputRef.current?.blur() return } const selectedKey = listSelectionState.selectedKey if (!selectedKey) { return } if (e.code === 'ArrowUp') { e.preventDefault() const index = tailwindClassCandidates.indexOf(selectedKey) const prevIndex = index - 1 const prevItem = tailwindClassCandidates[prevIndex] ?? tailwindClassCandidates[tailwindClassCandidates.length - 1] setListSelectionState({ selectedKey: prevItem, }) } if (e.key === 'ArrowDown') { e.preventDefault() const index = tailwindClassCandidates.indexOf(selectedKey) const nextIndex = index + 1 const nextItem = tailwindClassCandidates[nextIndex] ?? tailwindClassCandidates[0] setListSelectionState({ selectedKey: nextItem, }) } if (e.key === 'Enter') { e.preventDefault() await onClassSelected(selectedKey) } } inputRef.current.addEventListener('keydown', onKeyDown) return () => { inputRef.current?.removeEventListener('keydown', onKeyDown) } }, [inputRef.current, listSelectionState, classesToReplace]) useEffect(() => { if (state.inputFocused) { inputRef.current?.focus() } }, [state.inputFocused]) const listContainerRef = useRef(null) const listSelectedElementRef = useRef(null) useEffect(() => { if (!listSelectedElementRef.current || !listContainerRef.current) { return } const containerRect = listContainerRef.current.getBoundingClientRect() const selectedRect = listSelectedElementRef.current.getBoundingClientRect() if (selectedRect.top < containerRect.top || selectedRect.bottom > containerRect.bottom) { animateScrollTo(listSelectedElementRef.current, { elementToScroll: listContainerRef.current, }) } }, [listContainerRef.current, listSelectedElementRef.current, listSelectionState.selectedKey]) useEffect(() => { rerender() }, [listSelectionState]) return ( { setState((state) => { return { ...state, inputValue: value, } }) }} inputOnFocus={() => { focus() }} inputOnBlur={() => { blur() }} selectedKey={listSelectionState.selectedKey} onItemClick={(className) => { focus() if (!(selectedNode instanceof Element)) { return } onClassSelected(className) }} {...{ tailwindClasses, tailwindClassMatched, tailwindClassCandidates, existingClasses, classesToReplace, }} /> ) } export function ClassEditorView(props: { refs: { input: Ref listContainer: Ref listSelectedElement: Ref } selectedNode?: Node style?: CSSProperties tailwindClassMatched?: TailwindClassDescription classEditorState: ClassEditorState inputOnChange: (value: string) => void inputOnFocus: () => void inputOnBlur: () => void tailwindClassCandidates: string[] tailwindClasses: { [key: string]: TailwindClassDescription } selectedKey: string | null onItemClick: (className: string) => void existingClasses: string[] classesToReplace: string[] }) { const selectedClassIsExistingClass = props.selectedKey && props.existingClasses.includes(props.selectedKey) return (
{props.classEditorState.inputFocused && props.tailwindClassMatched && !selectedClassIsExistingClass && ( )} {props.selectedNode instanceof Element && (
{'<'} {props.selectedNode.tagName.toLowerCase()} {'>'}:{' '} {(() => { const roundTwoDecimals = (num: number) => Math.round(num * 100) / 100 const { width, height } = elementGetAbsolutePosition(props.selectedNode) return `${roundTwoDecimals(width)}x${roundTwoDecimals(height)}` })()}
)}
{ props.inputOnChange(event.target.value) }} onFocus={() => { props.inputOnFocus() }} onBlur={() => { props.inputOnBlur() }} />
{props.tailwindClassCandidates.map((className) => { const isSelected = props.selectedKey === className return ( ) })}
) } function TailwindIcon() { return ( ) }