cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

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;