diff --git a/README.md b/README.md index 8b4a1fe..c0482e3 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@
-[![LICENSE](https://img.shields.io/npm/l/samagra-chatui?style=flat-square)](https://github.com/Samagra-Development/ChatUI/blob/next/LICENSE) [![NPM version](https://img.shields.io/npm/v/samagra-chatui?style=flat-square)](https://www.npmjs.com/package/samagra-chatui) +[![LICENSE](https://img.shields.io/npm/l/@samagra-x/chatui?style=flat-square)](https://github.com/Samagra-Development/ChatUI/blob/next/LICENSE) [![NPM version](https://img.shields.io/npm/v/@samagra-x/chatui?style=flat-square)](https://www.npmjs.com/package/@samagra-x/chatui)
@@ -31,18 +31,18 @@ ## Install ```bash -npm install samagra-chatui --save +npm install @samagra-x/chatui ``` ```bash -yarn add samagra-chatui +yarn add @samagra-x/chatui ``` ## Usage ```jsx -import Chat, { Bubble, useMessages } from 'samagra-chatui'; -import 'samagra-chatui/dist/index.css'; +import Chat, { Bubble, useMessages } from '@samagra-x/chatui'; +import '@samagra-x/chatui/dist/index.css'; const App = () => { const { messages, appendMsg, setTyping } = useMessages([]); diff --git a/package.json b/package.json index a16a5da..ff9e1d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samagra-x/chatui", - "version": "1.0.1", + "version": "1.0.3", "description": "The React library for Chatbot UI", "main": "lib/index.js", "module": "es/index.js", diff --git a/src/components/Chat/index.tsx b/src/components/Chat/index.tsx index 624d126..72e5e37 100644 --- a/src/components/Chat/index.tsx +++ b/src/components/Chat/index.tsx @@ -10,6 +10,14 @@ import { QuickReplies, QuickReplyItemProps } from '../QuickReplies'; import { Composer as DComposer, ComposerProps, ComposerHandle } from '../Composer'; import { isSafari, getIOSMajorVersion } from '../../utils/ua'; +export type TransliterationConfig = { + transliterationApi:string; + transliterationSuggestions?:number; + transliterationInputLanguage:string; + transliterationOutputLanguage:string; + transliterationProvider?:string; +} + export type ChatProps = Omit & MessageContainerProps & { /** @@ -145,6 +153,8 @@ export type ChatProps = Omit & disableSend?:boolean btnColor?:string background?:string + showTransliteration?:boolean + transliterationConfig?:TransliterationConfig }; export const Chat = React.forwardRef((props, ref) => { @@ -178,6 +188,8 @@ export const Chat = React.forwardRef((props, ref) => onInputBlur, onSend, disableSend, + showTransliteration, + transliterationConfig, btnColor, background, onImageSend, @@ -262,6 +274,8 @@ export const Chat = React.forwardRef((props, ref) => onBlur={onInputBlur} onSend={onSend} disableSend={disableSend} + showTransliteration={showTransliteration} + transliterationConfig={transliterationConfig} btnColor={btnColor} onImageSend={onImageSend} rightAction={rightAction} diff --git a/src/components/Composer/ComposerInput.tsx b/src/components/Composer/ComposerInput.tsx index 034b17a..7969d76 100644 --- a/src/components/Composer/ComposerInput.tsx +++ b/src/components/Composer/ComposerInput.tsx @@ -5,6 +5,7 @@ import { SendConfirm } from '../SendConfirm'; import riseInput from './riseInput'; import parseDataTransfer from '../../utils/parseDataTransfer'; import canUse from '../../utils/canUse'; +import { TransliterationConfig } from '../Chat'; const canTouch = canUse('touch'); @@ -12,6 +13,10 @@ interface ComposerInputProps extends InputProps { invisible: boolean; inputRef: React.MutableRefObject; onImageSend?: (file: File) => Promise; + showTransliteration: boolean; + transliterationConfig: TransliterationConfig | null; + cursorPosition: number; + setCursorPosition: any; } export const ComposerInput = ({ @@ -19,9 +24,18 @@ export const ComposerInput = ({ invisible, onImageSend, disabled, + showTransliteration, + transliterationConfig, + value, + onChange, + cursorPosition, + setCursorPosition, ...rest }: ComposerInputProps) => { const [pastedImage, setPastedImage] = useState(null); + const [suggestions, setSuggestions] = useState([]); + const [suggestionClicked, setSuggestionClicked] = useState(false); + const [activeSuggestion, setActiveSuggestion] = useState(0); const handlePaste = useCallback((e: React.ClipboardEvent) => { parseDataTransfer(e, setPastedImage); @@ -46,9 +60,163 @@ export const ComposerInput = ({ } }, [inputRef]); + useEffect(() => { + if ( + value && + //@ts-ignore + value.length > 0 && + showTransliteration && transliterationConfig + ) { + if (suggestionClicked) { + setSuggestionClicked(false); + return; + } + + setSuggestions([]); + + //@ts-ignore + const words = value.split(' '); + const wordUnderCursor = words.find( + (word: any) => + //@ts-ignore + cursorPosition >= value.indexOf(word) && + //@ts-ignore + cursorPosition <= value.indexOf(word) + word.length, + ); + if (!wordUnderCursor) return; + fetch(transliterationConfig.transliterationApi, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + "inputLanguage": transliterationConfig.transliterationInputLanguage, + "outputLanguage": transliterationConfig.transliterationOutputLanguage, + "input": wordUnderCursor, + "provider": transliterationConfig?.transliterationProvider || "bhashini", + "numSuggestions": transliterationConfig?.transliterationSuggestions || 3 + }), + }) + .then((response) => response.json()) + .then((data) => { + setSuggestions(data?.suggestions); + }) + .catch((error) => { + console.error('Error fetching transliteration:', error); + }); + } else { + setSuggestions([]); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, cursorPosition]); + + const suggestionClickHandler = useCallback( + (e: any) => { + //@ts-ignore + const words = value.split(' '); + + // Find the word at the cursor position + const selectedWord = words.find( + (word: any) => + //@ts-ignore + cursorPosition >= value.indexOf(word) && + //@ts-ignore + cursorPosition <= value.indexOf(word) + word.length, + ); + + if (selectedWord) { + // Replace the selected word with the transliterated suggestion + //@ts-ignore + const newInputMsg = value.replace( + selectedWord, + //@ts-ignore + cursorPosition === value.length ? e + ' ' : e, + ); + + setSuggestions([]); + setSuggestionClicked(true); + setActiveSuggestion(0); + + // Save and restore the cursor position + const restoredCursorPosition = + //@ts-ignore + cursorPosition - value.indexOf(selectedWord) + value.indexOf(e); + //@ts-ignore + onChange(newInputMsg, e); + setCursorPosition(restoredCursorPosition); + //@ts-ignore + inputRef.current && inputRef.current.focus(); + } + }, + [value, cursorPosition, onChange], + ); + + // @ts-ignore + const suggestionHandler = (e: any, index: number) => { + setActiveSuggestion(index); + }; + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (suggestions.length > 0) { + if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveSuggestion((prevActiveSuggestion) => + prevActiveSuggestion > 0 ? prevActiveSuggestion - 1 : prevActiveSuggestion, + ); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveSuggestion((prevActiveSuggestion) => + prevActiveSuggestion < suggestions.length - 1 + ? prevActiveSuggestion + 1 + : prevActiveSuggestion, + ); + } else if (e.key === ' ') { + e.preventDefault(); + if (activeSuggestion >= 0 && activeSuggestion < suggestions?.length) { + suggestionClickHandler(suggestions[activeSuggestion]); + setSuggestions([]); + } else { + //@ts-ignore + onChange(prevInputMsg + ' '); + } + } + } + }, + [activeSuggestion, suggestionClickHandler, suggestions], + ); + + useEffect(() => { + if (suggestions.length === 1) { + setSuggestions([]); + } + }, [suggestions]); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + return (
- + {suggestions.map((elem, index) => { + return ( +
suggestionClickHandler(elem)} + className={`suggestion ${activeSuggestion === index ? 'active' : ''}`} + onMouseEnter={(e) => suggestionHandler(e, index)} + > + {elem} +
+ ); + })} +
+ {pastedImage && ( diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index c8463a3..299d19a 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -1,4 +1,10 @@ -import React, { useState, useRef, useEffect, useImperativeHandle, useCallback } from 'react'; +import React, { + useState, + useRef, + useEffect, + useImperativeHandle, + useCallback, +} from 'react'; import clsx from 'clsx'; import { IconButtonProps } from '../IconButton'; import { Recorder, RecorderProps } from '../Recorder'; @@ -11,6 +17,7 @@ import { ComposerInput } from './ComposerInput'; import { SendButton } from './SendButton'; import { Action } from './Action'; import toggleClass from '../../utils/toggleClass'; +import { TransliterationConfig } from '../Chat'; export const CLASS_NAME_FOCUSING = 'S--focusing'; @@ -35,6 +42,8 @@ export type ComposerProps = { onAccessoryToggle?: (isAccessoryOpen: boolean) => void; rightAction?: IconButtonProps; disableSend:boolean; + showTransliteration: boolean; + transliterationConfig:TransliterationConfig | null; btnColor:string; voiceToText?:any; voiceToTextProps?:any; @@ -59,7 +68,9 @@ export const Composer = React.forwardRef((props, onSend, voiceToText: VoiceToText, voiceToTextProps, - disableSend=false, + disableSend = false, + showTransliteration = true, + transliterationConfig = null, onImageSend, onAccessoryToggle, toolbar = [], @@ -69,7 +80,6 @@ export const Composer = React.forwardRef((props, btnColor, } = props; - const [text, setText] = useState(initialText); const [textOnce, setTextOnce] = useState(''); const [placeholder, setPlaceholder] = useState(oPlaceholder); @@ -82,6 +92,7 @@ export const Composer = React.forwardRef((props, const popoverTarget = useRef(); const isMountRef = useRef(false); const [isWide, setWide] = useState(false); + const [cursorPosition, setCursorPosition] = useState(0); useEffect(() => { const mq = @@ -208,6 +219,9 @@ export const Composer = React.forwardRef((props, const handleTextChange = useCallback( (value: string, e: React.ChangeEvent) => { setText(value); + if (e.target instanceof HTMLTextAreaElement) { + setCursorPosition(e.target.selectionStart); + } if (onChange) { onChange(value, e); @@ -285,10 +299,17 @@ export const Composer = React.forwardRef((props, )}
-
- -
- +
+ +
+
); @@ -296,7 +317,10 @@ export const Composer = React.forwardRef((props, return ( <> -
+
{recorder.canRecord && ( ((props, aria-label={isInputText ? 'Switch to voice input' : 'Switch to keyboard input'} /> )} -
- +
+ { + + } {!isInputText && }
{!text && rightAction && } - + {!text && VoiceToText ? (
((props,
) : null} - + {hasToolbar && ( ((props, aria-label={isAccessoryOpen ? 'Close Toolbar' : 'Expand Toolbar'} /> )} - {(text || textOnce ) && } + {(text || textOnce) && ( + + )}
{isAccessoryOpen && ( @@ -344,4 +379,4 @@ export const Composer = React.forwardRef((props, )} ); -}); +}); \ No newline at end of file diff --git a/src/components/Composer/style.less b/src/components/Composer/style.less index 6a7cabc..848bb1f 100644 --- a/src/components/Composer/style.less +++ b/src/components/Composer/style.less @@ -8,6 +8,26 @@ } } +.suggestions{ + position: absolute; + display: flex; + min-width: 50px; + width: auto; + bottom: 42px; + left: 10px; + background-color: white; + box-shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px; +} + +.suggestion{ + padding: 0 10px; + cursor: pointer; +} +.active{ + background-color: #65c3d7; + color: white; +} + .Composer-actions { display: flex; align-items: center;