/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * */ import type { CommandPayloadType, DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, ElementFormatType, LexicalCommand, LexicalEditor, LexicalNode, NodeKey, ParagraphNode, PasteCommandType, RangeSelection, SerializedElementNode, Spread, TextFormatType, } from 'lexical'; import { $insertDataTransferForRichText, copyToClipboard, } from '@lexical/clipboard'; import { $moveCharacter, $shouldOverrideDefaultCharacterSelection, } from '@lexical/selection'; import { $findMatchingParent, $getNearestBlockElementAncestorOrThrow, addClassNamesToElement, isHTMLElement, mergeRegister, objectKlassEquals, } from '@lexical/utils'; import { $applyNodeReplacement, $createParagraphNode, $createRangeSelection, $createTabNode, $getAdjacentNode, $getNearestNodeFromDOMNode, $getRoot, $getSelection, $insertNodes, $isDecoratorNode, $isElementNode, $isNodeSelection, $isRangeSelection, $isRootNode, $isTextNode, $normalizeSelection__EXPERIMENTAL, $selectAll, $setSelection, CLICK_COMMAND, COMMAND_PRIORITY_EDITOR, CONTROLLED_TEXT_INSERTION_COMMAND, COPY_COMMAND, createCommand, CUT_COMMAND, DELETE_CHARACTER_COMMAND, DELETE_LINE_COMMAND, DELETE_WORD_COMMAND, DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, ElementNode, FORMAT_ELEMENT_COMMAND, FORMAT_TEXT_COMMAND, INDENT_CONTENT_COMMAND, INSERT_LINE_BREAK_COMMAND, INSERT_PARAGRAPH_COMMAND, INSERT_TAB_COMMAND, isSelectionCapturedInDecoratorInput, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_LEFT_COMMAND, KEY_ARROW_RIGHT_COMMAND, KEY_ARROW_UP_COMMAND, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, KEY_ENTER_COMMAND, KEY_ESCAPE_COMMAND, OUTDENT_CONTENT_COMMAND, PASTE_COMMAND, REMOVE_TEXT_COMMAND, SELECT_ALL_COMMAND, } from 'lexical'; import caretFromPoint from 'lexical/shared/caretFromPoint'; import { CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI, } from 'lexical/shared/environment'; export type SerializedHeadingNode = Spread< { tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; }, SerializedElementNode >; export const DRAG_DROP_PASTE: LexicalCommand> = createCommand( 'DRAG_DROP_PASTE_FILE', ); export type SerializedQuoteNode = SerializedElementNode; /** @noInheritDoc */ export class QuoteNode extends ElementNode { static getType(): string { return 'quote'; } static clone(node: QuoteNode): QuoteNode { return new QuoteNode(node.__key); } constructor(key?: NodeKey) { super(key); } // View createDOM(config: EditorConfig): HTMLElement { const element = document.createElement('blockquote'); addClassNamesToElement(element, config.theme.quote); return element; } updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean { return false; } static importDOM(): DOMConversionMap | null { return { blockquote: (node: Node) => ({ conversion: $convertBlockquoteElement, priority: 0, }), }; } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); if (element && isHTMLElement(element)) { if (this.isEmpty()) { element.append(document.createElement('br')); } const formatType = this.getFormatType(); element.style.textAlign = formatType; const direction = this.getDirection(); if (direction) { element.dir = direction; } } return { element, }; } static importJSON(serializedNode: SerializedQuoteNode): QuoteNode { const node = $createQuoteNode(); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } exportJSON(): SerializedElementNode { return { ...super.exportJSON(), type: 'quote', }; } // Mutation insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode { const newBlock = $createParagraphNode(); const direction = this.getDirection(); newBlock.setDirection(direction); this.insertAfter(newBlock, restoreSelection); return newBlock; } collapseAtStart(): true { const paragraph = $createParagraphNode(); const children = this.getChildren(); children.forEach((child) => paragraph.append(child)); this.replace(paragraph); return true; } canMergeWhenEmpty(): true { return true; } } export function $createQuoteNode(): QuoteNode { return $applyNodeReplacement(new QuoteNode()); } export function $isQuoteNode( node: LexicalNode | null | undefined, ): node is QuoteNode { return node instanceof QuoteNode; } export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; /** @noInheritDoc */ export class HeadingNode extends ElementNode { /** @internal */ __tag: HeadingTagType; static getType(): string { return 'heading'; } static clone(node: HeadingNode): HeadingNode { return new HeadingNode(node.__tag, node.__key); } constructor(tag: HeadingTagType, key?: NodeKey) { super(key); this.__tag = tag; } getTag(): HeadingTagType { return this.__tag; } // View createDOM(config: EditorConfig): HTMLElement { const tag = this.__tag; const element = document.createElement(tag); const theme = config.theme; const classNames = theme.heading; if (classNames !== undefined) { const className = classNames[tag]; addClassNamesToElement(element, className); } return element; } updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean { return false; } static importDOM(): DOMConversionMap | null { return { h1: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), h2: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), h3: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), h4: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), h5: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), h6: (node: Node) => ({ conversion: $convertHeadingElement, priority: 0, }), p: (node: Node) => { // domNode is a

since we matched it by nodeName const paragraph = node as HTMLParagraphElement; const firstChild = paragraph.firstChild; if (firstChild !== null && isGoogleDocsTitle(firstChild)) { return { conversion: () => ({node: null}), priority: 3, }; } return null; }, span: (node: Node) => { if (isGoogleDocsTitle(node)) { return { conversion: (domNode: Node) => { return { node: $createHeadingNode('h1'), }; }, priority: 3, }; } return null; }, }; } exportDOM(editor: LexicalEditor): DOMExportOutput { const {element} = super.exportDOM(editor); if (element && isHTMLElement(element)) { if (this.isEmpty()) { element.append(document.createElement('br')); } const formatType = this.getFormatType(); element.style.textAlign = formatType; const direction = this.getDirection(); if (direction) { element.dir = direction; } } return { element, }; } static importJSON(serializedNode: SerializedHeadingNode): HeadingNode { const node = $createHeadingNode(serializedNode.tag); node.setFormat(serializedNode.format); node.setIndent(serializedNode.indent); node.setDirection(serializedNode.direction); return node; } exportJSON(): SerializedHeadingNode { return { ...super.exportJSON(), tag: this.getTag(), type: 'heading', version: 1, }; } // Mutation insertNewAfter( selection?: RangeSelection, restoreSelection = true, ): ParagraphNode | HeadingNode { const anchorOffet = selection ? selection.anchor.offset : 0; const lastDesc = this.getLastDescendant(); const isAtEnd = !lastDesc || (selection && selection.anchor.key === lastDesc.getKey() && anchorOffet === lastDesc.getTextContentSize()); const newElement = isAtEnd || !selection ? $createParagraphNode() : $createHeadingNode(this.getTag()); const direction = this.getDirection(); newElement.setDirection(direction); this.insertAfter(newElement, restoreSelection); if (anchorOffet === 0 && !this.isEmpty() && selection) { const paragraph = $createParagraphNode(); paragraph.select(); this.replace(paragraph, true); } return newElement; } collapseAtStart(): true { const newElement = !this.isEmpty() ? $createHeadingNode(this.getTag()) : $createParagraphNode(); const children = this.getChildren(); children.forEach((child) => newElement.append(child)); this.replace(newElement); return true; } extractWithChild(): boolean { return true; } } function isGoogleDocsTitle(domNode: Node): boolean { if (domNode.nodeName.toLowerCase() === 'span') { return (domNode as HTMLSpanElement).style.fontSize === '26pt'; } return false; } function $convertHeadingElement(element: HTMLElement): DOMConversionOutput { const nodeName = element.nodeName.toLowerCase(); let node = null; if ( nodeName === 'h1' || nodeName === 'h2' || nodeName === 'h3' || nodeName === 'h4' || nodeName === 'h5' || nodeName === 'h6' ) { node = $createHeadingNode(nodeName); if (element.style !== null) { node.setFormat(element.style.textAlign as ElementFormatType); } } return {node}; } function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput { const node = $createQuoteNode(); if (element.style !== null) { node.setFormat(element.style.textAlign as ElementFormatType); } return {node}; } export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode { return $applyNodeReplacement(new HeadingNode(headingTag)); } export function $isHeadingNode( node: LexicalNode | null | undefined, ): node is HeadingNode { return node instanceof HeadingNode; } function onPasteForRichText( event: CommandPayloadType, editor: LexicalEditor, ): void { event.preventDefault(); editor.update( () => { const selection = $getSelection(); const clipboardData = objectKlassEquals(event, InputEvent) || objectKlassEquals(event, KeyboardEvent) ? null : (event as ClipboardEvent).clipboardData; if (clipboardData != null && selection !== null) { $insertDataTransferForRichText(clipboardData, selection, editor); } }, { tag: 'paste', }, ); } async function onCutForRichText( event: CommandPayloadType, editor: LexicalEditor, ): Promise { await copyToClipboard( editor, objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null, ); editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { selection.removeText(); } else if ($isNodeSelection(selection)) { selection.getNodes().forEach((node) => node.remove()); } }); } // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless, // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We // control this with the first boolean flag. export function eventFiles( event: DragEvent | PasteCommandType, ): [boolean, Array, boolean] { let dataTransfer: null | DataTransfer = null; if (objectKlassEquals(event, DragEvent)) { dataTransfer = (event as DragEvent).dataTransfer; } else if (objectKlassEquals(event, ClipboardEvent)) { dataTransfer = (event as ClipboardEvent).clipboardData; } if (dataTransfer === null) { return [false, [], false]; } const types = dataTransfer.types; const hasFiles = types.includes('Files'); const hasContent = types.includes('text/html') || types.includes('text/plain'); return [hasFiles, Array.from(dataTransfer.files), hasContent]; } function $handleIndentAndOutdent( indentOrOutdent: (block: ElementNode) => void, ): boolean { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } const alreadyHandled = new Set(); const nodes = selection.getNodes(); for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; const key = node.getKey(); if (alreadyHandled.has(key)) { continue; } const parentBlock = $findMatchingParent( node, (parentNode): parentNode is ElementNode => $isElementNode(parentNode) && !parentNode.isInline(), ); if (parentBlock === null) { continue; } const parentKey = parentBlock.getKey(); if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) { alreadyHandled.add(parentKey); indentOrOutdent(parentBlock); } } return alreadyHandled.size > 0; } function $isTargetWithinDecorator(target: HTMLElement): boolean { const node = $getNearestNodeFromDOMNode(target); return $isDecoratorNode(node); } function $isSelectionAtEndOfRoot(selection: RangeSelection) { const focus = selection.focus; return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize(); } export function registerRichText(editor: LexicalEditor): () => void { const removeListener = mergeRegister( editor.registerCommand( CLICK_COMMAND, (payload) => { const selection = $getSelection(); if ($isNodeSelection(selection)) { selection.clear(); return true; } return false; }, 0, ), editor.registerCommand( DELETE_CHARACTER_COMMAND, (isBackward) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.deleteCharacter(isBackward); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( DELETE_WORD_COMMAND, (isBackward) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.deleteWord(isBackward); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( DELETE_LINE_COMMAND, (isBackward) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.deleteLine(isBackward); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( CONTROLLED_TEXT_INSERTION_COMMAND, (eventOrText) => { const selection = $getSelection(); if (typeof eventOrText === 'string') { if (selection !== null) { selection.insertText(eventOrText); } } else { if (selection === null) { return false; } const dataTransfer = eventOrText.dataTransfer; if (dataTransfer != null) { $insertDataTransferForRichText(dataTransfer, selection, editor); } else if ($isRangeSelection(selection)) { const data = eventOrText.data; if (data) { selection.insertText(data); } return true; } } return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( REMOVE_TEXT_COMMAND, () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.removeText(); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( FORMAT_TEXT_COMMAND, (format) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.formatText(format); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( FORMAT_ELEMENT_COMMAND, (format) => { const selection = $getSelection(); if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) { return false; } const nodes = selection.getNodes(); for (const node of nodes) { const element = $findMatchingParent( node, (parentNode): parentNode is ElementNode => $isElementNode(parentNode) && !parentNode.isInline(), ); if (element !== null) { element.setFormat(format); } } return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( INSERT_LINE_BREAK_COMMAND, (selectStart) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.insertLineBreak(selectStart); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( INSERT_PARAGRAPH_COMMAND, () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } selection.insertParagraph(); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( INSERT_TAB_COMMAND, () => { $insertNodes([$createTabNode()]); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( INDENT_CONTENT_COMMAND, () => { return $handleIndentAndOutdent((block) => { const indent = block.getIndent(); block.setIndent(indent + 1); }); }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( OUTDENT_CONTENT_COMMAND, () => { return $handleIndentAndOutdent((block) => { const indent = block.getIndent(); if (indent > 0) { block.setIndent(indent - 1); } }); }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ARROW_UP_COMMAND, (event) => { const selection = $getSelection(); if ( $isNodeSelection(selection) && !$isTargetWithinDecorator(event.target as HTMLElement) ) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { nodes[0].selectPrevious(); return true; } } else if ($isRangeSelection(selection)) { const possibleNode = $getAdjacentNode(selection.focus, true); if ( !event.shiftKey && $isDecoratorNode(possibleNode) && !possibleNode.isIsolated() && !possibleNode.isInline() ) { possibleNode.selectPrevious(); event.preventDefault(); return true; } } return false; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ARROW_DOWN_COMMAND, (event) => { const selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { nodes[0].selectNext(0, 0); return true; } } else if ($isRangeSelection(selection)) { if ($isSelectionAtEndOfRoot(selection)) { event.preventDefault(); return true; } const possibleNode = $getAdjacentNode(selection.focus, false); if ( !event.shiftKey && $isDecoratorNode(possibleNode) && !possibleNode.isIsolated() && !possibleNode.isInline() ) { possibleNode.selectNext(); event.preventDefault(); return true; } } return false; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ARROW_LEFT_COMMAND, (event) => { const selection = $getSelection(); if ($isNodeSelection(selection)) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { event.preventDefault(); nodes[0].selectPrevious(); return true; } } if (!$isRangeSelection(selection)) { return false; } if ($shouldOverrideDefaultCharacterSelection(selection, true)) { const isHoldingShift = event.shiftKey; event.preventDefault(); $moveCharacter(selection, isHoldingShift, true); return true; } return false; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ARROW_RIGHT_COMMAND, (event) => { const selection = $getSelection(); if ( $isNodeSelection(selection) && !$isTargetWithinDecorator(event.target as HTMLElement) ) { // If selection is on a node, let's try and move selection // back to being a range selection. const nodes = selection.getNodes(); if (nodes.length > 0) { event.preventDefault(); nodes[0].selectNext(0, 0); return true; } } if (!$isRangeSelection(selection)) { return false; } const isHoldingShift = event.shiftKey; if ($shouldOverrideDefaultCharacterSelection(selection, false)) { event.preventDefault(); $moveCharacter(selection, isHoldingShift, false); return true; } return false; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_BACKSPACE_COMMAND, (event) => { if ($isTargetWithinDecorator(event.target as HTMLElement)) { return false; } const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } event.preventDefault(); const {anchor} = selection; const anchorNode = anchor.getNode(); if ( selection.isCollapsed() && anchor.offset === 0 && !$isRootNode(anchorNode) ) { const element = $getNearestBlockElementAncestorOrThrow(anchorNode); if (element.getIndent() > 0) { return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined); } } return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true); }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_DELETE_COMMAND, (event) => { if ($isTargetWithinDecorator(event.target as HTMLElement)) { return false; } const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } event.preventDefault(); return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false); }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ENTER_COMMAND, (event) => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } if (event !== null) { // If we have beforeinput, then we can avoid blocking // the default behavior. This ensures that the iOS can // intercept that we're actually inserting a paragraph, // and autocomplete, autocapitalize etc work as intended. // This can also cause a strange performance issue in // Safari, where there is a noticeable pause due to // preventing the key down of enter. if ( (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) && CAN_USE_BEFORE_INPUT ) { return false; } event.preventDefault(); if (event.shiftKey) { return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false); } } return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined); }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( KEY_ESCAPE_COMMAND, () => { const selection = $getSelection(); if (!$isRangeSelection(selection)) { return false; } editor.blur(); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( DROP_COMMAND, (event) => { const [, files] = eventFiles(event); if (files.length > 0) { const x = event.clientX; const y = event.clientY; const eventRange = caretFromPoint(x, y); if (eventRange !== null) { const {offset: domOffset, node: domNode} = eventRange; const node = $getNearestNodeFromDOMNode(domNode); if (node !== null) { const selection = $createRangeSelection(); if ($isTextNode(node)) { selection.anchor.set(node.getKey(), domOffset, 'text'); selection.focus.set(node.getKey(), domOffset, 'text'); } else { const parentKey = node.getParentOrThrow().getKey(); const offset = node.getIndexWithinParent() + 1; selection.anchor.set(parentKey, offset, 'element'); selection.focus.set(parentKey, offset, 'element'); } const normalizedSelection = $normalizeSelection__EXPERIMENTAL(selection); $setSelection(normalizedSelection); } editor.dispatchCommand(DRAG_DROP_PASTE, files); } event.preventDefault(); return true; } const selection = $getSelection(); if ($isRangeSelection(selection)) { return true; } return false; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( DRAGSTART_COMMAND, (event) => { const [isFileTransfer] = eventFiles(event); const selection = $getSelection(); if (isFileTransfer && !$isRangeSelection(selection)) { return false; } return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( DRAGOVER_COMMAND, (event) => { const [isFileTransfer] = eventFiles(event); const selection = $getSelection(); if (isFileTransfer && !$isRangeSelection(selection)) { return false; } const x = event.clientX; const y = event.clientY; const eventRange = caretFromPoint(x, y); if (eventRange !== null) { const node = $getNearestNodeFromDOMNode(eventRange.node); if ($isDecoratorNode(node)) { // Show browser caret as the user is dragging the media across the screen. Won't work // for DecoratorNode nor it's relevant. event.preventDefault(); } } return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( SELECT_ALL_COMMAND, () => { $selectAll(); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( COPY_COMMAND, (event) => { copyToClipboard( editor, objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null, ); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( CUT_COMMAND, (event) => { onCutForRichText(event, editor); return true; }, COMMAND_PRIORITY_EDITOR, ), editor.registerCommand( PASTE_COMMAND, (event) => { const [, files, hasTextContent] = eventFiles(event); if (files.length > 0 && !hasTextContent) { editor.dispatchCommand(DRAG_DROP_PASTE, files); return true; } // if inputs then paste within the input ignore creating a new node on paste event if (isSelectionCapturedInDecoratorInput(event.target as Node)) { return false; } const selection = $getSelection(); if (selection !== null) { onPasteForRichText(event, editor); return true; } return false; }, COMMAND_PRIORITY_EDITOR, ), ); return removeListener; }