From fc9b6bab58940127571ef624375a6eb529020903 Mon Sep 17 00:00:00 2001 From: Nitesh Seram Date: Tue, 30 Jan 2024 05:12:45 +0530 Subject: [PATCH] [web] projects: text editor toolbar plugin system cleanup (#296) Co-authored-by: Yangshun Co-authored-by: GitHub Actions --- .../useRichTextEditorOnClickListener.tsx | 240 +----------------- .../src/components/ui/RichTextEditor/misc.ts | 14 - .../plugin/RichTextEditorItalicPlugin.tsx | 47 +++- .../RichTextEditorOrderedListPlugin.tsx | 106 +++++++- .../plugin/RichTextEditorQuotePlugin.tsx | 79 +++++- .../plugin/RichTextEditorRedoPlugin.tsx | 27 +- .../RichTextEditorSpecialCasePlugin.tsx | 83 ++++-- .../plugin/RichTextEditorTextTypePlugin.tsx | 88 ++++++- .../plugin/RichTextEditorUnderlinePlugin.tsx | 47 +++- .../plugin/RichTextEditorUndoPlugin.tsx | 27 +- .../RichTextEditorUnorderedListPlugin.tsx | 106 +++++++- .../src/components/ui/RichTextEditor/types.ts | 17 +- apps/web/src/locales/en-US.json | 8 + 13 files changed, 576 insertions(+), 313 deletions(-) diff --git a/apps/web/src/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener.tsx b/apps/web/src/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener.tsx index 2de76ea1a..3714b0fe5 100644 --- a/apps/web/src/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener.tsx +++ b/apps/web/src/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener.tsx @@ -3,13 +3,8 @@ import { $getNodeByKey, $getSelection, $isRangeSelection, - CAN_REDO_COMMAND, - CAN_UNDO_COMMAND, COMMAND_PRIORITY_CRITICAL, - FORMAT_TEXT_COMMAND, - REDO_COMMAND, SELECTION_CHANGE_COMMAND, - UNDO_COMMAND, } from 'lexical'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -25,45 +20,20 @@ import { getCodeLanguages, getDefaultCodeLanguage, } from '@lexical/code'; -import { - $isListNode, - INSERT_ORDERED_LIST_COMMAND, - INSERT_UNORDERED_LIST_COMMAND, - insertList, - ListNode, - REMOVE_LIST_COMMAND, - removeList, -} from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { - $createHeadingNode, - $createQuoteNode, - $isHeadingNode, - $isQuoteNode, -} from '@lexical/rich-text'; import { $setBlocksType } from '@lexical/selection'; -import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; +import { mergeRegister } from '@lexical/utils'; const priority = COMMAND_PRIORITY_CRITICAL; export default function useRichTextEditorOnClickListener() { const [editor] = useLexicalComposerContext(); - const [isItalic, setIsItalic] = useState(false); - const [canRedo, setCanRedo] = useState(false); - const [canUndo, setCanUndo] = useState(false); - const [isUnderline, setIsUnderline] = useState(false); - const [isOrderedList, setIsOrderedList] = useState(false); - const [isUnorderedList, setIsUnorderedList] = useState(false); const [isCode, setIsCode] = useState(false); - const [isQuote, setIsQuote] = useState(false); const [codeLanguage, setCodeLanguage] = useState(''); const [selectedElementKey, setSelectedElementKey] = useState( null, ); - const [headingType, setHeadingType] = useState('normal'); - const [specialCase, setSpecialCase] = - useState(null); const updateToolbar = useCallback(() => { const selection = $getSelection(); @@ -79,60 +49,13 @@ export default function useRichTextEditorOnClickListener() { if (elementDOM !== null) { setSelectedElementKey(elementKey); - if ($isListNode(element)) { - const parentList = $getNearestNodeOfType(anchorNode, ListNode); - const type = parentList ? parentList.getTag() : element.getTag(); - - if (type === 'ol') { - setIsOrderedList(true); - } - if (type === 'ul') { - setIsUnorderedList(true); - } + if ($isCodeNode(element)) { + setIsCode(true); + setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); } else { - const type = $isHeadingNode(element) - ? element.getTag() - : element.getType(); - - if (type === 'paragraph') { - setHeadingType('normal'); - } - if (type === 'h1') { - setHeadingType('h1'); - } - if (type === 'h2') { - setHeadingType('h2'); - } - if (type === 'h3') { - setHeadingType('h3'); - } - setIsOrderedList(false); - setIsUnorderedList(false); - if ($isCodeNode(element)) { - setIsCode(true); - setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); - } else { - setIsCode(false); - } - if ($isQuoteNode(element)) { - setIsQuote(true); - } else { - setIsQuote(false); - } + setIsCode(false); } } - - setIsItalic(selection.hasFormat('italic')); - setIsUnderline(selection.hasFormat('underline')); - if (selection.hasFormat('strikethrough')) { - setSpecialCase('strikethrough' as RichTextEditorSpecialCase); - } else if (selection.hasFormat('subscript')) { - setSpecialCase('subscript' as RichTextEditorSpecialCase); - } else if (selection.hasFormat('superscript')) { - setSpecialCase('superscript' as RichTextEditorSpecialCase); - } else { - setSpecialCase(null); - } } }, [editor]); @@ -147,86 +70,12 @@ export default function useRichTextEditorOnClickListener() { }, priority, ), - editor.registerCommand( - CAN_REDO_COMMAND, - (payload) => { - setCanRedo(payload); - - return false; - }, - priority, - ), - editor.registerCommand( - CAN_UNDO_COMMAND, - (payload) => { - setCanUndo(payload); - - return false; - }, - priority, - ), - editor.registerCommand( - INSERT_ORDERED_LIST_COMMAND, - () => { - insertList(editor, 'number'); - - return true; - }, - priority, - ), - editor.registerCommand( - INSERT_UNORDERED_LIST_COMMAND, - () => { - insertList(editor, 'bullet'); - - return true; - }, - priority, - ), - editor.registerCommand( - REMOVE_LIST_COMMAND, - () => { - removeList(editor); - setIsOrderedList(false); - setIsUnorderedList(false); - - return true; - }, - priority, - ), ); }, [editor, updateToolbar]); const onClick = ( event: RichTextEditorEventType | RichTextEditorSpecialCase, ) => { - if (event === richTextEditorToolbarEventTypes.italic) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic'); - } - if (event === richTextEditorToolbarEventTypes.undo) { - editor.dispatchCommand(UNDO_COMMAND, undefined); - } - if (event === richTextEditorToolbarEventTypes.redo) { - editor.dispatchCommand(REDO_COMMAND, undefined); - } - if (event === richTextEditorToolbarEventTypes.underline) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline'); - } - if (event === richTextEditorToolbarEventTypes.ol) { - if (isOrderedList) { - editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); - } else { - editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); - } - } - if (event === richTextEditorToolbarEventTypes.ul) { - if (isUnorderedList) { - editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); - } else { - editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); - } - } - if (event === richTextEditorToolbarEventTypes.code) { editor.update(() => { const selection = $getSelection(); @@ -240,76 +89,6 @@ export default function useRichTextEditorOnClickListener() { } }); } - - if (event === richTextEditorToolbarEventTypes.quote) { - editor.update(() => { - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - if (!isQuote) { - $setBlocksType(selection, () => $createQuoteNode()); - } else { - $setBlocksType(selection, () => $createParagraphNode()); - } - } - }); - } - - if (event === richTextEditorToolbarEventTypes.normal) { - editor.update(() => { - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createParagraphNode()); - } - }); - } - if (event === richTextEditorToolbarEventTypes.h1) { - editor.update(() => { - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode('h1')); - } - }); - } - if (event === richTextEditorToolbarEventTypes.h2) { - editor.update(() => { - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode('h2')); - } - }); - } - if (event === richTextEditorToolbarEventTypes.h3) { - editor.update(() => { - const selection = $getSelection(); - - if ($isRangeSelection(selection)) { - $setBlocksType(selection, () => $createHeadingNode('h3')); - } - }); - } - - if (event === richTextEditorToolbarEventTypes.strikethrough) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough'); - if (specialCase === richTextEditorToolbarEventTypes.strikethrough) { - setSpecialCase(null); - } - } - if (event === richTextEditorToolbarEventTypes.subscript) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript'); - if (specialCase === richTextEditorToolbarEventTypes.subscript) { - setSpecialCase(null); - } - } - if (event === richTextEditorToolbarEventTypes.superscript) { - editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript'); - if (specialCase === richTextEditorToolbarEventTypes.superscript) { - setSpecialCase(null); - } - } }; const codeLanguages = useMemo(() => getCodeLanguages(), []); @@ -330,19 +109,10 @@ export default function useRichTextEditorOnClickListener() { ); return { - canRedo, - canUndo, codeLanguage, codeLanguages, - headingType, isCode, - isItalic, - isOrderedList, - isQuote, - isUnderline, - isUnorderedList, onClick, onCodeLanguageSelect, - specialCase, }; } diff --git a/apps/web/src/components/ui/RichTextEditor/misc.ts b/apps/web/src/components/ui/RichTextEditor/misc.ts index 833d7ce63..eeddbf076 100644 --- a/apps/web/src/components/ui/RichTextEditor/misc.ts +++ b/apps/web/src/components/ui/RichTextEditor/misc.ts @@ -5,18 +5,4 @@ export const richTextEditorToolbarEventTypes: Record< RichTextEditorEventType > = { code: 'code', - h1: 'h1', - h2: 'h2', - h3: 'h3', - italic: 'italic', - normal: 'normal', - ol: 'ol', - quote: 'quote', - redo: 'redo', - strikethrough: 'strikethrough', - subscript: 'subscript', - superscript: 'superscript', - ul: 'ul', - underline: 'underline', - undo: 'undo', }; diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorItalicPlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorItalicPlugin.tsx index 08737ebfc..8188bfcd9 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorItalicPlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorItalicPlugin.tsx @@ -1,13 +1,52 @@ +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + FORMAT_TEXT_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiItalic } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; export default function RichTextEditorItalicPlugin() { const intl = useIntl(); - const { isItalic, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [isItalic, setIsItalic] = useState(false); + + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + setIsItalic(selection.hasFormat('italic')); + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateState(); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); return ( onClick(richTextEditorToolbarEventTypes.italic)} + onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorOrderedListPlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorOrderedListPlugin.tsx index f830879d9..3fd570661 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorOrderedListPlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorOrderedListPlugin.tsx @@ -1,13 +1,111 @@ +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_LOW, + INSERT_PARAGRAPH_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiListOrdered2 } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { + $handleListInsertParagraph, + $isListNode, + INSERT_ORDERED_LIST_COMMAND, + insertList, + ListNode, + REMOVE_LIST_COMMAND, + removeList, +} from '@lexical/list'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; export default function RichTextEditorOrderedListPlugin() { const intl = useIntl(); - const { isOrderedList, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [isOrderedList, setIsOrderedList] = useState(false); + + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === 'root' + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + if (elementDOM === null) { + return; + } + + if (!$isListNode(element)) { + setIsOrderedList(false); + + return; + } + + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + const type = parentList ? parentList.getTag() : element.getTag(); + + if (type === 'ol') { + setIsOrderedList(true); + } else { + setIsOrderedList(false); + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + INSERT_ORDERED_LIST_COMMAND, + () => { + insertList(editor, 'number'); + + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + REMOVE_LIST_COMMAND, + () => { + removeList(editor); + setIsOrderedList(false); + + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return $handleListInsertParagraph(); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); + + const onFormatOrderedList = () => { + if (isOrderedList) { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); + } + }; return ( onClick(richTextEditorToolbarEventTypes.ol)} + onClick={onFormatOrderedList} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorQuotePlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorQuotePlugin.tsx index acefc0df3..409f72620 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorQuotePlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorQuotePlugin.tsx @@ -1,13 +1,84 @@ +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiQuoteText } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $createQuoteNode, $isQuoteNode } from '@lexical/rich-text'; +import { $setBlocksType } from '@lexical/selection'; +import { mergeRegister } from '@lexical/utils'; export default function RichTextEditorQuotePlugin() { const intl = useIntl(); - const { isQuote, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [isQuote, setIsQuote] = useState(false); + + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === 'root' + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + if (elementDOM === null) { + return; + } + + if ($isQuoteNode(element)) { + setIsQuote(true); + } else { + setIsQuote(false); + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateState(); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); + + const onFormatQuote = () => { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (!isQuote) { + $setBlocksType(selection, () => $createQuoteNode()); + } else { + $setBlocksType(selection, () => $createParagraphNode()); + } + } + }); + }; return ( onClick(richTextEditorToolbarEventTypes.quote)} + onClick={onFormatQuote} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorRedoPlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorRedoPlugin.tsx index bd2ff51d3..36e094a76 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorRedoPlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorRedoPlugin.tsx @@ -1,13 +1,32 @@ +import { + CAN_REDO_COMMAND, + COMMAND_PRIORITY_CRITICAL, + REDO_COMMAND, +} from 'lexical'; +import { useEffect, useState } from 'react'; import { RiArrowGoForwardLine } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; export default function RichTextEditorRedoPlugin() { const intl = useIntl(); - const { canRedo, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [canRedo, setCanRedo] = useState(false); + + useEffect(() => { + return editor.registerCommand( + CAN_REDO_COMMAND, + (payload) => { + setCanRedo(payload); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); return ( onClick(richTextEditorToolbarEventTypes.redo)} + onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorSpecialCasePlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorSpecialCasePlugin.tsx index fe77cff9f..06c49c904 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorSpecialCasePlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorSpecialCasePlugin.tsx @@ -1,3 +1,11 @@ +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + FORMAT_TEXT_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiFontSize, RiStrikethrough, @@ -7,13 +15,18 @@ import { import { useIntl } from 'react-intl'; import DropdownMenu from '~/components/ui/DropdownMenu'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; import type { RichTextEditorSpecialCase } from '~/components/ui/RichTextEditor/types'; -import Tooltip from '~/components/ui/Tooltip'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; export default function RichTextEditorSpecialCasePlugin() { const intl = useIntl(); - const { specialCase, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + + const [isStrikethrough, setIsStrikethrough] = useState(false); + const [isSubscript, setIsSubscript] = useState(false); + const [isSuperscript, setIsSuperscript] = useState(false); const caseOptions: Array<{ icon: (props: React.ComponentProps<'svg'>) => JSX.Element; label: string; @@ -48,14 +61,58 @@ export default function RichTextEditorSpecialCasePlugin() { }, ]; - const selectedValue = caseOptions.find((type) => type.value === specialCase); + const $updateState = useCallback(() => { + const selection = $getSelection(); - const menu = ( + if (!$isRangeSelection(selection)) { + return; + } + + setIsStrikethrough(selection.hasFormat('strikethrough')); + setIsSubscript(selection.hasFormat('subscript')); + setIsSuperscript(selection.hasFormat('superscript')); + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateState(); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); + + const isSelectedValue = (value: RichTextEditorSpecialCase) => { + if (value === 'strikethrough') { + return isStrikethrough; + } + if (value === 'subscript') { + return isSubscript; + } + + return isSuperscript; + }; + + return ( @@ -63,19 +120,11 @@ export default function RichTextEditorSpecialCasePlugin() { onClick(value)} + onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, value)} /> ))} ); - - return specialCase == null ? ( - menu - ) : ( - - {menu} - - ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorTextTypePlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorTextTypePlugin.tsx index d2bec7d97..d373db4b9 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorTextTypePlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorTextTypePlugin.tsx @@ -1,13 +1,27 @@ +import { + $createParagraphNode, + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiFontSize2, RiH1, RiH2, RiH3 } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import DropdownMenu from '~/components/ui/DropdownMenu'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; import type { RichTextEditorHeadingType } from '~/components/ui/RichTextEditor/types'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $createHeadingNode, $isHeadingNode } from '@lexical/rich-text'; +import { $setBlocksType } from '@lexical/selection'; +import { mergeRegister } from '@lexical/utils'; + export default function RichTextEditorTextTypePlugin() { const intl = useIntl(); - const { headingType, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [headingType, setHeadingType] = useState('normal'); + const typeOptions: Array<{ icon: (props: React.ComponentProps<'svg'>) => JSX.Element; label: string; @@ -51,8 +65,76 @@ export default function RichTextEditorTextTypePlugin() { }, ]; + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === 'root' + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + if (elementDOM === null) { + return; + } + + const type = $isHeadingNode(element) ? element.getTag() : element.getType(); + + if (type === 'paragraph') { + setHeadingType('normal'); + } + if (type === 'h1') { + setHeadingType('h1'); + } + if (type === 'h2') { + setHeadingType('h2'); + } + if (type === 'h3') { + setHeadingType('h3'); + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateState(); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); + const selectedValue = typeOptions.find((type) => type.value === headingType); + const onFormatHeading = (type: RichTextEditorHeadingType) => { + editor.update(() => { + const selection = $getSelection(); + + if ($isRangeSelection(selection)) { + if (type === 'normal') { + $setBlocksType(selection, () => $createParagraphNode()); + } else { + $setBlocksType(selection, () => $createHeadingNode(type)); + } + } + }); + }; + return ( onClick(value)} + onClick={() => onFormatHeading(value)} /> ))} diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnderlinePlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnderlinePlugin.tsx index 862563528..5e25a50d2 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnderlinePlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnderlinePlugin.tsx @@ -1,13 +1,52 @@ +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + FORMAT_TEXT_COMMAND, + SELECTION_CHANGE_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiUnderline } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { mergeRegister } from '@lexical/utils'; export default function RichTextEditorUnderlinePlugin() { const intl = useIntl(); - const { isUnderline, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [isUnderline, setIsUnderline] = useState(false); + + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + setIsUnderline(selection.hasFormat('underline')); + }, []); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { + $updateState(); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); return ( onClick(richTextEditorToolbarEventTypes.underline)} + onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUndoPlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUndoPlugin.tsx index 9a291dfe2..d5043eb27 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUndoPlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUndoPlugin.tsx @@ -1,13 +1,32 @@ +import { + CAN_UNDO_COMMAND, + COMMAND_PRIORITY_CRITICAL, + UNDO_COMMAND, +} from 'lexical'; +import { useEffect, useState } from 'react'; import { RiArrowGoBackLine } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; export default function RichTextEditorUndoPlugin() { const intl = useIntl(); - const { canUndo, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [canUndo, setCanUndo] = useState(false); + + useEffect(() => { + return editor.registerCommand( + CAN_UNDO_COMMAND, + (payload) => { + setCanUndo(payload); + + return false; + }, + COMMAND_PRIORITY_CRITICAL, + ); + }, [editor]); return ( onClick(richTextEditorToolbarEventTypes.undo)} + onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnorderedListPlugin.tsx b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnorderedListPlugin.tsx index 0f9e200dd..068840df4 100644 --- a/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnorderedListPlugin.tsx +++ b/apps/web/src/components/ui/RichTextEditor/plugin/RichTextEditorUnorderedListPlugin.tsx @@ -1,13 +1,111 @@ +import { + $getSelection, + $isRangeSelection, + COMMAND_PRIORITY_CRITICAL, + COMMAND_PRIORITY_LOW, + INSERT_PARAGRAPH_COMMAND, +} from 'lexical'; +import { useCallback, useEffect, useState } from 'react'; import { RiListUnordered } from 'react-icons/ri'; import { useIntl } from 'react-intl'; import RichTextEditorToolbarActionNode from '~/components/ui/RichTextEditor/components/RichTextEditorToolbarActionNode'; -import useRichTextEditorOnClickListener from '~/components/ui/RichTextEditor/hooks/useRichTextEditorOnClickListener'; -import { richTextEditorToolbarEventTypes } from '~/components/ui/RichTextEditor/misc'; + +import { + $handleListInsertParagraph, + $isListNode, + INSERT_UNORDERED_LIST_COMMAND, + insertList, + ListNode, + REMOVE_LIST_COMMAND, + removeList, +} from '@lexical/list'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; export default function RichTextEditorUnorderedListPlugin() { const intl = useIntl(); - const { isUnorderedList, onClick } = useRichTextEditorOnClickListener(); + const [editor] = useLexicalComposerContext(); + const [isUnorderedList, setIsUnorderedList] = useState(false); + + const $updateState = useCallback(() => { + const selection = $getSelection(); + + if (!$isRangeSelection(selection)) { + return; + } + + const anchorNode = selection.anchor.getNode(); + const element = + anchorNode.getKey() === 'root' + ? anchorNode + : anchorNode.getTopLevelElementOrThrow(); + const elementKey = element.getKey(); + const elementDOM = editor.getElementByKey(elementKey); + + if (elementDOM === null) { + return; + } + + if (!$isListNode(element)) { + setIsUnorderedList(false); + + return; + } + + const parentList = $getNearestNodeOfType(anchorNode, ListNode); + const type = parentList ? parentList.getTag() : element.getTag(); + + if (type === 'ul') { + setIsUnorderedList(true); + } else { + setIsUnorderedList(false); + } + }, [editor]); + + useEffect(() => { + return mergeRegister( + editor.registerCommand( + INSERT_UNORDERED_LIST_COMMAND, + () => { + insertList(editor, 'bullet'); + + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + REMOVE_LIST_COMMAND, + () => { + removeList(editor); + setIsUnorderedList(false); + + return true; + }, + COMMAND_PRIORITY_CRITICAL, + ), + editor.registerCommand( + INSERT_PARAGRAPH_COMMAND, + () => { + return $handleListInsertParagraph(); + }, + COMMAND_PRIORITY_LOW, + ), + editor.registerUpdateListener(({ editorState }) => { + editorState.read(() => { + $updateState(); + }); + }), + ); + }, [editor, $updateState]); + + const onFormatUnorderedList = () => { + if (isUnorderedList) { + editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + } else { + editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); + } + }; return ( onClick(richTextEditorToolbarEventTypes.ul)} + onClick={onFormatUnorderedList} /> ); } diff --git a/apps/web/src/components/ui/RichTextEditor/types.ts b/apps/web/src/components/ui/RichTextEditor/types.ts index a584d850b..1c36583cf 100644 --- a/apps/web/src/components/ui/RichTextEditor/types.ts +++ b/apps/web/src/components/ui/RichTextEditor/types.ts @@ -1,19 +1,4 @@ -export type RichTextEditorEventType = - | 'code' - | 'h1' - | 'h2' - | 'h3' - | 'italic' - | 'normal' - | 'ol' - | 'quote' - | 'redo' - | 'strikethrough' - | 'subscript' - | 'superscript' - | 'ul' - | 'underline' - | 'undo'; +export type RichTextEditorEventType = 'code'; export type RichTextEditorHeadingType = 'h1' | 'h2' | 'h3' | 'normal'; diff --git a/apps/web/src/locales/en-US.json b/apps/web/src/locales/en-US.json index d66038b76..8f8be4a70 100644 --- a/apps/web/src/locales/en-US.json +++ b/apps/web/src/locales/en-US.json @@ -135,6 +135,10 @@ "defaultMessage": "Challenge brief", "description": "Projects challenge submission hero card title" }, + "/CfHhU": { + "defaultMessage": "Special case for richtext editor", + "description": "Special case action for richtext editor toolbar" + }, "/F80kH": { "defaultMessage": "Read our Front End Interview Guidebook", "description": "Link to front end interview guidebook" @@ -4283,6 +4287,10 @@ "defaultMessage": "Copy line down", "description": "Text describing command to copy lines down in the coding workspace shortcuts menu" }, + "d4ZiFU": { + "defaultMessage": "Quote", + "description": "Quote tooltip for richtext toolbar" + }, "d7gVP2": { "defaultMessage": "Chennai, India", "description": "Chennai in India"