9 changed files with 280 additions and 28 deletions
@ -0,0 +1,191 @@
@@ -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 @@
@@ -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