ToolbarPlugin.tsx (26491B)
1 import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; 2 import { 3 useCallback, 4 useEffect, 5 useMemo, 6 useRef, 7 useState, 8 ChangeEventHandler, 9 ChangeEvent, 10 MutableRefObject, 11 Dispatch, 12 SetStateAction, 13 createElement, 14 memo 15 } from "react"; 16 import { 17 CAN_REDO_COMMAND, 18 CAN_UNDO_COMMAND, 19 REDO_COMMAND, 20 UNDO_COMMAND, 21 SELECTION_CHANGE_COMMAND, 22 FORMAT_TEXT_COMMAND, 23 $getSelection, 24 $isRangeSelection, 25 $getNodeByKey, 26 LexicalEditor, 27 RangeSelection, 28 NodeSelection, 29 GridSelection 30 } from "lexical"; 31 import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link"; 32 import { 33 $isParentElementRTL, 34 $wrapNodes, 35 $isAtNodeEnd 36 } from "@lexical/selection"; 37 import { $getNearestNodeOfType, mergeRegister } from "@lexical/utils"; 38 import { 39 INSERT_ORDERED_LIST_COMMAND, 40 INSERT_UNORDERED_LIST_COMMAND, 41 REMOVE_LIST_COMMAND, 42 $isListNode, 43 ListNode 44 } from "@lexical/list"; 45 import { createPortal } from "react-dom"; 46 import { 47 $createHeadingNode, 48 $createQuoteNode, 49 $isHeadingNode 50 } from "@lexical/rich-text"; 51 import { 52 $createCodeNode, 53 $isCodeNode, 54 getDefaultCodeLanguage, 55 getCodeLanguages 56 } from "@lexical/code"; 57 import { Bold, ChevronDown, Code, Heading1, Heading2, Heading4, Heading5, Italic, Link, List, ListOrdered, Quote, Redo, Strikethrough, Text, Underline, Undo } from "lucide-react"; 58 import { Heading3 } from "lucide-react"; 59 import { $createCustomParagraphNode } from "../customNodes/CustomParagraphNode"; 60 61 const LowPriority = 1; 62 63 const supportedBlockTypes = new Set([ 64 "custom-paragraph", 65 "quote", 66 "code", 67 "h1", 68 "h2", 69 "ul", 70 "ol" 71 ]); 72 73 interface BlockTypes { 74 "code": string, 75 h1: string, 76 h2: string, 77 h3: string, 78 h4: string, 79 h5: string, 80 ol: string, 81 "custom-paragraph": string, 82 quote: string, 83 ul: string 84 } 85 86 const blockTypeToBlockName: BlockTypes = { 87 "code": "Code Block", 88 h1: "Large Heading", 89 h2: "Small Heading", 90 h3: "Heading", 91 h4: "Heading", 92 h5: "Heading", 93 ol: "Numbered List", 94 "custom-paragraph": "Normal", 95 quote: "Quote", 96 ul: "Bulleted List" 97 } as const; 98 99 type BlockType = keyof typeof blockTypeToBlockName; 100 101 const Divider = memo(() => { 102 return <div className="divider" />; 103 }) 104 105 function positionEditorElement(editor: HTMLDivElement, rect: DOMRect | undefined) { 106 if (!rect) { 107 editor.style.opacity = "0"; 108 editor.style.top = "-1000px"; 109 editor.style.left = "-1000px"; 110 } else { 111 editor.style.opacity = "1"; 112 editor.style.top = `${rect.top + rect.height + window.pageYOffset + 10}px`; 113 editor.style.left = `${rect.left + window.pageXOffset - editor.offsetWidth / 2 + rect.width / 2 114 }px`; 115 } 116 } 117 118 const FloatingLinkEditor = memo(({ editor }: { editor: LexicalEditor }) => { 119 const editorRef: MutableRefObject<HTMLDivElement | null> = useRef(null); 120 const inputRef: MutableRefObject<HTMLInputElement | null> = useRef(null); 121 const mouseDownRef = useRef(false); 122 const [linkUrl, setLinkUrl] = useState(""); 123 const [isEditMode, setEditMode] = useState(false); 124 const [lastSelection, setLastSelection] = useState<RangeSelection | GridSelection | NodeSelection | null>(null); 125 126 const updateLinkEditor = useCallback(() => { 127 const selection = $getSelection(); 128 if ($isRangeSelection(selection)) { 129 const node = getSelectedNode(selection); 130 const parent = node.getParent(); 131 if ($isLinkNode(parent)) { 132 setLinkUrl(parent.getURL()); 133 } else if ($isLinkNode(node)) { 134 setLinkUrl(node.getURL()); 135 } else { 136 setLinkUrl(""); 137 } 138 } 139 const editorElem = editorRef.current; 140 const nativeSelection = window.getSelection(); 141 const activeElement = document.activeElement; 142 143 if (editorElem === null) { 144 return; 145 } 146 147 const rootElement = editor.getRootElement(); 148 if ( 149 selection !== null && 150 !nativeSelection?.isCollapsed && 151 rootElement !== null && 152 rootElement.contains(nativeSelection?.anchorNode as (Node | null)) 153 ) { 154 const domRange = nativeSelection?.getRangeAt(0); 155 let rect; 156 if (nativeSelection?.anchorNode === rootElement) { 157 let inner = rootElement; 158 while (inner.firstElementChild != null) { 159 inner = inner.firstElementChild as HTMLElement; 160 } 161 rect = inner.getBoundingClientRect(); 162 } else { 163 rect = domRange?.getBoundingClientRect(); 164 } 165 166 if (!mouseDownRef.current) { 167 positionEditorElement(editorElem, rect); 168 } 169 setLastSelection(selection); 170 } else if (!activeElement || activeElement.className !== "link-input") { 171 positionEditorElement(editorElem, undefined); 172 setLastSelection(null); 173 setEditMode(false); 174 setLinkUrl(""); 175 } 176 177 return true; 178 }, [editor]); 179 180 useEffect(() => { 181 return mergeRegister( 182 editor.registerUpdateListener(({ editorState }) => { 183 editorState.read(() => { 184 updateLinkEditor(); 185 }); 186 }), 187 188 editor.registerCommand( 189 SELECTION_CHANGE_COMMAND, 190 () => { 191 updateLinkEditor(); 192 return true; 193 }, 194 LowPriority 195 ) 196 ); 197 }, [editor, updateLinkEditor]); 198 199 useEffect(() => { 200 editor.getEditorState().read(() => { 201 updateLinkEditor(); 202 }); 203 }, [editor, updateLinkEditor]); 204 205 useEffect(() => { 206 if (isEditMode && inputRef.current) { 207 inputRef.current.focus(); 208 } 209 }, [isEditMode]); 210 211 const safeLink = createElement("a", { href: linkUrl, target: "_blank", rel: "noopener noreferrer" }, linkUrl); 212 213 return ( 214 <div ref={editorRef} className="link-editor"> 215 {isEditMode ? ( 216 <input 217 ref={inputRef} 218 className="link-input" 219 value={linkUrl} 220 onChange={(event) => { 221 setLinkUrl(event.target.value); 222 }} 223 onKeyDown={(event) => { 224 if (event.key === "Enter") { 225 event.preventDefault(); 226 if (lastSelection !== null) { 227 if (linkUrl !== "") { 228 editor.dispatchCommand(TOGGLE_LINK_COMMAND, linkUrl); 229 } 230 setEditMode(false); 231 } 232 } else if (event.key === "Escape") { 233 event.preventDefault(); 234 setEditMode(false); 235 } 236 }} 237 /> 238 ) : ( 239 <> 240 <div className="link-input"> 241 {safeLink} 242 <div 243 className="link-edit" 244 role="button" 245 tabIndex={0} 246 onMouseDown={(event) => event.preventDefault()} 247 onClick={() => { 248 setEditMode(true); 249 }} 250 /> 251 </div> 252 </> 253 )} 254 </div> 255 ); 256 }) 257 258 const Select = memo(({ onChange, className, options, value }: { onChange: ChangeEventHandler<HTMLSelectElement>, className: string, options: string[], value: string | ReadonlyArray<string> | number }) => { 259 return ( 260 <select className={className} onChange={onChange} value={value}> 261 <option hidden={true} value="" /> 262 {options.map((option) => ( 263 <option key={option} value={option}> 264 {option} 265 </option> 266 ))} 267 </select> 268 ); 269 }) 270 271 function getSelectedNode(selection: RangeSelection | GridSelection) { 272 const anchor = selection.anchor; 273 const focus = selection.focus; 274 const anchorNode = selection.anchor.getNode(); 275 const focusNode = selection.focus.getNode(); 276 if (anchorNode === focusNode) { 277 return anchorNode; 278 } 279 const isBackward = selection.isBackward(); 280 if (isBackward) { 281 return $isAtNodeEnd(focus) ? anchorNode : focusNode; 282 } else { 283 return $isAtNodeEnd(anchor) ? focusNode : anchorNode; 284 } 285 } 286 287 const BlockOptionsDropdownList = memo(({ 288 editor, 289 blockType, 290 toolbarRef, 291 setShowBlockOptionsDropDown 292 }: { editor: LexicalEditor, blockType: BlockType, toolbarRef: MutableRefObject<HTMLDivElement | null>, setShowBlockOptionsDropDown: Dispatch<SetStateAction<boolean>> }) => { 293 const dropDownRef: MutableRefObject<HTMLDivElement | null> = useRef(null); 294 295 useEffect(() => { 296 const dropDown = dropDownRef.current; 297 const toolbar = toolbarRef.current; 298 299 if (dropDown !== null && toolbar !== null) { 300 const editorContainer = document.getElementsByClassName("editor-container")[0]; 301 if (editorContainer) { 302 dropDown.style.bottom = `${editorContainer.clientHeight}px`; 303 } 304 305 const handle = (event: MouseEvent) => { 306 const target = event.target; 307 308 if (!dropDown.contains(target as Node) && !toolbar.contains(target as Node)) { 309 setShowBlockOptionsDropDown(false); 310 } 311 }; 312 document.addEventListener("click", handle); 313 314 return () => { 315 document.removeEventListener("click", handle); 316 }; 317 } 318 }, [dropDownRef, setShowBlockOptionsDropDown, toolbarRef]); 319 320 const formatParagraph = () => { 321 if (blockType !== "custom-paragraph") { 322 editor.update(() => { 323 const selection = $getSelection(); 324 325 if ($isRangeSelection(selection)) { 326 $wrapNodes(selection, () => $createCustomParagraphNode()); 327 } 328 }); 329 } 330 setShowBlockOptionsDropDown(false); 331 }; 332 333 const formatLargeHeading = () => { 334 if (blockType !== "h1") { 335 editor.update(() => { 336 const selection = $getSelection(); 337 338 if ($isRangeSelection(selection)) { 339 $wrapNodes(selection, () => $createHeadingNode("h1")); 340 } 341 }); 342 } 343 setShowBlockOptionsDropDown(false); 344 }; 345 346 const formatSmallHeading = () => { 347 if (blockType !== "h2") { 348 editor.update(() => { 349 const selection = $getSelection(); 350 351 if ($isRangeSelection(selection)) { 352 $wrapNodes(selection, () => $createHeadingNode("h2")); 353 } 354 }); 355 } 356 setShowBlockOptionsDropDown(false); 357 }; 358 359 const formatBulletList = () => { 360 if (blockType !== "ul") { 361 editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined); 362 } else { 363 editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); 364 } 365 setShowBlockOptionsDropDown(false); 366 }; 367 368 const formatNumberedList = () => { 369 if (blockType !== "ol") { 370 editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined); 371 } else { 372 editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); 373 } 374 setShowBlockOptionsDropDown(false); 375 }; 376 377 const formatQuote = () => { 378 if (blockType !== "quote") { 379 editor.update(() => { 380 const selection = $getSelection(); 381 382 if ($isRangeSelection(selection)) { 383 $wrapNodes(selection, () => $createQuoteNode()); 384 } 385 }); 386 } 387 setShowBlockOptionsDropDown(false); 388 }; 389 390 const formatCode = () => { 391 if (blockType !== "code") { 392 editor.update(() => { 393 const selection = $getSelection(); 394 395 if ($isRangeSelection(selection)) { 396 $wrapNodes(selection, () => $createCodeNode()); 397 } 398 }); 399 } 400 setShowBlockOptionsDropDown(false); 401 }; 402 403 return ( 404 <div className="dropdown" ref={dropDownRef}> 405 <button className="item" onClick={formatParagraph}> 406 <Text className="icon" size={20} /> 407 <span className="text">Normal</span> 408 {blockType === "custom-paragraph" && <span className="active" />} 409 </button> 410 <button className="item" onClick={formatLargeHeading}> 411 <Heading1 className="icon" size={20} /> 412 <span className="text">Large Heading</span> 413 {blockType === "h1" && <span className="active" />} 414 </button> 415 <button className="item" onClick={formatSmallHeading}> 416 <Heading2 className="icon" size={20} /> 417 <span className="text">Small Heading</span> 418 {blockType === "h2" && <span className="active" />} 419 </button> 420 <button className="item" onClick={formatBulletList}> 421 <List className="icon" size={20} /> 422 <span className="text">Bullet List</span> 423 {blockType === "ul" && <span className="active" />} 424 </button> 425 <button className="item" onClick={formatNumberedList}> 426 <ListOrdered className="icon" size={20} /> 427 <span className="text">Numbered List</span> 428 {blockType === "ol" && <span className="active" />} 429 </button> 430 <button className="item" onClick={formatQuote}> 431 <Quote className="icon" size={20} /> 432 <span className="text">Quote</span> 433 {blockType === "quote" && <span className="active" />} 434 </button> 435 <button className="item" onClick={formatCode}> 436 <Code className="icon" size={20} /> 437 <span className="text">Code Block</span> 438 {blockType === "code" && <span className="active" />} 439 </button> 440 </div> 441 ); 442 }) 443 444 const ToolbarPlugin = memo(() => { 445 const [editor] = useLexicalComposerContext(); 446 const toolbarRef = useRef(null); 447 const [canUndo, setCanUndo] = useState(false); 448 const [canRedo, setCanRedo] = useState(false); 449 const [blockType, setBlockType] = useState<BlockType>("custom-paragraph"); 450 const [selectedElementKey, setSelectedElementKey] = useState(null); 451 const [showBlockOptionsDropDown, setShowBlockOptionsDropDown] = useState( 452 false 453 ); 454 const [codeLanguage, setCodeLanguage] = useState(""); 455 const [_isRTL, setIsRTL] = useState(false); 456 const [isLink, setIsLink] = useState(false); 457 const [isBold, setIsBold] = useState(false); 458 const [isItalic, setIsItalic] = useState(false); 459 const [isUnderline, setIsUnderline] = useState(false); 460 const [isStrikethrough, setIsStrikethrough] = useState(false); 461 const [isCode, setIsCode] = useState(false); 462 463 const updateToolbar = useCallback(() => { 464 const selection = $getSelection(); 465 if ($isRangeSelection(selection)) { 466 const anchorNode = selection.anchor.getNode(); 467 const element = 468 anchorNode.getKey() === "root" 469 ? anchorNode 470 : anchorNode.getTopLevelElementOrThrow(); 471 const elementKey = element.getKey(); 472 const elementDOM = editor.getElementByKey(elementKey); 473 if (elementDOM !== null) { 474 setSelectedElementKey(elementKey); 475 if ($isListNode(element)) { 476 const parentList = $getNearestNodeOfType(anchorNode, ListNode); 477 const type = parentList ? parentList.getTag() : element.getTag(); 478 setBlockType(type); 479 } else { 480 const type = $isHeadingNode(element) 481 ? element.getTag() 482 : element.getType(); 483 setBlockType(type); 484 if ($isCodeNode(element)) { 485 setCodeLanguage(element.getLanguage() || getDefaultCodeLanguage()); 486 } 487 } 488 } 489 // Update text format 490 setIsBold(selection.hasFormat("bold")); 491 setIsItalic(selection.hasFormat("italic")); 492 setIsUnderline(selection.hasFormat("underline")); 493 setIsStrikethrough(selection.hasFormat("strikethrough")); 494 setIsCode(selection.hasFormat("code")); 495 setIsRTL($isParentElementRTL(selection)); 496 497 // Update links 498 const node = getSelectedNode(selection); 499 const parent = node.getParent(); 500 if ($isLinkNode(parent) || $isLinkNode(node)) { 501 setIsLink(true); 502 } else { 503 setIsLink(false); 504 } 505 } 506 }, [editor]); 507 508 useEffect(() => { 509 return mergeRegister( 510 editor.registerUpdateListener(({ editorState }) => { 511 editorState.read(() => { 512 updateToolbar(); 513 }); 514 }), 515 editor.registerCommand( 516 SELECTION_CHANGE_COMMAND, 517 (_payload, _newEditor) => { 518 updateToolbar(); 519 return false; 520 }, 521 LowPriority 522 ), 523 editor.registerCommand( 524 CAN_UNDO_COMMAND, 525 (payload) => { 526 setCanUndo(payload); 527 return false; 528 }, 529 LowPriority 530 ), 531 editor.registerCommand( 532 CAN_REDO_COMMAND, 533 (payload) => { 534 setCanRedo(payload); 535 return false; 536 }, 537 LowPriority 538 ) 539 ); 540 }, [editor, updateToolbar]); 541 542 const codeLanguges = useMemo(() => getCodeLanguages(), []); 543 const onCodeLanguageSelect = useCallback( 544 (e: ChangeEvent<HTMLSelectElement>) => { 545 editor.update(() => { 546 if (selectedElementKey !== null) { 547 const node = $getNodeByKey(selectedElementKey); 548 if ($isCodeNode(node)) { 549 node.setLanguage(e.target.value); 550 } 551 } 552 }); 553 }, 554 [editor, selectedElementKey] 555 ); 556 557 const insertLink = useCallback(() => { 558 if (!isLink) { 559 editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://"); 560 } else { 561 editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); 562 } 563 }, [editor, isLink]); 564 565 const [portalContainer, setPortalContainer] = useState<HTMLDivElement | null>(null); 566 useEffect(() => { 567 const container = document.getElementsByClassName("room-wrapper")[0]; 568 const portalContainer = document.createElement('div'); 569 container?.prepend(portalContainer) 570 setPortalContainer(portalContainer) 571 return () => { 572 container?.removeChild(portalContainer) 573 } 574 }, []) 575 576 return ( 577 <div className="toolbar" ref={toolbarRef}> 578 <button 579 disabled={!canUndo} 580 onClick={() => { 581 editor.dispatchCommand(UNDO_COMMAND, undefined); 582 }} 583 className="toolbar-item spaced" 584 aria-label="Undo" 585 > 586 <Undo className="format" size={20} /> 587 </button> 588 <button 589 disabled={!canRedo} 590 onClick={() => { 591 editor.dispatchCommand(REDO_COMMAND, undefined); 592 }} 593 className="toolbar-item" 594 aria-label="Redo" 595 > 596 <Redo className="format" size={20} /> 597 </button> 598 <Divider /> 599 {supportedBlockTypes.has(blockType) && ( 600 <> 601 <button 602 className="toolbar-item block-controls" 603 onClick={() => 604 setShowBlockOptionsDropDown(!showBlockOptionsDropDown) 605 } 606 aria-label="Formatting Options" 607 > 608 { 609 blockType === "h1" ? 610 <Heading1 className="icon" size={20} /> : 611 blockType === "h2" ? 612 <Heading2 className="icon" size={20} /> : 613 blockType === "h3" ? 614 <Heading3 className="icon" size={20} /> : 615 blockType === "h4" ? 616 <Heading4 className="icon" size={20} /> : 617 blockType === "h5" ? 618 <Heading5 className="icon" size={20} /> : 619 blockType === "code" ? 620 <Code className="icon" size={20} /> : 621 blockType === "custom-paragraph" ? 622 <Text className="icon" size={20} /> : 623 blockType === "ol" ? 624 <ListOrdered className="icon" size={20} /> : 625 blockType === "ul" ? 626 <List className="icon" size={20} /> : 627 blockType === "quote" ? 628 <Quote className="icon" size={20} /> : 629 <></> 630 } 631 <span className="text">{blockTypeToBlockName[blockType]}</span> 632 <ChevronDown size={20} /> 633 </button> 634 {showBlockOptionsDropDown && 635 createPortal( 636 <BlockOptionsDropdownList 637 editor={editor} 638 blockType={blockType} 639 toolbarRef={toolbarRef} 640 setShowBlockOptionsDropDown={setShowBlockOptionsDropDown} 641 />, 642 portalContainer!! 643 )} 644 <Divider /> 645 </> 646 )} 647 {blockType === "code" ? ( 648 <> 649 <Select 650 className="toolbar-item code-language" 651 onChange={onCodeLanguageSelect} 652 options={codeLanguges} 653 value={codeLanguage} 654 /> 655 <i className="chevron-down inside" /> 656 </> 657 ) : ( 658 <> 659 <button 660 onClick={() => { 661 editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold"); 662 }} 663 className={"toolbar-item spaced " + (isBold ? "active" : "")} 664 aria-label="Format Bold" 665 > 666 <Bold className="format" size={20} /> 667 </button> 668 <button 669 onClick={() => { 670 editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic"); 671 }} 672 className={"toolbar-item spaced " + (isItalic ? "active" : "")} 673 aria-label="Format Italics" 674 > 675 <Italic className="format" size={20} /> 676 </button> 677 <button 678 onClick={() => { 679 editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline"); 680 }} 681 className={"toolbar-item spaced " + (isUnderline ? "active" : "")} 682 aria-label="Format Underline" 683 > 684 <Underline className="format" size={20} /> 685 </button> 686 <button 687 onClick={() => { 688 editor.dispatchCommand(FORMAT_TEXT_COMMAND, "strikethrough"); 689 }} 690 className={ 691 "toolbar-item spaced " + (isStrikethrough ? "active" : "") 692 } 693 aria-label="Format Strikethrough" 694 > 695 <Strikethrough className="format" size={20} /> 696 </button> 697 <button 698 onClick={() => { 699 editor.dispatchCommand(FORMAT_TEXT_COMMAND, "code"); 700 }} 701 className={"toolbar-item spaced " + (isCode ? "active" : "")} 702 aria-label="Insert Code" 703 > 704 <Code className="format" size={20} /> 705 </button> 706 <button 707 onClick={insertLink} 708 className={"toolbar-item spaced " + (isLink ? "active" : "")} 709 aria-label="Insert Link" 710 > 711 <Link className="format" size={20} /> 712 </button> 713 {isLink && 714 createPortal(<FloatingLinkEditor editor={editor} />, document.body)} 715 {" "} 716 </> 717 )} 718 </div> 719 ); 720 }) 721 722 export default ToolbarPlugin;