import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { CAN_REDO_COMMAND, CAN_UNDO_COMMAND, REDO_COMMAND, UNDO_COMMAND, SELECTION_CHANGE_COMMAND, FORMAT_TEXT_COMMAND, FORMAT_ELEMENT_COMMAND, OUTDENT_CONTENT_COMMAND, INDENT_CONTENT_COMMAND, $getSelection, $isElementNode, $isRangeSelection, $createParagraphNode, $getNodeByKey, } from 'lexical'; import { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'; import { $getSelectionStyleValueForProperty, $isParentElementRTL, $patchStyleText, $setBlocksType, // $wrapNodes, $isAtNodeEnd, } from '@lexical/selection'; import { $findMatchingParent, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'; import { INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, $isListNode, ListNode } from '@lexical/list'; import { createPortal } from 'react-dom'; import { $createHeadingNode, $createQuoteNode, $isHeadingNode } from '@lexical/rich-text'; import { $createCodeNode, $isCodeNode, getDefaultCodeLanguage, getCodeLanguages } from '@lexical/code'; import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'; import DropDown, { DropDownItem } from './../ui/DropDown'; import DropdownColorPicker from '../ui/DropdownColorPicker'; import { // INSERT_IMAGE_COMMAND, InsertImageDialog, // InsertImagePayload, } from './ImagesPlugin'; import {InsertInlineImageDialog} from './InlineImagePlugin'; import useModal from './../hooks/useModal'; const LowPriority = 1; const supportedBlockTypes = new Set(['paragraph', 'quote', 'code', 'h1', 'h2', 'h3', 'ul', 'ol']); const blockTypeToBlockName = { code: 'Code Block', h1: 'Large Heading', h2: 'Small Heading', h3: 'Heading', h4: 'Heading', h5: 'Heading', ol: 'Numbered List', paragraph: 'Normal', quote: 'Quote', ul: 'Bulleted List', }; const FONT_FAMILY_OPTIONS = [ ['Arial', 'Arial'], ['Courier New', 'Courier New'], ['Georgia', 'Georgia'], ['Times New Roman', 'Times New Roman'], ['Trebuchet MS', 'Trebuchet MS'], ['Verdana', 'Verdana'], ]; const FONT_SIZE_OPTIONS = [ ['10px', '10px'], ['11px', '11px'], ['12px', '12px'], ['13px', '13px'], ['14px', '14px'], ['15px', '15px'], ['16px', '16px'], ['17px', '17px'], ['18px', '18px'], ['19px', '19px'], ['20px', '20px'], ]; const ELEMENT_FORMAT_OPTIONS = { center: { icon: 'center-align', iconRTL: 'center-align', name: 'Center Align' }, end: { icon: 'right-align', iconRTL: 'left-align', name: 'End Align' }, justify: { icon: 'justify-align', iconRTL: 'justify-align', name: 'Justify Align' }, left: { icon: 'left-align', iconRTL: 'left-align', name: 'Left Align' }, right: { icon: 'right-align', iconRTL: 'right-align', name: 'Right Align' }, start: { icon: 'left-align', iconRTL: 'right-align', name: 'Start Align' }, }; function dropDownActiveClass(active) { if (active) { return 'active dropdown-item-active'; } else { return ''; } } function Divider() { return
; } function positionEditorElement(editor, rect) { if (rect === null) { editor.style.opacity = '0'; editor.style.top = '-1000px'; editor.style.left = '-1000px'; } else { editor.style.opacity = '1'; editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`; editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2}px`; } } function FloatingLinkEditor({ editor }) { const editorRef = useRef(null); const inputRef = useRef(null); const mouseDownRef = useRef(false); const [linkUrl, setLinkUrl] = useState(''); const [isEditMode, setEditMode] = useState(false); const [lastSelection, setLastSelection] = useState(null); const updateLinkEditor = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { const node = getSelectedNode(selection); const parent = node.getParent(); if ($isLinkNode(parent)) { setLinkUrl(parent.getURL()); } else if ($isLinkNode(node)) { setLinkUrl(node.getURL()); } else { setLinkUrl(''); } } const editorElem = editorRef.current; const nativeSelection = window.getSelection(); const activeElement = document.activeElement; if (editorElem === null) { return; } const rootElement = editor.getRootElement(); if (selection !== null && !nativeSelection.isCollapsed && rootElement !== null && rootElement.contains(nativeSelection.anchorNode)) { const domRange = nativeSelection.getRangeAt(0); let rect; if (nativeSelection.anchorNode === rootElement) { let inner = rootElement; while (inner.firstElementChild != null) { inner = inner.firstElementChild; } rect = inner.getBoundingClientRect(); } else { rect = domRange.getBoundingClientRect(); } if (!mouseDownRef.current) { positionEditorElement(editorElem, rect); } setLastSelection(selection); } else if (!activeElement || activeElement.className !== 'link-input') { positionEditorElement(editorElem, null); setLastSelection(null); setEditMode(false); setLinkUrl(''); } return true; }, [editor]); useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateLinkEditor(); }); }), editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { updateLinkEditor(); return true; }, LowPriority ) ); }, [editor, updateLinkEditor]); useEffect(() => { editor.getEditorState().read(() => { updateLinkEditor(); }); }, [editor, updateLinkEditor]); useEffect(() => { if (isEditMode && inputRef.current) { inputRef.current.focus(); } }, [isEditMode]); return (
{isEditMode ? ( { setLinkUrl(event.target.value); }} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); if (lastSelection !== null) { if (linkUrl !== '') { editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); } setEditMode(false); } } else if (event.key === 'Escape') { event.preventDefault(); setEditMode(false); } }} /> ) : ( <>
{linkUrl}
event.preventDefault()} onClick={() => { setEditMode(true); }} />
)}
); } function Select({ onChange, className, options, value }) { return ( ); } function getSelectedNode(selection) { const anchor = selection.anchor; const focus = selection.focus; const anchorNode = selection.anchor.getNode(); const focusNode = selection.focus.getNode(); if (anchorNode === focusNode) { return anchorNode; } const isBackward = selection.isBackward(); if (isBackward) { return $isAtNodeEnd(focus) ? anchorNode : focusNode; } else { return $isAtNodeEnd(anchor) ? focusNode : anchorNode; } } function getDomRangeRect(nativeSelection, rootElement) { const domRange = nativeSelection.getRangeAt(0); let rect; if (nativeSelection.anchorNode === rootElement) { let inner = rootElement; while (inner.firstElementChild != null) { inner = inner.firstElementChild; } rect = inner.getBoundingClientRect(); } else { rect = domRange.getBoundingClientRect(); } return rect; } function BlockOptionsDropdownList({ editor, blockType, toolbarRef, setShowBlockOptionsDropDown }) { const dropDownRef = useRef(null); useEffect(() => { const toolbar = toolbarRef.current; const dropDown = dropDownRef.current; if (toolbar !== null && dropDown !== null) { const { top, left } = toolbar.getBoundingClientRect(); dropDown.style.top = `${top + 40}px`; dropDown.style.left = `${left}px`; } }, [dropDownRef, toolbarRef]); useEffect(() => { const dropDown = dropDownRef.current; const toolbar = toolbarRef.current; if (dropDown !== null && toolbar !== null) { const handle = (event) => { const target = event.target; if (!dropDown.contains(target) && !toolbar.contains(target)) { setShowBlockOptionsDropDown(false); } }; document.addEventListener('click', handle); return () => { document.removeEventListener('click', handle); }; } }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]); const formatParagraph = () => { if (blockType !== 'paragraph') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createParagraphNode()); } }); } setShowBlockOptionsDropDown(false); }; const formatLargeHeading = () => { if (blockType !== 'h1') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode('h1')); } }); } setShowBlockOptionsDropDown(false); }; const formatSmallHeading = () => { if (blockType !== 'h2') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode('h2')); } }); } setShowBlockOptionsDropDown(false); }; const formatSmallHeading3 = () => { if (blockType !== 'h3') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createHeadingNode('h3')); } }); } setShowBlockOptionsDropDown(false); }; const formatBulletList = () => { if (blockType !== 'ul') { editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND); } else { editor.dispatchCommand(REMOVE_LIST_COMMAND); } setShowBlockOptionsDropDown(false); }; const formatNumberedList = () => { if (blockType !== 'ol') { editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND); } else { editor.dispatchCommand(REMOVE_LIST_COMMAND); } setShowBlockOptionsDropDown(false); }; const formatQuote = () => { if (blockType !== 'quote') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createQuoteNode()); } }); } setShowBlockOptionsDropDown(false); }; const formatCode = () => { if (blockType !== 'code') { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $setBlocksType(selection, () => $createCodeNode()); } }); } setShowBlockOptionsDropDown(false); }; return (
{/* */}
); } function FontDropDown({ editor, value, style, disabled = false }) { const handleClick = useCallback( (option) => { editor.update(() => { const selection = $getSelection(); if (selection !== null) { $patchStyleText(selection, { [style]: option, }); } }); }, [editor, style] ); const buttonAriaLabel = style === 'font-family' ? 'Formatting options for font family' : 'Formatting options for font size'; return ( {(style === 'font-family' ? FONT_FAMILY_OPTIONS : FONT_SIZE_OPTIONS).map(([option, text]) => ( handleClick(option)} key={option}> {text} ))} ); } function ElementFormatDropdown({ editor, value, isRTL, disabled = false }) { const formatOption = ELEMENT_FORMAT_OPTIONS[value || 'left']; return ( { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left'); }} className='item'> Left Align { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center'); }} className='item'> Center Align { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right'); }} className='item'> Right Align { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify'); }} className='item'> Justify Align { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'start'); }} className='item'> Start Align { editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'end'); }} className='item'> End Align { editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); }} className='item'> Outdent (Shift+Tab) { editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); }} className='item'> Indent (Tab) ); } export default function ToolbarPlugin() { const [editor] = useLexicalComposerContext(); const [activeEditor, setActiveEditor] = useState(editor); const toolbarRef = useRef(null); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); const [blockType, setBlockType] = useState('paragraph'); const [selectedElementKey, setSelectedElementKey] = useState(null); const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState(false); const [codeLanguage, setCodeLanguage] = useState(''); const [isRTL, setIsRTL] = useState(false); const [isLink, setIsLink] = useState(false); const [isBold, setIsBold] = useState(false); const [isItalic, setIsItalic] = useState(false); const [isUnderline, setIsUnderline] = useState(false); const [isStrikethrough, setIsStrikethrough] = useState(false); const [isCode, setIsCode] = useState(false); const [fontFamily, setFontFamily] = useState('Arial'); const [fontColor, setFontColor] = useState('#000'); const [bgColor, setBgColor] = useState('#fff'); const [elementFormat, setElementFormat] = useState('left'); const [floatingAnchorElem, setFloatingAnchorElem] = useState(null); const [isSmallWidthViewport, setIsSmallWidthViewport] = useState(false); const [modal, showModal] = useModal(); const applyStyleText = useCallback( (styles, skipHistoryStack = null) => { editor.update( () => { const selection = $getSelection(); if (selection !== null) { $patchStyleText(selection, styles); } }, skipHistoryStack ? { tag: 'historic' } : {} ); }, [editor] ); const onFontColorSelect = useCallback( (value, skipHistoryStack) => { applyStyleText({ color: value }, skipHistoryStack); }, [applyStyleText] ); const onBgColorSelect = useCallback( (value, skipHistoryStack) => { applyStyleText({ 'background-color': value }, skipHistoryStack); }, [applyStyleText] ); const updateToolbar = useCallback(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { 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) { setSelectedElementKey(elementKey); if ($isListNode(element)) { const parentList = $getNearestNodeOfType(anchorNode, ListNode); const type = parentList ? parentList.getTag() : element.getTag(); setBlockType(type); } else { const type = $isHeadingNode(element) ? element.getTag() : element.getType(); setBlockType(type); if ($isCodeNode(element)) { setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); } } } // Update text format setIsBold(selection.hasFormat('bold')); setIsItalic(selection.hasFormat('italic')); setIsUnderline(selection.hasFormat('underline')); setIsStrikethrough(selection.hasFormat('strikethrough')); setIsCode(selection.hasFormat('code')); setIsRTL($isParentElementRTL(selection)); // Update links const node = getSelectedNode(selection); const parent = node.getParent(); if ($isLinkNode(parent) || $isLinkNode(node)) { setIsLink(true); } else { setIsLink(false); } // Handle buttons setFontColor($getSelectionStyleValueForProperty(selection, 'color', '#000')); setBgColor( $getSelectionStyleValueForProperty( selection, 'background-color', '#fff', ), ); setFontFamily( $getSelectionStyleValueForProperty(selection, 'font-family', 'Arial'), ); let matchingParent; if ($isLinkNode(parent)) { // If node is a link, we need to fetch the parent paragraph node to set format matchingParent = $findMatchingParent(node, (parentNode) => $isElementNode(parentNode) && !parentNode.isInline()); } // If matchingParent is a valid node, pass it's format type setElementFormat($isElementNode(matchingParent) ? matchingParent.getFormatType() : $isElementNode(node) ? node.getFormatType() : parent?.getFormatType() || 'left'); } }, [editor]); useEffect(() => { return mergeRegister( editor.registerUpdateListener(({ editorState }) => { editorState.read(() => { updateToolbar(); }); }), editor.registerCommand( SELECTION_CHANGE_COMMAND, (_payload, newEditor) => { setActiveEditor(newEditor); updateToolbar(); return false; }, LowPriority // COMMAND_PRIORITY_CRITICAL, ), editor.registerCommand( CAN_UNDO_COMMAND, (payload) => { setCanUndo(payload); return false; }, LowPriority ), editor.registerCommand( CAN_REDO_COMMAND, (payload) => { setCanRedo(payload); return false; }, LowPriority ) ); }, [editor, updateToolbar]); const codeLanguges = useMemo(() => getCodeLanguages(), []); const onCodeLanguageSelect = useCallback( (e) => { editor.update(() => { if (selectedElementKey !== null) { const node = $getNodeByKey(selectedElementKey); if ($isCodeNode(node)) { node.setLanguage(e.target.value); } } }); }, [editor, selectedElementKey] ); const insertLink = useCallback(() => { if (!isLink) { editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://'); } else { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); } }, [editor, isLink]); const insertHorizontalRule = useCallback(() => { editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined); }, [editor]); return (
{supportedBlockTypes.has(blockType) && ( <> {showBlockOptionsDropDown && createPortal( , document.body )} )} {blockType === 'code' ? ( <>