From c3c05349e6d34381188daa59eff0020e34568694 Mon Sep 17 00:00:00 2001 From: David Rouyer <rouyer.david@gmail.com> Date: Wed, 13 Sep 2023 15:03:12 +0200 Subject: [PATCH] feat(text-editor): improve command handling --- .../src/components/messages/message-form.tsx | 100 ++++++++++-------- .../my-custom-command-handler-plugin.ts | 35 ++++++ .../components/text-editor/text-editor.tsx | 2 + 3 files changed, 92 insertions(+), 45 deletions(-) create mode 100644 apps/customer-service/src/components/text-editor/plugins/my-custom-command-handler-plugin.ts diff --git a/apps/customer-service/src/components/messages/message-form.tsx b/apps/customer-service/src/components/messages/message-form.tsx index 9caa05996..da93ae2f5 100644 --- a/apps/customer-service/src/components/messages/message-form.tsx +++ b/apps/customer-service/src/components/messages/message-form.tsx @@ -1,6 +1,6 @@ 'use client'; -import { FC } from 'react'; +import { createContext, FC, RefObject, useRef } from 'react'; import { PaperclipIcon, SmilePlusIcon } from 'lucide-react'; import { useSession } from 'next-auth/react'; import { Controller, FormProvider, useForm } from 'react-hook-form'; @@ -21,7 +21,11 @@ type MessageFormSchema = { content: string; }; +export const FormElementContext = + createContext<RefObject<HTMLFormElement> | null>(null); + export const MessageForm: FC<{ ticketId: number }> = ({ ticketId }) => { + const formRef = useRef<HTMLFormElement>(null); const session = useSession(); const utils = api.useContext(); const { mutateAsync: sendMessage } = api.message.create.useMutation({ @@ -90,55 +94,61 @@ export const MessageForm: FC<{ ticketId: number }> = ({ ticketId }) => { }; return ( - <FormProvider {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="relative"> - <div className="overflow-hidden rounded-lg shadow-sm ring-1 ring-inset ring-border focus-within:ring-2 focus-within:ring-foreground"> - <Controller - name="content" - control={form.control} - render={({ field }) => <TextEditor {...field} />} - ></Controller> - {/* Spacer element to match the height of the toolbar */} - <div className="py-2" aria-hidden="true"> - {/* Matches height of button in toolbar (1px border + 36px content height) */} - <div className="py-px"> - <div className="h-9" /> + <FormElementContext.Provider value={formRef}> + <FormProvider {...form}> + <form + ref={formRef} + onSubmit={form.handleSubmit(onSubmit)} + className="relative" + > + <div className="overflow-hidden rounded-lg shadow-sm ring-1 ring-inset ring-border focus-within:ring-2 focus-within:ring-foreground"> + <Controller + name="content" + control={form.control} + render={({ field }) => <TextEditor {...field} />} + ></Controller> + {/* Spacer element to match the height of the toolbar */} + <div className="py-2" aria-hidden="true"> + {/* Matches height of button in toolbar (1px border + 36px content height) */} + <div className="py-px"> + <div className="h-9" /> + </div> </div> </div> - </div> - <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> - <div className="flex items-center space-x-5"> - <div className="flex items-center"> - <button - type="button" - className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" - > - <PaperclipIcon className="h-5 w-5" aria-hidden="true" /> - <span className="sr-only"> - <FormattedMessage id="text_editor.attach_files" /> - </span> - </button> + <div className="absolute inset-x-0 bottom-0 flex justify-between py-2 pl-3 pr-2"> + <div className="flex items-center space-x-5"> + <div className="flex items-center"> + <button + type="button" + className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" + > + <PaperclipIcon className="h-5 w-5" aria-hidden="true" /> + <span className="sr-only"> + <FormattedMessage id="text_editor.attach_files" /> + </span> + </button> + </div> + <div className="flex items-center"> + <button + type="button" + className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" + > + <SmilePlusIcon className="h-5 w-5" aria-hidden="true" /> + <span className="sr-only"> + <FormattedMessage id="text_editor.add_emoticons" /> + </span> + </button> + </div> </div> - <div className="flex items-center"> - <button - type="button" - className="-m-2.5 flex h-10 w-10 items-center justify-center rounded-full text-gray-400 hover:text-gray-500" - > - <SmilePlusIcon className="h-5 w-5" aria-hidden="true" /> - <span className="sr-only"> - <FormattedMessage id="text_editor.add_emoticons" /> - </span> - </button> + <div className="shrink-0"> + <Button type="submit" className="h-auto px-3"> + <FormattedMessage id="text_editor.send" /> + </Button> </div> </div> - <div className="shrink-0"> - <Button type="submit" className="h-auto px-3"> - <FormattedMessage id="text_editor.send" /> - </Button> - </div> - </div> - </form> - </FormProvider> + </form> + </FormProvider> + </FormElementContext.Provider> ); }; diff --git a/apps/customer-service/src/components/text-editor/plugins/my-custom-command-handler-plugin.ts b/apps/customer-service/src/components/text-editor/plugins/my-custom-command-handler-plugin.ts new file mode 100644 index 000000000..e57003b19 --- /dev/null +++ b/apps/customer-service/src/components/text-editor/plugins/my-custom-command-handler-plugin.ts @@ -0,0 +1,35 @@ +import { useContext, useEffect } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; +import { COMMAND_PRIORITY_LOW, KEY_ENTER_COMMAND } from 'lexical'; + +import { FormElementContext } from '~/components/messages/message-form'; + +// Lexical React plugins are React components, which makes them +// highly composable. Furthermore, you can lazy load plugins if +// desired, so you don't pay the cost for plugins until you +// actually use them. +export default function MyCustomCommandHandlerPlugin() { + const [editor] = useLexicalComposerContext(); + const form = useContext(FormElementContext); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + KEY_ENTER_COMMAND, + (event: KeyboardEvent) => { + // skipping if shift is pressed (defaulting to line-break) + if (event !== null && !event.ctrlKey) { + event.preventDefault(); + form?.current?.requestSubmit(); + return true; + } + return false; + }, + COMMAND_PRIORITY_LOW + ) + ); + }, [editor]); + + return null; +} diff --git a/apps/customer-service/src/components/text-editor/text-editor.tsx b/apps/customer-service/src/components/text-editor/text-editor.tsx index 31dde36e6..11cba050a 100644 --- a/apps/customer-service/src/components/text-editor/text-editor.tsx +++ b/apps/customer-service/src/components/text-editor/text-editor.tsx @@ -12,6 +12,7 @@ import { FormattedMessage } from 'react-intl'; import editorConfig from '~/components/text-editor/editorConfig'; import EmoticonPlugin from '~/components/text-editor/plugins/emoticon-plugin'; import MyCustomAutoFocusPlugin from '~/components/text-editor/plugins/my-custom-auto-focus-plugin'; +import MyCustomCommandHandlerPlugin from '~/components/text-editor/plugins/my-custom-command-handler-plugin'; import MyCustomOnChangePlugin from '~/components/text-editor/plugins/my-custom-on-change-plugin'; import MyCustomValuePlugin from '~/components/text-editor/plugins/my-custom-value-plugin'; @@ -40,6 +41,7 @@ export const TextEditor: FC<TextEditorProps> = ({ value, onChange }) => { <EmoticonPlugin /> <ClearEditorPlugin /> <MyCustomAutoFocusPlugin /> + <MyCustomCommandHandlerPlugin /> </div> </LexicalComposer> );