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;
168 static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
169 const node = $createQuoteNode();
170 node.setFormat(serializedNode.format);
171 node.setIndent(serializedNode.indent);
175 exportJSON(): SerializedElementNode {
177 ...super.exportJSON(),
184 insertNewAfter(_: RangeSelection, restoreSelection?: boolean): ParagraphNode {
185 const newBlock = $createParagraphNode();
186 const direction = this.getDirection();
187 newBlock.setDirection(direction);
188 this.insertAfter(newBlock, restoreSelection);
192 collapseAtStart(): true {
193 const paragraph = $createParagraphNode();
194 const children = this.getChildren();
195 children.forEach((child) => paragraph.append(child));
196 this.replace(paragraph);
200 canMergeWhenEmpty(): true {
205 export function $createQuoteNode(): QuoteNode {
206 return $applyNodeReplacement(new QuoteNode());
209 export function $isQuoteNode(
210 node: LexicalNode | null | undefined,
211 ): node is QuoteNode {
212 return node instanceof QuoteNode;
215 export type HeadingTagType = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
218 export class HeadingNode extends ElementNode {
220 __tag: HeadingTagType;
222 static getType(): string {
226 static clone(node: HeadingNode): HeadingNode {
227 return new HeadingNode(node.__tag, node.__key);
230 constructor(tag: HeadingTagType, key?: NodeKey) {
235 getTag(): HeadingTagType {
241 createDOM(config: EditorConfig): HTMLElement {
242 const tag = this.__tag;
243 const element = document.createElement(tag);
244 const theme = config.theme;
245 const classNames = theme.heading;
246 if (classNames !== undefined) {
247 const className = classNames[tag];
248 addClassNamesToElement(element, className);
253 updateDOM(prevNode: HeadingNode, dom: HTMLElement): boolean {
257 static importDOM(): DOMConversionMap | null {
259 h1: (node: Node) => ({
260 conversion: $convertHeadingElement,
263 h2: (node: Node) => ({
264 conversion: $convertHeadingElement,
267 h3: (node: Node) => ({
268 conversion: $convertHeadingElement,
271 h4: (node: Node) => ({
272 conversion: $convertHeadingElement,
275 h5: (node: Node) => ({
276 conversion: $convertHeadingElement,
279 h6: (node: Node) => ({
280 conversion: $convertHeadingElement,
284 // domNode is a <p> since we matched it by nodeName
285 const paragraph = node as HTMLParagraphElement;
286 const firstChild = paragraph.firstChild;
287 if (firstChild !== null && isGoogleDocsTitle(firstChild)) {
289 conversion: () => ({node: null}),
295 span: (node: Node) => {
296 if (isGoogleDocsTitle(node)) {
298 conversion: (domNode: Node) => {
300 node: $createHeadingNode('h1'),
311 exportDOM(editor: LexicalEditor): DOMExportOutput {
312 const {element} = super.exportDOM(editor);
314 if (element && isHTMLElement(element)) {
315 if (this.isEmpty()) {
316 element.append(document.createElement('br'));
319 const formatType = this.getFormatType();
320 element.style.textAlign = formatType;
328 static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
329 const node = $createHeadingNode(serializedNode.tag);
330 node.setFormat(serializedNode.format);
331 node.setIndent(serializedNode.indent);
335 exportJSON(): SerializedHeadingNode {
337 ...super.exportJSON(),
346 selection?: RangeSelection,
347 restoreSelection = true,
348 ): ParagraphNode | HeadingNode {
349 const anchorOffet = selection ? selection.anchor.offset : 0;
350 const lastDesc = this.getLastDescendant();
354 selection.anchor.key === lastDesc.getKey() &&
355 anchorOffet === lastDesc.getTextContentSize());
357 isAtEnd || !selection
358 ? $createParagraphNode()
359 : $createHeadingNode(this.getTag());
360 const direction = this.getDirection();
361 newElement.setDirection(direction);
362 this.insertAfter(newElement, restoreSelection);
363 if (anchorOffet === 0 && !this.isEmpty() && selection) {
364 const paragraph = $createParagraphNode();
366 this.replace(paragraph, true);
371 collapseAtStart(): true {
372 const newElement = !this.isEmpty()
373 ? $createHeadingNode(this.getTag())
374 : $createParagraphNode();
375 const children = this.getChildren();
376 children.forEach((child) => newElement.append(child));
377 this.replace(newElement);
381 extractWithChild(): boolean {
386 function isGoogleDocsTitle(domNode: Node): boolean {
387 if (domNode.nodeName.toLowerCase() === 'span') {
388 return (domNode as HTMLSpanElement).style.fontSize === '26pt';
393 function $convertHeadingElement(element: HTMLElement): DOMConversionOutput {
394 const nodeName = element.nodeName.toLowerCase();
404 node = $createHeadingNode(nodeName);
405 if (element.style !== null) {
406 node.setFormat(element.style.textAlign as ElementFormatType);
412 function $convertBlockquoteElement(element: HTMLElement): DOMConversionOutput {
413 const node = $createQuoteNode();
414 if (element.style !== null) {
415 node.setFormat(element.style.textAlign as ElementFormatType);
420 export function $createHeadingNode(headingTag: HeadingTagType): HeadingNode {
421 return $applyNodeReplacement(new HeadingNode(headingTag));
424 export function $isHeadingNode(
425 node: LexicalNode | null | undefined,
426 ): node is HeadingNode {
427 return node instanceof HeadingNode;
430 function onPasteForRichText(
431 event: CommandPayloadType<typeof PASTE_COMMAND>,
432 editor: LexicalEditor,
434 event.preventDefault();
437 const selection = $getSelection();
438 const clipboardData =
439 objectKlassEquals(event, InputEvent) ||
440 objectKlassEquals(event, KeyboardEvent)
442 : (event as ClipboardEvent).clipboardData;
443 if (clipboardData != null && selection !== null) {
444 $insertDataTransferForRichText(clipboardData, selection, editor);
453 async function onCutForRichText(
454 event: CommandPayloadType<typeof CUT_COMMAND>,
455 editor: LexicalEditor,
457 await copyToClipboard(
459 objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
461 editor.update(() => {
462 const selection = $getSelection();
463 if ($isRangeSelection(selection)) {
464 selection.removeText();
465 } else if ($isNodeSelection(selection)) {
466 selection.getNodes().forEach((node) => node.remove());
471 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
472 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
473 // control this with the first boolean flag.
474 export function eventFiles(
475 event: DragEvent | PasteCommandType,
476 ): [boolean, Array<File>, boolean] {
477 let dataTransfer: null | DataTransfer = null;
478 if (objectKlassEquals(event, DragEvent)) {
479 dataTransfer = (event as DragEvent).dataTransfer;
480 } else if (objectKlassEquals(event, ClipboardEvent)) {
481 dataTransfer = (event as ClipboardEvent).clipboardData;
484 if (dataTransfer === null) {
485 return [false, [], false];
488 const types = dataTransfer.types;
489 const hasFiles = types.includes('Files');
491 types.includes('text/html') || types.includes('text/plain');
492 return [hasFiles, Array.from(dataTransfer.files), hasContent];
495 function $handleIndentAndOutdent(
496 indentOrOutdent: (block: ElementNode) => void,
498 const selection = $getSelection();
499 if (!$isRangeSelection(selection)) {
502 const alreadyHandled = new Set();
503 const nodes = selection.getNodes();
504 for (let i = 0; i < nodes.length; i++) {
505 const node = nodes[i];
506 const key = node.getKey();
507 if (alreadyHandled.has(key)) {
510 const parentBlock = $findMatchingParent(
512 (parentNode): parentNode is ElementNode =>
513 $isElementNode(parentNode) && !parentNode.isInline(),
515 if (parentBlock === null) {
518 const parentKey = parentBlock.getKey();
519 if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
520 alreadyHandled.add(parentKey);
521 indentOrOutdent(parentBlock);
524 return alreadyHandled.size > 0;
527 function $isTargetWithinDecorator(target: HTMLElement): boolean {
528 const node = $getNearestNodeFromDOMNode(target);
529 return $isDecoratorNode(node);
532 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
533 const focus = selection.focus;
534 return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
537 export function registerRichText(editor: LexicalEditor): () => void {
538 const removeListener = mergeRegister(
539 editor.registerCommand(
542 const selection = $getSelection();
543 if ($isNodeSelection(selection)) {
551 editor.registerCommand<boolean>(
552 DELETE_CHARACTER_COMMAND,
554 const selection = $getSelection();
555 if (!$isRangeSelection(selection)) {
558 selection.deleteCharacter(isBackward);
561 COMMAND_PRIORITY_EDITOR,
563 editor.registerCommand<boolean>(
566 const selection = $getSelection();
567 if (!$isRangeSelection(selection)) {
570 selection.deleteWord(isBackward);
573 COMMAND_PRIORITY_EDITOR,
575 editor.registerCommand<boolean>(
578 const selection = $getSelection();
579 if (!$isRangeSelection(selection)) {
582 selection.deleteLine(isBackward);
585 COMMAND_PRIORITY_EDITOR,
587 editor.registerCommand(
588 CONTROLLED_TEXT_INSERTION_COMMAND,
590 const selection = $getSelection();
592 if (typeof eventOrText === 'string') {
593 if (selection !== null) {
594 selection.insertText(eventOrText);
597 if (selection === null) {
601 const dataTransfer = eventOrText.dataTransfer;
602 if (dataTransfer != null) {
603 $insertDataTransferForRichText(dataTransfer, selection, editor);
604 } else if ($isRangeSelection(selection)) {
605 const data = eventOrText.data;
607 selection.insertText(data);
614 COMMAND_PRIORITY_EDITOR,
616 editor.registerCommand(
619 const selection = $getSelection();
620 if (!$isRangeSelection(selection)) {
623 selection.removeText();
626 COMMAND_PRIORITY_EDITOR,
628 editor.registerCommand<TextFormatType>(
631 const selection = $getSelection();
632 if (!$isRangeSelection(selection)) {
635 selection.formatText(format);
638 COMMAND_PRIORITY_EDITOR,
640 editor.registerCommand<ElementFormatType>(
641 FORMAT_ELEMENT_COMMAND,
643 const selection = $getSelection();
644 if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
647 const nodes = selection.getNodes();
648 for (const node of nodes) {
649 const element = $findMatchingParent(
651 (parentNode): parentNode is ElementNode =>
652 $isElementNode(parentNode) && !parentNode.isInline(),
654 if (element !== null) {
655 element.setFormat(format);
660 COMMAND_PRIORITY_EDITOR,
662 editor.registerCommand<boolean>(
663 INSERT_LINE_BREAK_COMMAND,
665 const selection = $getSelection();
666 if (!$isRangeSelection(selection)) {
669 selection.insertLineBreak(selectStart);
672 COMMAND_PRIORITY_EDITOR,
674 editor.registerCommand(
675 INSERT_PARAGRAPH_COMMAND,
677 const selection = $getSelection();
678 if (!$isRangeSelection(selection)) {
681 selection.insertParagraph();
684 COMMAND_PRIORITY_EDITOR,
686 editor.registerCommand(
689 $insertNodes([$createTabNode()]);
692 COMMAND_PRIORITY_EDITOR,
694 editor.registerCommand(
695 INDENT_CONTENT_COMMAND,
697 return $handleIndentAndOutdent((block) => {
698 const indent = block.getIndent();
699 block.setIndent(indent + 1);
702 COMMAND_PRIORITY_EDITOR,
704 editor.registerCommand(
705 OUTDENT_CONTENT_COMMAND,
707 return $handleIndentAndOutdent((block) => {
708 const indent = block.getIndent();
710 block.setIndent(indent - 1);
714 COMMAND_PRIORITY_EDITOR,
716 editor.registerCommand<KeyboardEvent>(
717 KEY_ARROW_UP_COMMAND,
719 const selection = $getSelection();
721 $isNodeSelection(selection) &&
722 !$isTargetWithinDecorator(event.target as HTMLElement)
724 // If selection is on a node, let's try and move selection
725 // back to being a range selection.
726 const nodes = selection.getNodes();
727 if (nodes.length > 0) {
728 nodes[0].selectPrevious();
731 } else if ($isRangeSelection(selection)) {
732 const possibleNode = $getAdjacentNode(selection.focus, true);
735 $isDecoratorNode(possibleNode) &&
736 !possibleNode.isIsolated() &&
737 !possibleNode.isInline()
739 possibleNode.selectPrevious();
740 event.preventDefault();
746 COMMAND_PRIORITY_EDITOR,
748 editor.registerCommand<KeyboardEvent>(
749 KEY_ARROW_DOWN_COMMAND,
751 const selection = $getSelection();
752 if ($isNodeSelection(selection)) {
753 // If selection is on a node, let's try and move selection
754 // back to being a range selection.
755 const nodes = selection.getNodes();
756 if (nodes.length > 0) {
757 nodes[0].selectNext(0, 0);
760 } else if ($isRangeSelection(selection)) {
761 if ($isSelectionAtEndOfRoot(selection)) {
762 event.preventDefault();
765 const possibleNode = $getAdjacentNode(selection.focus, false);
768 $isDecoratorNode(possibleNode) &&
769 !possibleNode.isIsolated() &&
770 !possibleNode.isInline()
772 possibleNode.selectNext();
773 event.preventDefault();
779 COMMAND_PRIORITY_EDITOR,
781 editor.registerCommand<KeyboardEvent>(
782 KEY_ARROW_LEFT_COMMAND,
784 const selection = $getSelection();
785 if ($isNodeSelection(selection)) {
786 // If selection is on a node, let's try and move selection
787 // back to being a range selection.
788 const nodes = selection.getNodes();
789 if (nodes.length > 0) {
790 event.preventDefault();
791 nodes[0].selectPrevious();
795 if (!$isRangeSelection(selection)) {
798 if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
799 const isHoldingShift = event.shiftKey;
800 event.preventDefault();
801 $moveCharacter(selection, isHoldingShift, true);
806 COMMAND_PRIORITY_EDITOR,
808 editor.registerCommand<KeyboardEvent>(
809 KEY_ARROW_RIGHT_COMMAND,
811 const selection = $getSelection();
813 $isNodeSelection(selection) &&
814 !$isTargetWithinDecorator(event.target as HTMLElement)
816 // If selection is on a node, let's try and move selection
817 // back to being a range selection.
818 const nodes = selection.getNodes();
819 if (nodes.length > 0) {
820 event.preventDefault();
821 nodes[0].selectNext(0, 0);
825 if (!$isRangeSelection(selection)) {
828 const isHoldingShift = event.shiftKey;
829 if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
830 event.preventDefault();
831 $moveCharacter(selection, isHoldingShift, false);
836 COMMAND_PRIORITY_EDITOR,
838 editor.registerCommand<KeyboardEvent>(
839 KEY_BACKSPACE_COMMAND,
841 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
844 const selection = $getSelection();
845 if (!$isRangeSelection(selection)) {
848 event.preventDefault();
849 const {anchor} = selection;
850 const anchorNode = anchor.getNode();
853 selection.isCollapsed() &&
854 anchor.offset === 0 &&
855 !$isRootNode(anchorNode)
857 const element = $getNearestBlockElementAncestorOrThrow(anchorNode);
858 if (element.getIndent() > 0) {
859 return editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
862 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
864 COMMAND_PRIORITY_EDITOR,
866 editor.registerCommand<KeyboardEvent>(
869 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
872 const selection = $getSelection();
873 if (!$isRangeSelection(selection)) {
876 event.preventDefault();
877 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
879 COMMAND_PRIORITY_EDITOR,
881 editor.registerCommand<KeyboardEvent | null>(
884 const selection = $getSelection();
885 if (!$isRangeSelection(selection)) {
888 if (event !== null) {
889 // If we have beforeinput, then we can avoid blocking
890 // the default behavior. This ensures that the iOS can
891 // intercept that we're actually inserting a paragraph,
892 // and autocomplete, autocapitalize etc work as intended.
893 // This can also cause a strange performance issue in
894 // Safari, where there is a noticeable pause due to
895 // preventing the key down of enter.
897 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
902 event.preventDefault();
903 if (event.shiftKey) {
904 return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
907 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
909 COMMAND_PRIORITY_EDITOR,
911 editor.registerCommand(
914 const selection = $getSelection();
915 if (!$isRangeSelection(selection)) {
921 COMMAND_PRIORITY_EDITOR,
923 editor.registerCommand<DragEvent>(
926 const [, files] = eventFiles(event);
927 if (files.length > 0) {
928 const x = event.clientX;
929 const y = event.clientY;
930 const eventRange = caretFromPoint(x, y);
931 if (eventRange !== null) {
932 const {offset: domOffset, node: domNode} = eventRange;
933 const node = $getNearestNodeFromDOMNode(domNode);
935 const selection = $createRangeSelection();
936 if ($isTextNode(node)) {
937 selection.anchor.set(node.getKey(), domOffset, 'text');
938 selection.focus.set(node.getKey(), domOffset, 'text');
940 const parentKey = node.getParentOrThrow().getKey();
941 const offset = node.getIndexWithinParent() + 1;
942 selection.anchor.set(parentKey, offset, 'element');
943 selection.focus.set(parentKey, offset, 'element');
945 const normalizedSelection =
946 $normalizeSelection__EXPERIMENTAL(selection);
947 $setSelection(normalizedSelection);
949 editor.dispatchCommand(DRAG_DROP_PASTE, files);
951 event.preventDefault();
955 const selection = $getSelection();
956 if ($isRangeSelection(selection)) {
962 COMMAND_PRIORITY_EDITOR,
964 editor.registerCommand<DragEvent>(
967 const [isFileTransfer] = eventFiles(event);
968 const selection = $getSelection();
969 if (isFileTransfer && !$isRangeSelection(selection)) {
974 COMMAND_PRIORITY_EDITOR,
976 editor.registerCommand<DragEvent>(
979 const [isFileTransfer] = eventFiles(event);
980 const selection = $getSelection();
981 if (isFileTransfer && !$isRangeSelection(selection)) {
984 const x = event.clientX;
985 const y = event.clientY;
986 const eventRange = caretFromPoint(x, y);
987 if (eventRange !== null) {
988 const node = $getNearestNodeFromDOMNode(eventRange.node);
989 if ($isDecoratorNode(node)) {
990 // Show browser caret as the user is dragging the media across the screen. Won't work
991 // for DecoratorNode nor it's relevant.
992 event.preventDefault();
997 COMMAND_PRIORITY_EDITOR,
999 editor.registerCommand(
1006 COMMAND_PRIORITY_EDITOR,
1008 editor.registerCommand(
1013 objectKlassEquals(event, ClipboardEvent)
1014 ? (event as ClipboardEvent)
1019 COMMAND_PRIORITY_EDITOR,
1021 editor.registerCommand(
1024 onCutForRichText(event, editor);
1027 COMMAND_PRIORITY_EDITOR,
1029 editor.registerCommand(
1032 const [, files, hasTextContent] = eventFiles(event);
1033 if (files.length > 0 && !hasTextContent) {
1034 editor.dispatchCommand(DRAG_DROP_PASTE, files);
1038 // if inputs then paste within the input ignore creating a new node on paste event
1039 if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
1043 const selection = $getSelection();
1044 if (selection !== null) {
1045 onPasteForRichText(event, editor);
1051 COMMAND_PRIORITY_EDITOR,
1054 return removeListener;