1056 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
			
		
		
	
	
			1056 lines
		
	
	
		
			28 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
| /**
 | |
|  * 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<Array<File>> = 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;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       element,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   static importJSON(serializedNode: SerializedQuoteNode): QuoteNode {
 | |
|     const node = $createQuoteNode();
 | |
|     node.setFormat(serializedNode.format);
 | |
|     node.setIndent(serializedNode.indent);
 | |
|     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 <p> 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;
 | |
|     }
 | |
| 
 | |
|     return {
 | |
|       element,
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   static importJSON(serializedNode: SerializedHeadingNode): HeadingNode {
 | |
|     const node = $createHeadingNode(serializedNode.tag);
 | |
|     node.setFormat(serializedNode.format);
 | |
|     node.setIndent(serializedNode.indent);
 | |
|     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<typeof PASTE_COMMAND>,
 | |
|   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<typeof CUT_COMMAND>,
 | |
|   editor: LexicalEditor,
 | |
| ): Promise<void> {
 | |
|   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<File>, 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<boolean>(
 | |
|       DELETE_CHARACTER_COMMAND,
 | |
|       (isBackward) => {
 | |
|         const selection = $getSelection();
 | |
|         if (!$isRangeSelection(selection)) {
 | |
|           return false;
 | |
|         }
 | |
|         selection.deleteCharacter(isBackward);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand<boolean>(
 | |
|       DELETE_WORD_COMMAND,
 | |
|       (isBackward) => {
 | |
|         const selection = $getSelection();
 | |
|         if (!$isRangeSelection(selection)) {
 | |
|           return false;
 | |
|         }
 | |
|         selection.deleteWord(isBackward);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand<boolean>(
 | |
|       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<TextFormatType>(
 | |
|       FORMAT_TEXT_COMMAND,
 | |
|       (format) => {
 | |
|         const selection = $getSelection();
 | |
|         if (!$isRangeSelection(selection)) {
 | |
|           return false;
 | |
|         }
 | |
|         selection.formatText(format);
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand<ElementFormatType>(
 | |
|       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<boolean>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent>(
 | |
|       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<KeyboardEvent | null>(
 | |
|       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<DragEvent>(
 | |
|       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<DragEvent>(
 | |
|       DRAGSTART_COMMAND,
 | |
|       (event) => {
 | |
|         const [isFileTransfer] = eventFiles(event);
 | |
|         const selection = $getSelection();
 | |
|         if (isFileTransfer && !$isRangeSelection(selection)) {
 | |
|           return false;
 | |
|         }
 | |
|         return true;
 | |
|       },
 | |
|       COMMAND_PRIORITY_EDITOR,
 | |
|     ),
 | |
|     editor.registerCommand<DragEvent>(
 | |
|       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;
 | |
| }
 |