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'));
165 static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
166 const node = $createQuoteNode();
170 exportJSON(): SerializedElementNode {
172 ...super.exportJSON(),
179 insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
180 const newBlock = $createParagraphNode();
181 const direction = this.getDirection();
182 newBlock.setDirection(direction);
183 this.insertAfter(newBlock, restoreSelection);
187 collapseAtStart(): true {
188 const paragraph = $createParagraphNode();
189 const children = this.getChildren();
190 children.forEach((child) => paragraph.append(child));
191 this.replace(paragraph);
195 canMergeWhenEmpty(): true {
200 export function $createQuoteNode(): QuoteNode {
201 return $applyNodeReplacement(new QuoteNode());
204 export function $isQuoteNode(
205 node: LexicalNode | null | undefined,
206 ): node is QuoteNode {
207 return node instanceof QuoteNode;
210 export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
213 export class HeadingNode extends ElementNode {
215 __tag: HeadingTagType;
217 static getType(): string {
221 static clone(node: HeadingNode): HeadingNode {
222 return new HeadingNode(node.__tag, node.__key);
225 constructor(tag: HeadingTagType, key?: NodeKey) {
230 getTag(): HeadingTagType {
236 createDOM(config: EditorConfig): HTMLElement {
237 const tag = this.__tag;
238 const element = document.createElement(tag);
239 const theme = config.theme;
240 const classNames = theme.heading;
241 if (classNames !== undefined) {
242 const className = classNames[tag];
243 addClassNamesToElement(element, className);
248 updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
252 static importDOM(): DOMConversionMap | null {
254 h1: (node: Node) => ({
255 conversion: $convertHeadingElement,
258 h2: (node: Node) => ({
259 conversion: $convertHeadingElement,
262 h3: (node: Node) => ({
263 conversion: $convertHeadingElement,
266 h4: (node: Node) => ({
267 conversion: $convertHeadingElement,
270 h5: (node: Node) => ({
271 conversion: $convertHeadingElement,
274 h6: (node: Node) => ({
275 conversion: $convertHeadingElement,
279 // domNode is a <p> since we matched it by nodeName
280 const paragraph = node as HTMLParagraphElement;
281 const firstChild = paragraph.firstChild;
282 if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
284 conversion: () => ({node: null}),
290 span: (node: Node) => {
291 if (isGoogleDocsTitle(node)) {
293 conversion: (domNode: Node) => {
295 node: $createHeadingNode('h1'),
306 exportDOM(editor: LexicalEditor): DOMExportOutput {
307 const {element} = super.exportDOM(editor);
309 if (element && isHTMLElement(element)) {
310 if (this.isEmpty()) {
311 element.append(document.createElement('br'));
320 static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
321 return $createHeadingNode(serializedNode.tag);
324 exportJSON(): SerializedHeadingNode {
326 ...super.exportJSON(),
335 selection?: RangeSelection,
336 restoreSelection = true,
337 ): ParagraphNode | HeadingNode {
338 const anchorOffet = selection ? selection.anchor.offset : 0;
339 const lastDesc = this.getLastDescendant();
343 selection.anchor.key === lastDesc.getKey() &&
344 anchorOffet === lastDesc.getTextContentSize());
346 isAtEnd || !selection
347 ? $createParagraphNode()
348 : $createHeadingNode(this.getTag());
349 const direction = this.getDirection();
350 newElement.setDirection(direction);
351 this.insertAfter(newElement, restoreSelection);
352 if (anchorOffet === 0 && !this.isEmpty() && selection) {
353 const paragraph = $createParagraphNode();
355 this.replace(paragraph, true);
360 collapseAtStart(): true {
361 const newElement = !this.isEmpty()
362 ? $createHeadingNode(this.getTag())
363 : $createParagraphNode();
364 const children = this.getChildren();
365 children.forEach((child) => newElement.append(child));
366 this.replace(newElement);
370 extractWithChild(): boolean {
375 function isGoogleDocsTitle(domNode: Node): boolean {
376 if (domNode.nodeName.toLowerCase() === 'span') {
377 return (domNode as HTMLSpanElement).style.fontSize === '26pt';
382 function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
383 const nodeName = element.nodeName.toLowerCase();
393 node = $createHeadingNode(nodeName);
398 function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
399 const node = $createQuoteNode();
403 export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
404 return $applyNodeReplacement(new HeadingNode(headingTag));
407 export function $isHeadingNode(
408 node: LexicalNode | null | undefined,
409 ): node is HeadingNode {
410 return node instanceof HeadingNode;
413 function onPasteForRichText(
414 event: CommandPayloadType<typeof PASTE_COMMAND>,
415 editor: LexicalEditor,
417 event.preventDefault();
420 const selection = $getSelection();
421 const clipboardData =
422 objectKlassEquals(event, InputEvent) ||
423 objectKlassEquals(event, KeyboardEvent)
425 : (event as ClipboardEvent).clipboardData;
426 if (clipboardData != null && selection !== null) {
427 $insertDataTransferForRichText(clipboardData, selection, editor);
436 async function onCutForRichText(
437 event: CommandPayloadType<typeof CUT_COMMAND>,
438 editor: LexicalEditor,
440 await copyToClipboard(
442 objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
444 editor.update(() => {
445 const selection = $getSelection();
446 if ($isRangeSelection(selection)) {
447 selection.removeText();
448 } else if ($isNodeSelection(selection)) {
449 selection.getNodes().forEach((node) => node.remove());
454 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
455 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
456 // control this with the first boolean flag.
457 export function eventFiles(
458 event: DragEvent | PasteCommandType,
459 ): [boolean, Array<File>, boolean] {
460 let dataTransfer: null | DataTransfer = null;
461 if (objectKlassEquals(event, DragEvent)) {
462 dataTransfer = (event as DragEvent).dataTransfer;
463 } else if (objectKlassEquals(event, ClipboardEvent)) {
464 dataTransfer = (event as ClipboardEvent).clipboardData;
467 if (dataTransfer === null) {
468 return [false, [], false];
471 const types = dataTransfer.types;
472 const hasFiles = types.includes('Files');
474 types.includes('text/html') || types.includes('text/plain');
475 return [hasFiles, Array.from(dataTransfer.files), hasContent];
478 function $handleIndentAndOutdent(
479 indentOrOutdent: (block: ElementNode) => void,
481 const selection = $getSelection();
482 if (!$isRangeSelection(selection)) {
485 const alreadyHandled = new Set();
486 const nodes = selection.getNodes();
487 for (let i = 0; i < nodes.length; i++) {
488 const node = nodes[i];
489 const key = node.getKey();
490 if (alreadyHandled.has(key)) {
493 const parentBlock = $findMatchingParent(
495 (parentNode): parentNode is ElementNode =>
496 $isElementNode(parentNode) && !parentNode.isInline(),
498 if (parentBlock === null) {
501 const parentKey = parentBlock.getKey();
502 if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
503 alreadyHandled.add(parentKey);
504 indentOrOutdent(parentBlock);
507 return alreadyHandled.size > 0;
510 function $isTargetWithinDecorator(target: HTMLElement): boolean {
511 const node = $getNearestNodeFromDOMNode(target);
512 return $isDecoratorNode(node);
515 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
516 const focus = selection.focus;
517 return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
520 export function registerRichText(editor: LexicalEditor): () => void {
521 const removeListener = mergeRegister(
522 editor.registerCommand(
525 const selection = $getSelection();
526 if ($isNodeSelection(selection)) {
534 editor.registerCommand<boolean>(
535 DELETE_CHARACTER_COMMAND,
537 const selection = $getSelection();
538 if (!$isRangeSelection(selection)) {
541 selection.deleteCharacter(isBackward);
544 COMMAND_PRIORITY_EDITOR,
546 editor.registerCommand<boolean>(
549 const selection = $getSelection();
550 if (!$isRangeSelection(selection)) {
553 selection.deleteWord(isBackward);
556 COMMAND_PRIORITY_EDITOR,
558 editor.registerCommand<boolean>(
561 const selection = $getSelection();
562 if (!$isRangeSelection(selection)) {
565 selection.deleteLine(isBackward);
568 COMMAND_PRIORITY_EDITOR,
570 editor.registerCommand(
571 CONTROLLED_TEXT_INSERTION_COMMAND,
573 const selection = $getSelection();
575 if (typeof eventOrText === 'string') {
576 if (selection !== null) {
577 selection.insertText(eventOrText);
580 if (selection === null) {
584 const dataTransfer = eventOrText.dataTransfer;
585 if (dataTransfer != null) {
586 $insertDataTransferForRichText(dataTransfer, selection, editor);
587 } else if ($isRangeSelection(selection)) {
588 const data = eventOrText.data;
590 selection.insertText(data);
597 COMMAND_PRIORITY_EDITOR,
599 editor.registerCommand(
602 const selection = $getSelection();
603 if (!$isRangeSelection(selection)) {
606 selection.removeText();
609 COMMAND_PRIORITY_EDITOR,
611 editor.registerCommand<TextFormatType>(
614 const selection = $getSelection();
615 if (!$isRangeSelection(selection)) {
618 selection.formatText(format);
621 COMMAND_PRIORITY_EDITOR,
623 editor.registerCommand<ElementFormatType>(
624 FORMAT_ELEMENT_COMMAND,
626 const selection = $getSelection();
627 if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
630 const nodes = selection.getNodes();
631 for (const node of nodes) {
632 const element = $findMatchingParent(
634 (parentNode): parentNode is ElementNode =>
635 $isElementNode(parentNode) && !parentNode.isInline(),
640 COMMAND_PRIORITY_EDITOR,
642 editor.registerCommand<boolean>(
643 INSERT_LINE_BREAK_COMMAND,
645 const selection = $getSelection();
646 if (!$isRangeSelection(selection)) {
649 selection.insertLineBreak(selectStart);
652 COMMAND_PRIORITY_EDITOR,
654 editor.registerCommand(
655 INSERT_PARAGRAPH_COMMAND,
657 const selection = $getSelection();
658 if (!$isRangeSelection(selection)) {
661 selection.insertParagraph();
664 COMMAND_PRIORITY_EDITOR,
666 editor.registerCommand(
669 $insertNodes([$createTabNode()]);
672 COMMAND_PRIORITY_EDITOR,
674 editor.registerCommand<KeyboardEvent>(
675 KEY_ARROW_UP_COMMAND,
677 const selection = $getSelection();
679 $isNodeSelection(selection) &&
680 !$isTargetWithinDecorator(event.target as HTMLElement)
682 // If selection is on a node, let's try and move selection
683 // back to being a range selection.
684 const nodes = selection.getNodes();
685 if (nodes.length > 0) {
686 nodes[0].selectPrevious();
689 } else if ($isRangeSelection(selection)) {
690 const possibleNode = $getAdjacentNode(selection.focus, true);
693 $isDecoratorNode(possibleNode) &&
694 !possibleNode.isIsolated() &&
695 !possibleNode.isInline()
697 possibleNode.selectPrevious();
698 event.preventDefault();
704 COMMAND_PRIORITY_EDITOR,
706 editor.registerCommand<KeyboardEvent>(
707 KEY_ARROW_DOWN_COMMAND,
709 const selection = $getSelection();
710 if ($isNodeSelection(selection)) {
711 // If selection is on a node, let's try and move selection
712 // back to being a range selection.
713 const nodes = selection.getNodes();
714 if (nodes.length > 0) {
715 nodes[0].selectNext(0, 0);
718 } else if ($isRangeSelection(selection)) {
719 if ($isSelectionAtEndOfRoot(selection)) {
720 event.preventDefault();
723 const possibleNode = $getAdjacentNode(selection.focus, false);
726 $isDecoratorNode(possibleNode) &&
727 !possibleNode.isIsolated() &&
728 !possibleNode.isInline()
730 possibleNode.selectNext();
731 event.preventDefault();
737 COMMAND_PRIORITY_EDITOR,
739 editor.registerCommand<KeyboardEvent>(
740 KEY_ARROW_LEFT_COMMAND,
742 const selection = $getSelection();
743 if ($isNodeSelection(selection)) {
744 // If selection is on a node, let's try and move selection
745 // back to being a range selection.
746 const nodes = selection.getNodes();
747 if (nodes.length > 0) {
748 event.preventDefault();
749 nodes[0].selectPrevious();
753 if (!$isRangeSelection(selection)) {
756 if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
757 const isHoldingShift = event.shiftKey;
758 event.preventDefault();
759 $moveCharacter(selection, isHoldingShift, true);
764 COMMAND_PRIORITY_EDITOR,
766 editor.registerCommand<KeyboardEvent>(
767 KEY_ARROW_RIGHT_COMMAND,
769 const selection = $getSelection();
771 $isNodeSelection(selection) &&
772 !$isTargetWithinDecorator(event.target as HTMLElement)
774 // If selection is on a node, let's try and move selection
775 // back to being a range selection.
776 const nodes = selection.getNodes();
777 if (nodes.length > 0) {
778 event.preventDefault();
779 nodes[0].selectNext(0, 0);
783 if (!$isRangeSelection(selection)) {
786 const isHoldingShift = event.shiftKey;
787 if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
788 event.preventDefault();
789 $moveCharacter(selection, isHoldingShift, false);
794 COMMAND_PRIORITY_EDITOR,
796 editor.registerCommand<KeyboardEvent>(
797 KEY_BACKSPACE_COMMAND,
799 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
802 const selection = $getSelection();
803 if (!$isRangeSelection(selection)) {
806 event.preventDefault();
808 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
810 COMMAND_PRIORITY_EDITOR,
812 editor.registerCommand<KeyboardEvent>(
815 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
818 const selection = $getSelection();
819 if (!$isRangeSelection(selection)) {
822 event.preventDefault();
823 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
825 COMMAND_PRIORITY_EDITOR,
827 editor.registerCommand<KeyboardEvent | null>(
830 const selection = $getSelection();
831 if (!$isRangeSelection(selection)) {
834 if (event !== null) {
835 // If we have beforeinput, then we can avoid blocking
836 // the default behavior. This ensures that the iOS can
837 // intercept that we're actually inserting a paragraph,
838 // and autocomplete, autocapitalize etc work as intended.
839 // This can also cause a strange performance issue in
840 // Safari, where there is a noticeable pause due to
841 // preventing the key down of enter.
843 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
848 event.preventDefault();
849 if (event.shiftKey) {
850 return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
853 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
855 COMMAND_PRIORITY_EDITOR,
857 editor.registerCommand(
860 const selection = $getSelection();
861 if (!$isRangeSelection(selection)) {
867 COMMAND_PRIORITY_EDITOR,
869 editor.registerCommand<DragEvent>(
872 const [, files] = eventFiles(event);
873 if (files.length > 0) {
874 const x = event.clientX;
875 const y = event.clientY;
876 const eventRange = caretFromPoint(x, y);
877 if (eventRange !== null) {
878 const {offset: domOffset, node: domNode} = eventRange;
879 const node = $getNearestNodeFromDOMNode(domNode);
881 const selection = $createRangeSelection();
882 if ($isTextNode(node)) {
883 selection.anchor.set(node.getKey(), domOffset, 'text');
884 selection.focus.set(node.getKey(), domOffset, 'text');
886 const parentKey = node.getParentOrThrow().getKey();
887 const offset = node.getIndexWithinParent() + 1;
888 selection.anchor.set(parentKey, offset, 'element');
889 selection.focus.set(parentKey, offset, 'element');
891 const normalizedSelection =
892 $normalizeSelection__EXPERIMENTAL(selection);
893 $setSelection(normalizedSelection);
895 editor.dispatchCommand(DRAG_DROP_PASTE, files);
897 event.preventDefault();
901 const selection = $getSelection();
902 if ($isRangeSelection(selection)) {
908 COMMAND_PRIORITY_EDITOR,
910 editor.registerCommand<DragEvent>(
913 const [isFileTransfer] = eventFiles(event);
914 const selection = $getSelection();
915 if (isFileTransfer && !$isRangeSelection(selection)) {
920 COMMAND_PRIORITY_EDITOR,
922 editor.registerCommand<DragEvent>(
925 const [isFileTransfer] = eventFiles(event);
926 const selection = $getSelection();
927 if (isFileTransfer && !$isRangeSelection(selection)) {
930 const x = event.clientX;
931 const y = event.clientY;
932 const eventRange = caretFromPoint(x, y);
933 if (eventRange !== null) {
934 const node = $getNearestNodeFromDOMNode(eventRange.node);
935 if ($isDecoratorNode(node)) {
936 // Show browser caret as the user is dragging the media across the screen. Won't work
937 // for DecoratorNode nor it's relevant.
938 event.preventDefault();
943 COMMAND_PRIORITY_EDITOR,
945 editor.registerCommand(
952 COMMAND_PRIORITY_EDITOR,
954 editor.registerCommand(
959 objectKlassEquals(event, ClipboardEvent)
960 ? (event as ClipboardEvent)
965 COMMAND_PRIORITY_EDITOR,
967 editor.registerCommand(
970 onCutForRichText(event, editor);
973 COMMAND_PRIORITY_EDITOR,
975 editor.registerCommand(
978 const [, files, hasTextContent] = eventFiles(event);
979 if (files.length > 0 && !hasTextContent) {
980 editor.dispatchCommand(DRAG_DROP_PASTE, files);
984 // if inputs then paste within the input ignore creating a new node on paste event
985 if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
989 const selection = $getSelection();
990 if (selection !== null) {
991 onPasteForRichText(event, editor);
997 COMMAND_PRIORITY_EDITOR,
1000 return removeListener;