2 * Copyright (c) Meta Platforms, Inc. and affiliates.
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
23 SerializedElementNode,
29 $insertDataTransferForRichText,
31 } from '@lexical/clipboard';
34 $shouldOverrideDefaultCharacterSelection,
35 } from '@lexical/selection';
38 $getNearestBlockElementAncestorOrThrow,
39 addClassNamesToElement,
43 } from '@lexical/utils';
45 $applyNodeReplacement,
47 $createRangeSelection,
50 $getNearestNodeFromDOMNode,
60 $normalizeSelection__EXPERIMENTAL,
64 COMMAND_PRIORITY_EDITOR,
65 CONTROLLED_TEXT_INSERTION_COMMAND,
69 DELETE_CHARACTER_COMMAND,
76 FORMAT_ELEMENT_COMMAND,
78 INDENT_CONTENT_COMMAND,
79 INSERT_LINE_BREAK_COMMAND,
80 INSERT_PARAGRAPH_COMMAND,
82 isSelectionCapturedInDecoratorInput,
83 KEY_ARROW_DOWN_COMMAND,
84 KEY_ARROW_LEFT_COMMAND,
85 KEY_ARROW_RIGHT_COMMAND,
87 KEY_BACKSPACE_COMMAND,
91 OUTDENT_CONTENT_COMMAND,
96 import caretFromPoint from 'lexical/shared/caretFromPoint';
102 } from 'lexical/shared/environment';
104 export type SerializedHeadingNode = Spread<
106 tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
108 SerializedElementNode
111 export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
112 'DRAG_DROP_PASTE_FILE',
115 export type SerializedQuoteNode = SerializedElementNode;
118 export class QuoteNode extends ElementNode {
119 static getType(): string {
123 static clone(node: QuoteNode): QuoteNode {
124 return new QuoteNode(node.__key);
127 constructor(key?: NodeKey) {
133 createDOM(config: EditorConfig): HTMLElement {
134 const element = document.createElement('blockquote');
135 addClassNamesToElement(element, config.theme.quote);
138 updateDOM(prevNode: QuoteNode, dom: HTMLElement): boolean {
142 static importDOM(): DOMConversionMap | null {
144 blockquote: (node: Node) => ({
145 conversion: $convertBlockquoteElement,
151 exportDOM(editor: LexicalEditor): DOMExportOutput {
152 const {element} = super.exportDOM(editor);
154 if (element && isHTMLElement(element)) {
155 if (this.isEmpty()) {
156 element.append(document.createElement('br'));
159 const formatType = this.getFormatType();
160 element.style.textAlign = formatType;
162 const direction = this.getDirection();
164 element.dir = direction;
173 static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
174 const node = $createQuoteNode();
175 node.setFormat(serializedNode.format);
176 node.setIndent(serializedNode.indent);
177 node.setDirection(serializedNode.direction);
181 exportJSON(): SerializedElementNode {
183 ...super.exportJSON(),
190 insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
191 const newBlock = $createParagraphNode();
192 const direction = this.getDirection();
193 newBlock.setDirection(direction);
194 this.insertAfter(newBlock, restoreSelection);
198 collapseAtStart(): true {
199 const paragraph = $createParagraphNode();
200 const children = this.getChildren();
201 children.forEach((child) => paragraph.append(child));
202 this.replace(paragraph);
206 canMergeWhenEmpty(): true {
211 export function $createQuoteNode(): QuoteNode {
212 return $applyNodeReplacement(new QuoteNode());
215 export function $isQuoteNode(
216 node: LexicalNode | null | undefined,
217 ): node is QuoteNode {
218 return node instanceof QuoteNode;
221 export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
224 export class HeadingNode extends ElementNode {
226 __tag: HeadingTagType;
228 static getType(): string {
232 static clone(node: HeadingNode): HeadingNode {
233 return new HeadingNode(node.__tag, node.__key);
236 constructor(tag: HeadingTagType, key?: NodeKey) {
241 getTag(): HeadingTagType {
247 createDOM(config: EditorConfig): HTMLElement {
248 const tag = this.__tag;
249 const element = document.createElement(tag);
250 const theme = config.theme;
251 const classNames = theme.heading;
252 if (classNames !== undefined) {
253 const className = classNames[tag];
254 addClassNamesToElement(element, className);
259 updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
263 static importDOM(): DOMConversionMap | null {
265 h1: (node: Node) => ({
266 conversion: $convertHeadingElement,
269 h2: (node: Node) => ({
270 conversion: $convertHeadingElement,
273 h3: (node: Node) => ({
274 conversion: $convertHeadingElement,
277 h4: (node: Node) => ({
278 conversion: $convertHeadingElement,
281 h5: (node: Node) => ({
282 conversion: $convertHeadingElement,
285 h6: (node: Node) => ({
286 conversion: $convertHeadingElement,
290 // domNode is a <p> since we matched it by nodeName
291 const paragraph = node as HTMLParagraphElement;
292 const firstChild = paragraph.firstChild;
293 if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
295 conversion: () => ({node: null}),
301 span: (node: Node) => {
302 if (isGoogleDocsTitle(node)) {
304 conversion: (domNode: Node) => {
306 node: $createHeadingNode('h1'),
317 exportDOM(editor: LexicalEditor): DOMExportOutput {
318 const {element} = super.exportDOM(editor);
320 if (element && isHTMLElement(element)) {
321 if (this.isEmpty()) {
322 element.append(document.createElement('br'));
325 const formatType = this.getFormatType();
326 element.style.textAlign = formatType;
328 const direction = this.getDirection();
330 element.dir = direction;
339 static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
340 const node = $createHeadingNode(serializedNode.tag);
341 node.setFormat(serializedNode.format);
342 node.setIndent(serializedNode.indent);
343 node.setDirection(serializedNode.direction);
347 exportJSON(): SerializedHeadingNode {
349 ...super.exportJSON(),
358 selection?: RangeSelection,
359 restoreSelection = true,
360 ): ParagraphNode | HeadingNode {
361 const anchorOffet = selection ? selection.anchor.offset : 0;
362 const lastDesc = this.getLastDescendant();
366 selection.anchor.key === lastDesc.getKey() &&
367 anchorOffet === lastDesc.getTextContentSize());
369 isAtEnd || !selection
370 ? $createParagraphNode()
371 : $createHeadingNode(this.getTag());
372 const direction = this.getDirection();
373 newElement.setDirection(direction);
374 this.insertAfter(newElement, restoreSelection);
375 if (anchorOffet === 0 && !this.isEmpty() && selection) {
376 const paragraph = $createParagraphNode();
378 this.replace(paragraph, true);
383 collapseAtStart(): true {
384 const newElement = !this.isEmpty()
385 ? $createHeadingNode(this.getTag())
386 : $createParagraphNode();
387 const children = this.getChildren();
388 children.forEach((child) => newElement.append(child));
389 this.replace(newElement);
393 extractWithChild(): boolean {
398 function isGoogleDocsTitle(domNode: Node): boolean {
399 if (domNode.nodeName.toLowerCase() === 'span') {
400 return (domNode as HTMLSpanElement).style.fontSize === '26pt';
405 function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
406 const nodeName = element.nodeName.toLowerCase();
416 node = $createHeadingNode(nodeName);
417 if (element.style !== null) {
418 node.setFormat(element.style.textAlign as ElementFormatType);
424 function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
425 const node = $createQuoteNode();
426 if (element.style !== null) {
427 node.setFormat(element.style.textAlign as ElementFormatType);
432 export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
433 return $applyNodeReplacement(new HeadingNode(headingTag));
436 export function $isHeadingNode(
437 node: LexicalNode | null | undefined,
438 ): node is HeadingNode {
439 return node instanceof HeadingNode;
442 function onPasteForRichText(
443 event: CommandPayloadType<typeof PASTE_COMMAND>,
444 editor: LexicalEditor,
446 event.preventDefault();
449 const selection = $getSelection();
450 const clipboardData =
451 objectKlassEquals(event, InputEvent) ||
452 objectKlassEquals(event, KeyboardEvent)
454 : (event as ClipboardEvent).clipboardData;
455 if (clipboardData != null && selection !== null) {
456 $insertDataTransferForRichText(clipboardData, selection, editor);
465 async function onCutForRichText(
466 event: CommandPayloadType<typeof CUT_COMMAND>,
467 editor: LexicalEditor,
469 await copyToClipboard(
471 objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
473 editor.update(() => {
474 const selection = $getSelection();
475 if ($isRangeSelection(selection)) {
476 selection.removeText();
477 } else if ($isNodeSelection(selection)) {
478 selection.getNodes().forEach((node) => node.remove());
483 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
484 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
485 // control this with the first boolean flag.
486 export function eventFiles(
487 event: DragEvent | PasteCommandType,
488 ): [boolean, Array<File>, boolean] {
489 let dataTransfer: null | DataTransfer = null;
490 if (objectKlassEquals(event, DragEvent)) {
491 dataTransfer = (event as DragEvent).dataTransfer;
492 } else if (objectKlassEquals(event, ClipboardEvent)) {
493 dataTransfer = (event as ClipboardEvent).clipboardData;
496 if (dataTransfer === null) {
497 return [false, [], false];
500 const types = dataTransfer.types;
501 const hasFiles = types.includes('Files');
503 types.includes('text/html') || types.includes('text/plain');
504 return [hasFiles, Array.from(dataTransfer.files), hasContent];
507 function $handleIndentAndOutdent(
508 indentOrOutdent: (block: ElementNode) => void,
510 const selection = $getSelection();
511 if (!$isRangeSelection(selection)) {
514 const alreadyHandled = new Set();
515 const nodes = selection.getNodes();
516 for (let i = 0; i < nodes.length; i++) {
517 const node = nodes[i];
518 const key = node.getKey();
519 if (alreadyHandled.has(key)) {
522 const parentBlock = $findMatchingParent(
524 (parentNode): parentNode is ElementNode =>
525 $isElementNode(parentNode) && !parentNode.isInline(),
527 if (parentBlock === null) {
530 const parentKey = parentBlock.getKey();
531 if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
532 alreadyHandled.add(parentKey);
533 indentOrOutdent(parentBlock);
536 return alreadyHandled.size > 0;
539 function $isTargetWithinDecorator(target: HTMLElement): boolean {
540 const node = $getNearestNodeFromDOMNode(target);
541 return $isDecoratorNode(node);
544 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
545 const focus = selection.focus;
546 return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
549 export function registerRichText(editor: LexicalEditor): () => void {
550 const removeListener = mergeRegister(
551 editor.registerCommand(
554 const selection = $getSelection();
555 if ($isNodeSelection(selection)) {
563 editor.registerCommand<boolean>(
564 DELETE_CHARACTER_COMMAND,
566 const selection = $getSelection();
567 if (!$isRangeSelection(selection)) {
570 selection.deleteCharacter(isBackward);
573 COMMAND_PRIORITY_EDITOR,
575 editor.registerCommand<boolean>(
578 const selection = $getSelection();
579 if (!$isRangeSelection(selection)) {
582 selection.deleteWord(isBackward);
585 COMMAND_PRIORITY_EDITOR,
587 editor.registerCommand<boolean>(
590 const selection = $getSelection();
591 if (!$isRangeSelection(selection)) {
594 selection.deleteLine(isBackward);
597 COMMAND_PRIORITY_EDITOR,
599 editor.registerCommand(
600 CONTROLLED_TEXT_INSERTION_COMMAND,
602 const selection = $getSelection();
604 if (typeof eventOrText === 'string') {
605 if (selection !== null) {
606 selection.insertText(eventOrText);
609 if (selection === null) {
613 const dataTransfer = eventOrText.dataTransfer;
614 if (dataTransfer != null) {
615 $insertDataTransferForRichText(dataTransfer, selection, editor);
616 } else if ($isRangeSelection(selection)) {
617 const data = eventOrText.data;
619 selection.insertText(data);
626 COMMAND_PRIORITY_EDITOR,
628 editor.registerCommand(
631 const selection = $getSelection();
632 if (!$isRangeSelection(selection)) {
635 selection.removeText();
638 COMMAND_PRIORITY_EDITOR,
640 editor.registerCommand<TextFormatType>(
643 const selection = $getSelection();
644 if (!$isRangeSelection(selection)) {
647 selection.formatText(format);
650 COMMAND_PRIORITY_EDITOR,
652 editor.registerCommand<ElementFormatType>(
653 FORMAT_ELEMENT_COMMAND,
655 const selection = $getSelection();
656 if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
659 const nodes = selection.getNodes();
660 for (const node of nodes) {
661 const element = $findMatchingParent(
663 (parentNode): parentNode is ElementNode =>
664 $isElementNode(parentNode) && !parentNode.isInline(),
666 if (element !== null) {
667 element.setFormat(format);
672 COMMAND_PRIORITY_EDITOR,
674 editor.registerCommand<boolean>(
675 INSERT_LINE_BREAK_COMMAND,
677 const selection = $getSelection();
678 if (!$isRangeSelection(selection)) {
681 selection.insertLineBreak(selectStart);
684 COMMAND_PRIORITY_EDITOR,
686 editor.registerCommand(
687 INSERT_PARAGRAPH_COMMAND,
689 const selection = $getSelection();
690 if (!$isRangeSelection(selection)) {
693 selection.insertParagraph();
696 COMMAND_PRIORITY_EDITOR,
698 editor.registerCommand(
701 $insertNodes([$createTabNode()]);
704 COMMAND_PRIORITY_EDITOR,
706 editor.registerCommand(
707 INDENT_CONTENT_COMMAND,
709 return $handleIndentAndOutdent((block) => {
710 const indent = block.getIndent();
711 block.setIndent(indent + 1);
714 COMMAND_PRIORITY_EDITOR,
716 editor.registerCommand(
717 OUTDENT_CONTENT_COMMAND,
719 return $handleIndentAndOutdent((block) => {
720 const indent = block.getIndent();
722 block.setIndent(indent - 1);
726 COMMAND_PRIORITY_EDITOR,
728 editor.registerCommand<KeyboardEvent>(
729 KEY_ARROW_UP_COMMAND,
731 const selection = $getSelection();
733 $isNodeSelection(selection) &&
734 !$isTargetWithinDecorator(event.target as HTMLElement)
736 // If selection is on a node, let's try and move selection
737 // back to being a range selection.
738 const nodes = selection.getNodes();
739 if (nodes.length > 0) {
740 nodes[0].selectPrevious();
743 } else if ($isRangeSelection(selection)) {
744 const possibleNode = $getAdjacentNode(selection.focus, true);
747 $isDecoratorNode(possibleNode) &&
748 !possibleNode.isIsolated() &&
749 !possibleNode.isInline()
751 possibleNode.selectPrevious();
752 event.preventDefault();
758 COMMAND_PRIORITY_EDITOR,
760 editor.registerCommand<KeyboardEvent>(
761 KEY_ARROW_DOWN_COMMAND,
763 const selection = $getSelection();
764 if ($isNodeSelection(selection)) {
765 // If selection is on a node, let's try and move selection
766 // back to being a range selection.
767 const nodes = selection.getNodes();
768 if (nodes.length > 0) {
769 nodes[0].selectNext(0, 0);
772 } else if ($isRangeSelection(selection)) {
773 if ($isSelectionAtEndOfRoot(selection)) {
774 event.preventDefault();
777 const possibleNode = $getAdjacentNode(selection.focus, false);
780 $isDecoratorNode(possibleNode) &&
781 !possibleNode.isIsolated() &&
782 !possibleNode.isInline()
784 possibleNode.selectNext();
785 event.preventDefault();
791 COMMAND_PRIORITY_EDITOR,
793 editor.registerCommand<KeyboardEvent>(
794 KEY_ARROW_LEFT_COMMAND,
796 const selection = $getSelection();
797 if ($isNodeSelection(selection)) {
798 // If selection is on a node, let's try and move selection
799 // back to being a range selection.
800 const nodes = selection.getNodes();
801 if (nodes.length > 0) {
802 event.preventDefault();
803 nodes[0].selectPrevious();
807 if (!$isRangeSelection(selection)) {
810 if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
811 const isHoldingShift = event.shiftKey;
812 event.preventDefault();
813 $moveCharacter(selection, isHoldingShift, true);
818 COMMAND_PRIORITY_EDITOR,
820 editor.registerCommand<KeyboardEvent>(
821 KEY_ARROW_RIGHT_COMMAND,
823 const selection = $getSelection();
825 $isNodeSelection(selection) &&
826 !$isTargetWithinDecorator(event.target as HTMLElement)
828 // If selection is on a node, let's try and move selection
829 // back to being a range selection.
830 const nodes = selection.getNodes();
831 if (nodes.length > 0) {
832 event.preventDefault();
833 nodes[0].selectNext(0, 0);
837 if (!$isRangeSelection(selection)) {
840 const isHoldingShift = event.shiftKey;
841 if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
842 event.preventDefault();
843 $moveCharacter(selection, isHoldingShift, false);
848 COMMAND_PRIORITY_EDITOR,
850 editor.registerCommand<KeyboardEvent>(
851 KEY_BACKSPACE_COMMAND,
853 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
856 const selection = $getSelection();
857 if (!$isRangeSelection(selection)) {
860 event.preventDefault();
861 const {anchor} = selection;
862 const anchorNode = anchor.getNode();
865 selection.isCollapsed() &&
866 anchor.offset === 0 &&
867 !$isRootNode(anchorNode)
869 const element = $getNearestBlockElementAncestorOrThrow(anchorNode);
870 if (element.getIndent() > 0) {
871 return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
874 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
876 COMMAND_PRIORITY_EDITOR,
878 editor.registerCommand<KeyboardEvent>(
881 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
884 const selection = $getSelection();
885 if (!$isRangeSelection(selection)) {
888 event.preventDefault();
889 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
891 COMMAND_PRIORITY_EDITOR,
893 editor.registerCommand<KeyboardEvent | null>(
896 const selection = $getSelection();
897 if (!$isRangeSelection(selection)) {
900 if (event !== null) {
901 // If we have beforeinput, then we can avoid blocking
902 // the default behavior. This ensures that the iOS can
903 // intercept that we're actually inserting a paragraph,
904 // and autocomplete, autocapitalize etc work as intended.
905 // This can also cause a strange performance issue in
906 // Safari, where there is a noticeable pause due to
907 // preventing the key down of enter.
909 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
914 event.preventDefault();
915 if (event.shiftKey) {
916 return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
919 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
921 COMMAND_PRIORITY_EDITOR,
923 editor.registerCommand(
926 const selection = $getSelection();
927 if (!$isRangeSelection(selection)) {
933 COMMAND_PRIORITY_EDITOR,
935 editor.registerCommand<DragEvent>(
938 const [, files] = eventFiles(event);
939 if (files.length > 0) {
940 const x = event.clientX;
941 const y = event.clientY;
942 const eventRange = caretFromPoint(x, y);
943 if (eventRange !== null) {
944 const {offset: domOffset, node: domNode} = eventRange;
945 const node = $getNearestNodeFromDOMNode(domNode);
947 const selection = $createRangeSelection();
948 if ($isTextNode(node)) {
949 selection.anchor.set(node.getKey(), domOffset, 'text');
950 selection.focus.set(node.getKey(), domOffset, 'text');
952 const parentKey = node.getParentOrThrow().getKey();
953 const offset = node.getIndexWithinParent() + 1;
954 selection.anchor.set(parentKey, offset, 'element');
955 selection.focus.set(parentKey, offset, 'element');
957 const normalizedSelection =
958 $normalizeSelection__EXPERIMENTAL(selection);
959 $setSelection(normalizedSelection);
961 editor.dispatchCommand(DRAG_DROP_PASTE, files);
963 event.preventDefault();
967 const selection = $getSelection();
968 if ($isRangeSelection(selection)) {
974 COMMAND_PRIORITY_EDITOR,
976 editor.registerCommand<DragEvent>(
979 const [isFileTransfer] = eventFiles(event);
980 const selection = $getSelection();
981 if (isFileTransfer && !$isRangeSelection(selection)) {
986 COMMAND_PRIORITY_EDITOR,
988 editor.registerCommand<DragEvent>(
991 const [isFileTransfer] = eventFiles(event);
992 const selection = $getSelection();
993 if (isFileTransfer && !$isRangeSelection(selection)) {
996 const x = event.clientX;
997 const y = event.clientY;
998 const eventRange = caretFromPoint(x, y);
999 if (eventRange !== null) {
1000 const node = $getNearestNodeFromDOMNode(eventRange.node);
1001 if ($isDecoratorNode(node)) {
1002 // Show browser caret as the user is dragging the media across the screen. Won't work
1003 // for DecoratorNode nor it's relevant.
1004 event.preventDefault();
1009 COMMAND_PRIORITY_EDITOR,
1011 editor.registerCommand(
1018 COMMAND_PRIORITY_EDITOR,
1020 editor.registerCommand(
1025 objectKlassEquals(event, ClipboardEvent)
1026 ? (event as ClipboardEvent)
1031 COMMAND_PRIORITY_EDITOR,
1033 editor.registerCommand(
1036 onCutForRichText(event, editor);
1039 COMMAND_PRIORITY_EDITOR,
1041 editor.registerCommand(
1044 const [, files, hasTextContent] = eventFiles(event);
1045 if (files.length > 0 && !hasTextContent) {
1046 editor.dispatchCommand(DRAG_DROP_PASTE, files);
1050 // if inputs then paste within the input ignore creating a new node on paste event
1051 if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
1055 const selection = $getSelection();
1056 if (selection !== null) {
1057 onPasteForRichText(event, editor);
1063 COMMAND_PRIORITY_EDITOR,
1066 return removeListener;