9 changed files with 280 additions and 28 deletions
@ -0,0 +1,191 @@ |
|||||||
|
import { |
||||||
|
Command, |
||||||
|
CommandGroup, |
||||||
|
CommandInput, |
||||||
|
CommandItem, |
||||||
|
CommandList |
||||||
|
} from '@/components/ui/command' |
||||||
|
import { Textarea } from '@/components/ui/textarea' |
||||||
|
import { useSearchProfiles } from '@/hooks' |
||||||
|
import { pubkeyToNpub } from '@/lib/pubkey' |
||||||
|
import { cn } from '@/lib/utils' |
||||||
|
import React, { |
||||||
|
ComponentProps, |
||||||
|
Dispatch, |
||||||
|
SetStateAction, |
||||||
|
useCallback, |
||||||
|
useEffect, |
||||||
|
useRef, |
||||||
|
useState |
||||||
|
} from 'react' |
||||||
|
import Nip05 from '../Nip05' |
||||||
|
import { SimpleUserAvatar } from '../UserAvatar' |
||||||
|
import { SimpleUsername } from '../Username' |
||||||
|
import { getCurrentWord, replaceWord } from './utils' |
||||||
|
|
||||||
|
export default function TextareaWithMentions({ |
||||||
|
textValue, |
||||||
|
setTextValue, |
||||||
|
...props |
||||||
|
}: ComponentProps<'textarea'> & { |
||||||
|
textValue: string |
||||||
|
setTextValue: Dispatch<SetStateAction<string>> |
||||||
|
}) { |
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null) |
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null) |
||||||
|
const inputRef = useRef<HTMLInputElement>(null) |
||||||
|
const [commandValue, setCommandValue] = useState('') |
||||||
|
const [debouncedCommandValue, setDebouncedCommandValue] = useState(commandValue) |
||||||
|
const { profiles, isFetching } = useSearchProfiles(debouncedCommandValue, 10) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const handler = setTimeout(() => { |
||||||
|
setDebouncedCommandValue(commandValue) |
||||||
|
}, 500) |
||||||
|
|
||||||
|
return () => { |
||||||
|
clearTimeout(handler) |
||||||
|
} |
||||||
|
}, [commandValue]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
if (!dropdown) return |
||||||
|
|
||||||
|
if (profiles.length > 0 && !isFetching) { |
||||||
|
dropdown.classList.remove('hidden') |
||||||
|
} else { |
||||||
|
dropdown.classList.add('hidden') |
||||||
|
} |
||||||
|
}, [profiles, isFetching]) |
||||||
|
|
||||||
|
const handleBlur = useCallback(() => { |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
if (dropdown) { |
||||||
|
dropdown.classList.add('hidden') |
||||||
|
setCommandValue('') |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent) => { |
||||||
|
const textarea = textareaRef.current |
||||||
|
const input = inputRef.current |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
if (textarea && input && dropdown) { |
||||||
|
const currentWord = getCurrentWord(textarea) |
||||||
|
const isDropdownHidden = dropdown.classList.contains('hidden') |
||||||
|
if (currentWord.startsWith('@') && !isDropdownHidden) { |
||||||
|
if ( |
||||||
|
e.key === 'ArrowUp' || |
||||||
|
e.key === 'ArrowDown' || |
||||||
|
e.key === 'Enter' || |
||||||
|
e.key === 'Escape' |
||||||
|
) { |
||||||
|
e.preventDefault() |
||||||
|
input.dispatchEvent(new KeyboardEvent('keydown', e)) |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
const onTextValueChange = useCallback( |
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => { |
||||||
|
const text = e.target.value |
||||||
|
const textarea = textareaRef.current |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
|
||||||
|
if (textarea && dropdown) { |
||||||
|
const currentWord = getCurrentWord(textarea) |
||||||
|
setTextValue(text) |
||||||
|
if (currentWord.startsWith('@') && currentWord.length > 1) { |
||||||
|
setCommandValue(currentWord.slice(1)) |
||||||
|
} else { |
||||||
|
// REMINDER: apparently, we need it when deleting
|
||||||
|
if (commandValue !== '') { |
||||||
|
setCommandValue('') |
||||||
|
dropdown.classList.add('hidden') |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
[setTextValue, commandValue] |
||||||
|
) |
||||||
|
|
||||||
|
const onCommandSelect = useCallback((value: string) => { |
||||||
|
const textarea = textareaRef.current |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
if (textarea && dropdown) { |
||||||
|
replaceWord(textarea, `${value}`) |
||||||
|
setCommandValue('') |
||||||
|
dropdown.classList.add('hidden') |
||||||
|
} |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleMouseDown = useCallback((e: Event) => { |
||||||
|
e.preventDefault() |
||||||
|
e.stopPropagation() |
||||||
|
}, []) |
||||||
|
|
||||||
|
const handleSectionChange = useCallback(() => { |
||||||
|
const textarea = textareaRef.current |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
if (textarea && dropdown) { |
||||||
|
const currentWord = getCurrentWord(textarea) |
||||||
|
if (!currentWord.startsWith('@') && commandValue !== '') { |
||||||
|
setCommandValue('') |
||||||
|
dropdown.classList.add('hidden') |
||||||
|
} |
||||||
|
} |
||||||
|
}, [commandValue]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const textarea = textareaRef.current |
||||||
|
const dropdown = dropdownRef.current |
||||||
|
textarea?.addEventListener('keydown', handleKeyDown) |
||||||
|
textarea?.addEventListener('blur', handleBlur) |
||||||
|
document?.addEventListener('selectionchange', handleSectionChange) |
||||||
|
dropdown?.addEventListener('mousedown', handleMouseDown) |
||||||
|
return () => { |
||||||
|
textarea?.removeEventListener('keydown', handleKeyDown) |
||||||
|
textarea?.removeEventListener('blur', handleBlur) |
||||||
|
document?.removeEventListener('selectionchange', handleSectionChange) |
||||||
|
dropdown?.removeEventListener('mousedown', handleMouseDown) |
||||||
|
} |
||||||
|
}, [handleBlur, handleKeyDown, handleMouseDown, handleSectionChange]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="relative w-full"> |
||||||
|
<Textarea {...props} ref={textareaRef} value={textValue} onChange={onTextValueChange} /> |
||||||
|
<Command |
||||||
|
ref={dropdownRef} |
||||||
|
className={cn( |
||||||
|
'sm:fixed hidden translate-y-2 h-auto max-h-44 w-full sm:w-[462px] z-10 overflow-auto border border-popover shadow' |
||||||
|
)} |
||||||
|
shouldFilter={false} |
||||||
|
> |
||||||
|
<div className="hidden"> |
||||||
|
<CommandInput ref={inputRef} value={commandValue} /> |
||||||
|
</div> |
||||||
|
<CommandList> |
||||||
|
<CommandGroup> |
||||||
|
{profiles.map((p) => { |
||||||
|
return ( |
||||||
|
<CommandItem |
||||||
|
key={p.pubkey} |
||||||
|
value={`nostr:${pubkeyToNpub(p.pubkey)}`} |
||||||
|
onSelect={onCommandSelect} |
||||||
|
> |
||||||
|
<div className="flex gap-2 items-center pointer-events-none"> |
||||||
|
<SimpleUserAvatar userId={p.pubkey} /> |
||||||
|
<SimpleUsername userId={p.pubkey} className="font-semibold" /> |
||||||
|
<Nip05 pubkey={p.pubkey} /> |
||||||
|
</div> |
||||||
|
</CommandItem> |
||||||
|
) |
||||||
|
})} |
||||||
|
</CommandGroup> |
||||||
|
</CommandList> |
||||||
|
</Command> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,67 @@ |
|||||||
|
export function getCaretPosition(element: HTMLTextAreaElement) { |
||||||
|
return { |
||||||
|
caretStartIndex: element.selectionStart || 0, |
||||||
|
caretEndIndex: element.selectionEnd || 0 |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function getCurrentWord(element: HTMLTextAreaElement) { |
||||||
|
const text = element.value |
||||||
|
const { caretStartIndex } = getCaretPosition(element) |
||||||
|
|
||||||
|
// Find the start position of the word
|
||||||
|
let start = caretStartIndex |
||||||
|
while (start > 0 && text[start - 1].match(/\S/)) { |
||||||
|
start-- |
||||||
|
} |
||||||
|
|
||||||
|
// Find the end position of the word
|
||||||
|
let end = caretStartIndex |
||||||
|
while (end < text.length && text[end].match(/\S/)) { |
||||||
|
end++ |
||||||
|
} |
||||||
|
|
||||||
|
const w = text.substring(start, end) |
||||||
|
|
||||||
|
return w |
||||||
|
} |
||||||
|
|
||||||
|
export function replaceWord(element: HTMLTextAreaElement, value: string) { |
||||||
|
const text = element.value |
||||||
|
const caretPos = element.selectionStart |
||||||
|
|
||||||
|
// Find the word that needs to be replaced
|
||||||
|
const wordRegex = /[\w@#]+/g |
||||||
|
let match |
||||||
|
let startIndex |
||||||
|
let endIndex |
||||||
|
|
||||||
|
while ((match = wordRegex.exec(text)) !== null) { |
||||||
|
startIndex = match.index |
||||||
|
endIndex = startIndex + match[0].length |
||||||
|
|
||||||
|
if (caretPos >= startIndex && caretPos <= endIndex) { |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Replace the word with a new word using document.execCommand
|
||||||
|
if (startIndex !== undefined && endIndex !== undefined) { |
||||||
|
// Preserve the current selection range
|
||||||
|
const selectionStart = element.selectionStart |
||||||
|
const selectionEnd = element.selectionEnd |
||||||
|
|
||||||
|
// Modify the selected range to encompass the word to be replaced
|
||||||
|
element.setSelectionRange(startIndex, endIndex) |
||||||
|
|
||||||
|
// REMINDER: Fastest way to include CMD + Z compatibility
|
||||||
|
// Execute the command to replace the selected text with the new word
|
||||||
|
document.execCommand('insertText', false, value) |
||||||
|
|
||||||
|
// Restore the original selection range
|
||||||
|
element.setSelectionRange( |
||||||
|
selectionStart - (endIndex - startIndex) + value.length, |
||||||
|
selectionEnd - (endIndex - startIndex) + value.length |
||||||
|
) |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue