input.tsx (13842B)
1 2 import { $generateHtmlFromNodes, } from '@lexical/html'; 3 import { InitialConfigType, LexicalComposer } from '@lexical/react/LexicalComposer'; 4 import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; 5 import { ContentEditable } from '@lexical/react/LexicalContentEditable'; 6 import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; 7 import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary'; 8 import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'; 9 import { HeadingNode, QuoteNode } from "@lexical/rich-text"; 10 import { TableCellNode, TableNode, TableRowNode } from "@lexical/table"; 11 import { ListItemNode, ListNode } from "@lexical/list"; 12 import { CodeHighlightNode, CodeNode } from "@lexical/code"; 13 import { AutoLinkNode, LinkNode } from "@lexical/link"; 14 import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin"; 15 import { ClearEditorPlugin } from "@lexical/react/LexicalClearEditorPlugin"; 16 import { TRANSFORMERS, $convertToMarkdownString } from "@lexical/markdown"; 17 18 import AutoLinkPlugin from "./plugins/AutoLinkPlugin"; 19 import ToolbarPlugin from "./plugins/ToolbarPlugin"; 20 //import TreeViewPlugin from "./plugins/DebugPlugin"; 21 import { CustomParagraphNode } from './customNodes/CustomParagraphNode'; 22 import CodeHighlightPlugin from './plugins/CodeHighlightPlugin'; 23 import EditorTheme from './theme'; 24 25 import './input.scss'; 26 import { FC, memo, useEffect, useState } from 'react'; 27 import { Send } from 'lucide-react'; 28 import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; 29 import { $getSelection, $isRangeSelection, CLEAR_EDITOR_COMMAND, CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_CRITICAL, INSERT_PARAGRAPH_COMMAND, KEY_ENTER_COMMAND, ParagraphNode } from 'lexical'; 30 import { useLocation, } from 'react-router-dom'; 31 import { Room } from '../../../app/sdk/room'; 32 import { MentionsPlugin } from './plugins/mentions/MentionsPlugin'; 33 import { MentionNode } from './plugins/mentions/MentionNode'; 34 35 export const CAN_USE_DOM: boolean = 36 typeof window !== 'undefined' && 37 typeof window.document !== 'undefined' && 38 typeof window.document.createElement !== 'undefined'; 39 40 41 declare global { 42 interface Document { 43 documentMode?: unknown; 44 } 45 46 interface Window { 47 MSStream?: unknown; 48 } 49 } 50 51 const documentMode = 52 CAN_USE_DOM && 'documentMode' in document ? document.documentMode : null; 53 54 export const CAN_USE_BEFORE_INPUT: boolean = 55 CAN_USE_DOM && 'InputEvent' in window && !documentMode 56 ? 'getTargetRanges' in new window.InputEvent('input') 57 : false; 58 59 export const IS_SAFARI: boolean = 60 CAN_USE_DOM && /Version\/[\d.]+.*Safari/.test(navigator.userAgent); 61 62 export const IS_IOS: boolean = 63 CAN_USE_DOM && 64 /iPad|iPhone|iPod/.test(navigator.userAgent) && 65 !window.MSStream; 66 67 68 // Keep these in case we need to use them in the future. 69 // export const IS_WINDOWS: boolean = CAN_USE_DOM && /Win/.test(navigator.platform); 70 export const IS_CHROME: boolean = 71 CAN_USE_DOM && /^(?=.*Chrome).*/i.test(navigator.userAgent); 72 // export const canUseTextInputEvent: boolean = CAN_USE_DOM && 'TextEvent' in window && !documentMode; 73 74 export const IS_APPLE_WEBKIT = 75 CAN_USE_DOM && /AppleWebKit\/[\d.]+/.test(navigator.userAgent) && !IS_CHROME; 76 77 type ChatInputProps = { 78 /** 79 * The Namespace 80 */ 81 namespace: string 82 /** 83 * The current Room 84 */ 85 room: Room 86 id?: string 87 }; 88 89 function Placeholder() { 90 return <div className="editor-placeholder" id="editor-placeholder">Enter message...</div>; 91 } 92 93 type SendButtonProps = { 94 /** 95 * Callback when sending starts 96 */ 97 onStartSending: () => void 98 /** 99 * Callback when sending stops 100 */ 101 onStopSending: () => void 102 /** 103 * The current Room 104 */ 105 room: Room 106 }; 107 108 const SendButton: FC<SendButtonProps> = ({ onStartSending, onStopSending, room }: SendButtonProps) => { 109 const [editor] = useLexicalComposerContext(); 110 111 //TODO: Room change fails 112 const sendMessage = (room?: Room) => { 113 // TODO: Sanitize the html and send message to room 114 if (!room) { 115 console.warn("Got no room") 116 return; 117 } 118 119 editor.getEditorState().read(() => { 120 let htmlMessage = $generateHtmlFromNodes(editor); 121 console.log("HTML Message", htmlMessage) 122 // TODO: Make sure that we strip any non matrix stuff 123 const codeRegex = /(?<all><code .* (?:data-highlight-language="(?<language>.*?)")(?: .*?)?>(?<code>[\s\S]*?)<\/code>)/; 124 let matched = codeRegex.exec(htmlMessage); 125 while (matched !== null) { 126 if (matched) { 127 const { groups } = matched; 128 if (groups) { 129 const { all, language } = groups; 130 let { code } = groups; 131 132 const spanRegex = /<span(?: class=".*?")?>(?<content>.*?)<\/span>/; 133 let matchedspans = spanRegex.exec(code); 134 while (matchedspans !== null) { 135 if (matchedspans) { 136 const { groups } = matchedspans; 137 if (groups) { 138 const { content } = groups; 139 140 code = code.replaceAll(matchedspans[0], content) 141 } 142 } 143 matchedspans = spanRegex.exec(code) 144 } 145 146 code = code.replaceAll("<br>", "\n") 147 htmlMessage = htmlMessage.replace(all, `<pre><code class="language-${language}">${code}</code></pre>`) 148 } 149 } 150 matched = codeRegex.exec(htmlMessage) 151 } 152 const paragraphRegex = /(?<paragraph> (?:class=".*?"|data-lexical-.*?=".*?")).*?/; 153 let paragraphMatched = paragraphRegex.exec(htmlMessage); 154 while (paragraphMatched !== null) { 155 if (paragraphMatched) { 156 const { groups } = paragraphMatched; 157 if (groups) { 158 const { paragraph } = groups; 159 htmlMessage = htmlMessage.replace(paragraph, "") 160 } 161 } 162 paragraphMatched = paragraphRegex.exec(htmlMessage) 163 } 164 const spanRegex = /(?<span><span.*?>(?<content>.*?)<\/span>).*?/; 165 let spanMatched = spanRegex.exec(htmlMessage); 166 while (spanMatched !== null) { 167 if (spanMatched) { 168 const { groups } = spanMatched; 169 if (groups) { 170 const { span, content } = groups; 171 htmlMessage = htmlMessage.replace(span, content) 172 } 173 } 174 spanMatched = spanRegex.exec(htmlMessage) 175 } 176 177 const plainMessage = $convertToMarkdownString(TRANSFORMERS); 178 179 console.log(htmlMessage) 180 // TODO: local echo 181 if ((htmlMessage === "" && plainMessage === "") || htmlMessage === '<p><br></p>') { 182 return; 183 } 184 onStartSending(); 185 console.log("Sending message to: ", room.roomID) 186 187 if (htmlMessage !== '<p class="editor-paragraph"><br></p>') { 188 room.sendHtmlMessage(htmlMessage, plainMessage, () => { 189 editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); 190 editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); 191 localStorage.removeItem(`editor-${room.roomID}`); 192 onStopSending(); 193 }).catch((e) => { 194 console.log(e); 195 onStopSending(); 196 }) 197 } else { 198 room.sendTextMessage(plainMessage, () => { 199 editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); 200 editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); 201 localStorage.removeItem(`editor-${room.roomID}`); 202 onStopSending(); 203 }).catch((e) => { 204 console.log(e); 205 onStopSending(); 206 }) 207 } 208 }) 209 }; 210 211 useEffect(() => { 212 if (!room) { 213 return; 214 } 215 const removeCommand = editor.registerCommand<KeyboardEvent | null>( 216 KEY_ENTER_COMMAND, 217 (event: KeyboardEvent | null): boolean => { 218 console.log("Room changed", room.roomID) 219 const selection = $getSelection(); 220 if (!$isRangeSelection(selection)) { 221 return false; 222 } 223 if (event !== null && event !== undefined) { 224 // If we have beforeinput, then we can avoid blocking 225 // the default behavior. This ensures that the iOS can 226 // intercept that we're actually inserting a paragraph, 227 // and autocomplete, autocapitalize etc work as intended. 228 // This can also cause a strange performance issue in 229 // Safari, where there is a noticeable pause due to 230 // preventing the key down of enter. 231 if ( 232 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) && 233 CAN_USE_BEFORE_INPUT 234 ) { 235 return false; 236 } 237 event.preventDefault(); 238 if (event.shiftKey) { 239 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); 240 } 241 sendMessage(room); 242 } 243 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); 244 }, 245 COMMAND_PRIORITY_CRITICAL, 246 ) 247 return () => { 248 console.log("Removing command since room or editor changed") 249 removeCommand() 250 } 251 }, [editor, room]) 252 253 return <Send 254 size={45} 255 stroke='unset' 256 className='stroke-slate-600 rounded m-4 hover:bg-slate-300 hover:stroke-slate-500 p-2 cursor-pointer' 257 onClick={() => { sendMessage(room) }} /> 258 }; 259 260 261 type RoomChangeProps = { 262 /** 263 * The current Room 264 */ 265 room: Room 266 }; 267 268 const RoomChangePlugin: FC<RoomChangeProps> = ({ room }) => { 269 const [editor] = useLexicalComposerContext(); 270 const { pathname } = useLocation(); 271 const [prevRoom, setPrevRoom] = useState<string | undefined>(undefined); 272 273 useEffect(() => { 274 if (room) { 275 const roomID = room.roomID; 276 if (roomID !== prevRoom) { 277 console.log("Saving editor state") 278 // Save the editor state to local storage 279 const editorState = editor.getEditorState(); 280 localStorage.setItem(`editor-${prevRoom}`, JSON.stringify(editorState.toJSON())); 281 } 282 setPrevRoom(roomID); 283 const savedHtml = localStorage.getItem(`editor-${roomID}`); 284 if (savedHtml) { 285 const initialEditorState = editor.parseEditorState(savedHtml) 286 editor.setEditorState(initialEditorState) 287 } else { 288 editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); 289 editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); 290 } 291 } 292 }, [pathname, room]); 293 294 return <></> 295 } 296 297 const ChatInput: FC<ChatInputProps> = memo(({ namespace, room, id }: ChatInputProps) => { 298 const [sending, setSending] = useState(false); 299 300 const initialConfig: InitialConfigType = { 301 namespace: namespace, 302 theme: EditorTheme, 303 onError: (e) => console.error(e), 304 editable: !sending, 305 nodes: [ 306 HeadingNode, 307 ListNode, 308 ListItemNode, 309 QuoteNode, 310 CodeNode, 311 CodeHighlightNode, 312 TableNode, 313 TableCellNode, 314 TableRowNode, 315 AutoLinkNode, 316 LinkNode, 317 MentionNode, 318 CustomParagraphNode, 319 { 320 replace: ParagraphNode, 321 with: (_node: ParagraphNode) => { 322 return new CustomParagraphNode(); 323 } 324 } 325 ] 326 } 327 return ( 328 <div id={id} className='flex flex-row items-end'> 329 <LexicalComposer initialConfig={initialConfig}> 330 <div className="editor-container flex-1"> 331 <ToolbarPlugin /> 332 <div className="editor-inner"> 333 <RichTextPlugin 334 contentEditable={<ContentEditable className="editor-input" ariaLabelledBy='editor-placeholder' />} 335 placeholder={<Placeholder />} 336 ErrorBoundary={LexicalErrorBoundary} 337 /> 338 <HistoryPlugin /> 339 <LinkPlugin /> 340 <CodeHighlightPlugin /> 341 <AutoLinkPlugin /> 342 <MarkdownShortcutPlugin transformers={TRANSFORMERS} /> 343 <ClearEditorPlugin /> 344 <RoomChangePlugin room={room} /> 345 <MentionsPlugin room={room} /> 346 {/*<TreeViewPlugin />*/} 347 </div> 348 </div> 349 <SendButton room={room} onStartSending={() => { console.log("Sending"); setSending(true) }} onStopSending={() => { setSending(false) }} /> 350 </LexicalComposer> 351 352 </div> 353 ); 354 }) 355 356 export default ChatInput;