@ -1,3 +1,4 @@
import classNames from "classnames" ;
import { useEffect , useRef , useState } from "react" ;
import getCaretCoordinates from "textarea-caret" ;
import { useTagStore } from "@/store/module" ;
@ -13,60 +14,97 @@ const TagSuggestions = ({ editorRef, editorActions }: Props) => {
const [ position , setPosition ] = useState < Position | null > ( null ) ;
const hide = ( ) = > setPosition ( null ) ;
const { state } = useTagStore ( ) ;
const tagsRef = useRef ( state . tags ) ;
tagsRef . current = state . tags ;
const [ selected , select ] = useState ( 0 ) ;
const selectedRef = useRef ( selected ) ;
selectedRef . current = selected ;
const getCurrentWord = ( ) : [ word : string , startIndex : number ] = > {
if ( ! editorRef . current ) return [ "" , 0 ] ;
const cursorPos = editorRef . current . selectionEnd ;
const before = editorRef . current . value . slice ( 0 , cursorPos ) . match ( /\S*$/ ) || { 0 : "" , index : cursorPos } ;
const ahead = editorRef . current . value . slice ( cursorPos ) . match ( /^\S*/ ) || { 0 : "" } ;
return [ before [ 0 ] + ahead [ 0 ] , before . index || cursorPos ] ;
const editor = editorRef . current ;
if ( ! editor ) return [ "" , 0 ] ;
const cursorPos = editor . selectionEnd ;
const before = editor . value . slice ( 0 , cursorPos ) . match ( /\S*$/ ) || { 0 : "" , index : cursorPos } ;
const after = editor . value . slice ( cursorPos ) . match ( /^\S*/ ) || { 0 : "" } ;
return [ before [ 0 ] + after [ 0 ] , before . index ? ? cursorPos ] ;
} ;
const suggestionsRef = useRef < string [ ] > ( [ ] ) ;
suggestionsRef . current = ( ( ) = > {
const partial = getCurrentWord ( ) [ 0 ] . slice ( 1 ) . toLowerCase ( ) ;
const matches = ( str : string ) = > str . startsWith ( partial ) && partial . length < str . length ;
return tagsRef . current . filter ( ( tag ) = > matches ( tag . toLowerCase ( ) ) ) . slice ( 0 , 5 ) ;
} ) ( ) ;
const isVisibleRef = useRef ( false ) ;
isVisibleRef . current = ! ! ( position && suggestionsRef . current . length > 0 ) ;
const autocomplete = ( tag : string ) = > {
if ( ! editorActions || ! ( "current" in editorActions ) || ! editorActions . current ) return ;
const [ word , index ] = getCurrentWord ( ) ;
editorActions . current . removeText ( index , word . length ) ;
editorActions . current . insertText ( ` # ${ tag } ` ) ;
hide ( ) ;
} ;
const handleKeyDown = ( e : KeyboardEvent ) = > {
const isArrowKey = [ "ArrowLeft" , "ArrowRight" , "ArrowDown" , "ArrowUp" ] . includes ( e . code ) ;
if ( isArrowKey || [ "Tab" , "Escape" ] . includes ( e . code ) ) hide ( ) ;
if ( ! isVisibleRef . current ) return ;
const suggestions = suggestionsRef . current ;
const selected = selectedRef . current ;
if ( [ "Escape" , "ArrowLeft" , "ArrowRight" ] . includes ( e . code ) ) hide ( ) ;
if ( "ArrowDown" === e . code ) {
select ( ( selected + 1 ) % suggestions . length ) ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
if ( "ArrowUp" === e . code ) {
select ( ( selected - 1 + suggestions . length ) % suggestions . length ) ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
if ( [ "Enter" , "Tab" ] . includes ( e . code ) ) {
autocomplete ( suggestions [ selected ] ) ;
e . preventDefault ( ) ;
e . stopPropagation ( ) ;
}
} ;
const handleInput = ( ) = > {
if ( ! editorRef . current ) return ;
select ( 0 ) ;
const [ word , index ] = getCurrentWord ( ) ;
if ( ! word . startsWith ( "#" ) || word . slice ( 1 ) . includes ( "#" ) ) return hide ( ) ;
setPosition ( getCaretCoordinates ( editorRef . current , index ) ) ;
const isActive = word . startsWith ( "#" ) && ! word . slice ( 1 ) . includes ( "#" ) ;
isActive ? setPosition( getCaretCoordinates ( editorRef . current , index ) ) : hide ( ) ;
} ;
const areListenersRegistered = useRef ( false ) ;
const listenersAreRegisteredRef = useRef ( false ) ;
const registerListeners = ( ) = > {
if ( ! editorRef . current || areListenersRegistered . current ) return ;
editorRef . current . addEventListener ( "click" , hide ) ;
editorRef . current . addEventListener ( "blur" , hide ) ;
editorRef . current . addEventListener ( "keydown" , handleKeyDown ) ;
editorRef . current . addEventListener ( "input" , handleInput ) ;
areListenersRegistered . current = true ;
const editor = editorRef . current ;
if ( ! editor || listenersAreRegisteredRef . current ) return ;
editor . addEventListener ( "click" , hide ) ;
editor . addEventListener ( "blur" , hide ) ;
editor . addEventListener ( "keydown" , handleKeyDown ) ;
editor . addEventListener ( "input" , handleInput ) ;
listenersAreRegisteredRef . current = true ;
} ;
useEffect ( registerListeners , [ ! ! editorRef . current ] ) ;
const { tags } = useTagStore ( ) . state ;
const getSuggestions = ( ) = > {
const partial = getCurrentWord ( ) [ 0 ] . slice ( 1 ) ;
return tags . filter ( ( tag ) = > tag . startsWith ( partial ) ) . slice ( 0 , 5 ) ;
} ;
const suggestions = getSuggestions ( ) ;
const handleSelection = ( tag : string ) = > {
if ( ! editorActions || ! ( "current" in editorActions ) || ! editorActions . current ) return ;
const partial = getCurrentWord ( ) [ 0 ] . slice ( 1 ) ;
editorActions . current . insertText ( tag . slice ( partial . length ) ) ;
} ;
if ( ! position || ! suggestions . length ) return null ;
if ( ! isVisibleRef . current || ! position ) return null ;
return (
< div
className = "z-2 p-1 absolute max-w-[12rem] rounded font-mono shadow bg-zinc-200 dark:bg-zinc-600"
style = { { left : position.left - 6 , top : position.top + position . height + 2 } }
>
{ suggestions . map ( ( tag ) = > (
{ suggestionsRef . current . map ( ( tag , i ) = > (
< div
key = { tag }
onMouseDown = { ( ) = > handleSelection ( tag ) }
className = "rounded p-1 px-2 w-full truncate text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700"
onMouseDown = { ( ) = > autocomplete ( tag ) }
className = { classNames (
"rounded p-1 px-2 w-full truncate text-sm dark:text-gray-300 cursor-pointer hover:bg-zinc-300 dark:hover:bg-zinc-700" ,
i === selected ? "bg-zinc-300 dark:bg-zinc-700" : ""
) }
>
# { tag }
< / div >