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.
19 $createRangeSelection,
22 $getNearestNodeFromDOMNode,
31 $normalizeSelection__EXPERIMENTAL,
35 COMMAND_PRIORITY_EDITOR,
36 CONTROLLED_TEXT_INSERTION_COMMAND,
40 DELETE_CHARACTER_COMMAND,
47 FORMAT_ELEMENT_COMMAND,
49 INSERT_LINE_BREAK_COMMAND,
50 INSERT_PARAGRAPH_COMMAND,
52 isSelectionCapturedInDecoratorInput,
53 KEY_ARROW_DOWN_COMMAND,
54 KEY_ARROW_LEFT_COMMAND,
55 KEY_ARROW_RIGHT_COMMAND,
57 KEY_BACKSPACE_COMMAND,
66 import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard';
67 import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection';
68 import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils';
69 import caretFromPoint from 'lexical/shared/caretFromPoint';
70 import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment';
72 export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
73 'DRAG_DROP_PASTE_FILE',
78 function onPasteForRichText(
79 event: CommandPayloadType<typeof PASTE_COMMAND>,
80 editor: LexicalEditor,
82 event.preventDefault();
85 const selection = $getSelection();
87 objectKlassEquals(event, InputEvent) ||
88 objectKlassEquals(event, KeyboardEvent)
90 : (event as ClipboardEvent).clipboardData;
91 if (clipboardData != null && selection !== null) {
92 $insertDataTransferForRichText(clipboardData, selection, editor);
101 async function onCutForRichText(
102 event: CommandPayloadType<typeof CUT_COMMAND>,
103 editor: LexicalEditor,
105 await copyToClipboard(
107 objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
109 editor.update(() => {
110 const selection = $getSelection();
111 if ($isRangeSelection(selection)) {
112 selection.removeText();
113 } else if ($isNodeSelection(selection)) {
114 selection.getNodes().forEach((node) => node.remove());
119 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
120 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
121 // control this with the first boolean flag.
122 export function eventFiles(
123 event: DragEvent | PasteCommandType,
124 ): [boolean, Array<File>, boolean] {
125 let dataTransfer: null | DataTransfer = null;
126 if (objectKlassEquals(event, DragEvent)) {
127 dataTransfer = (event as DragEvent).dataTransfer;
128 } else if (objectKlassEquals(event, ClipboardEvent)) {
129 dataTransfer = (event as ClipboardEvent).clipboardData;
132 if (dataTransfer === null) {
133 return [false, [], false];
136 const types = dataTransfer.types;
137 const hasFiles = types.includes('Files');
139 types.includes('text/html') || types.includes('text/plain');
140 return [hasFiles, Array.from(dataTransfer.files), hasContent];
143 function $handleIndentAndOutdent(
144 indentOrOutdent: (block: ElementNode) => void,
146 const selection = $getSelection();
147 if (!$isRangeSelection(selection)) {
150 const alreadyHandled = new Set();
151 const nodes = selection.getNodes();
152 for (let i = 0; i < nodes.length; i++) {
153 const node = nodes[i];
154 const key = node.getKey();
155 if (alreadyHandled.has(key)) {
158 const parentBlock = $findMatchingParent(
160 (parentNode): parentNode is ElementNode =>
161 $isElementNode(parentNode) && !parentNode.isInline(),
163 if (parentBlock === null) {
166 const parentKey = parentBlock.getKey();
167 if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
168 alreadyHandled.add(parentKey);
169 indentOrOutdent(parentBlock);
172 return alreadyHandled.size > 0;
175 function $isTargetWithinDecorator(target: HTMLElement): boolean {
176 const node = $getNearestNodeFromDOMNode(target);
177 return $isDecoratorNode(node);
180 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
181 const focus = selection.focus;
182 return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
185 export function registerRichText(editor: LexicalEditor): () => void {
186 const removeListener = mergeRegister(
187 editor.registerCommand(
190 const selection = $getSelection();
191 if ($isNodeSelection(selection)) {
199 editor.registerCommand<boolean>(
200 DELETE_CHARACTER_COMMAND,
202 const selection = $getSelection();
203 if (!$isRangeSelection(selection)) {
206 selection.deleteCharacter(isBackward);
209 COMMAND_PRIORITY_EDITOR,
211 editor.registerCommand<boolean>(
214 const selection = $getSelection();
215 if (!$isRangeSelection(selection)) {
218 selection.deleteWord(isBackward);
221 COMMAND_PRIORITY_EDITOR,
223 editor.registerCommand<boolean>(
226 const selection = $getSelection();
227 if (!$isRangeSelection(selection)) {
230 selection.deleteLine(isBackward);
233 COMMAND_PRIORITY_EDITOR,
235 editor.registerCommand(
236 CONTROLLED_TEXT_INSERTION_COMMAND,
238 const selection = $getSelection();
240 if (typeof eventOrText === 'string') {
241 if (selection !== null) {
242 selection.insertText(eventOrText);
245 if (selection === null) {
249 const dataTransfer = eventOrText.dataTransfer;
250 if (dataTransfer != null) {
251 $insertDataTransferForRichText(dataTransfer, selection, editor);
252 } else if ($isRangeSelection(selection)) {
253 const data = eventOrText.data;
255 selection.insertText(data);
262 COMMAND_PRIORITY_EDITOR,
264 editor.registerCommand(
267 const selection = $getSelection();
268 if (!$isRangeSelection(selection)) {
271 selection.removeText();
274 COMMAND_PRIORITY_EDITOR,
276 editor.registerCommand<TextFormatType>(
279 const selection = $getSelection();
280 if (!$isRangeSelection(selection)) {
283 selection.formatText(format);
286 COMMAND_PRIORITY_EDITOR,
288 editor.registerCommand<ElementFormatType>(
289 FORMAT_ELEMENT_COMMAND,
291 const selection = $getSelection();
292 if (!$isRangeSelection(selection) && !$isNodeSelection(selection)) {
295 const nodes = selection.getNodes();
296 for (const node of nodes) {
297 const element = $findMatchingParent(
299 (parentNode): parentNode is ElementNode =>
300 $isElementNode(parentNode) && !parentNode.isInline(),
305 COMMAND_PRIORITY_EDITOR,
307 editor.registerCommand<boolean>(
308 INSERT_LINE_BREAK_COMMAND,
310 const selection = $getSelection();
311 if (!$isRangeSelection(selection)) {
314 selection.insertLineBreak(selectStart);
317 COMMAND_PRIORITY_EDITOR,
319 editor.registerCommand(
320 INSERT_PARAGRAPH_COMMAND,
322 const selection = $getSelection();
323 if (!$isRangeSelection(selection)) {
326 selection.insertParagraph();
329 COMMAND_PRIORITY_EDITOR,
331 editor.registerCommand(
334 $insertNodes([$createTabNode()]);
337 COMMAND_PRIORITY_EDITOR,
339 editor.registerCommand<KeyboardEvent>(
340 KEY_ARROW_UP_COMMAND,
342 const selection = $getSelection();
344 $isNodeSelection(selection) &&
345 !$isTargetWithinDecorator(event.target as HTMLElement)
347 // If selection is on a node, let's try and move selection
348 // back to being a range selection.
349 const nodes = selection.getNodes();
350 if (nodes.length > 0) {
351 nodes[0].selectPrevious();
354 } else if ($isRangeSelection(selection)) {
355 const possibleNode = $getAdjacentNode(selection.focus, true);
358 $isDecoratorNode(possibleNode) &&
359 !possibleNode.isIsolated() &&
360 !possibleNode.isInline()
362 possibleNode.selectPrevious();
363 event.preventDefault();
369 COMMAND_PRIORITY_EDITOR,
371 editor.registerCommand<KeyboardEvent>(
372 KEY_ARROW_DOWN_COMMAND,
374 const selection = $getSelection();
375 if ($isNodeSelection(selection)) {
376 // If selection is on a node, let's try and move selection
377 // back to being a range selection.
378 const nodes = selection.getNodes();
379 if (nodes.length > 0) {
380 nodes[0].selectNext(0, 0);
383 } else if ($isRangeSelection(selection)) {
384 if ($isSelectionAtEndOfRoot(selection)) {
385 event.preventDefault();
388 const possibleNode = $getAdjacentNode(selection.focus, false);
391 $isDecoratorNode(possibleNode) &&
392 !possibleNode.isIsolated() &&
393 !possibleNode.isInline()
395 possibleNode.selectNext();
396 event.preventDefault();
402 COMMAND_PRIORITY_EDITOR,
404 editor.registerCommand<KeyboardEvent>(
405 KEY_ARROW_LEFT_COMMAND,
407 const selection = $getSelection();
408 if ($isNodeSelection(selection)) {
409 // If selection is on a node, let's try and move selection
410 // back to being a range selection.
411 const nodes = selection.getNodes();
412 if (nodes.length > 0) {
413 event.preventDefault();
414 nodes[0].selectPrevious();
418 if (!$isRangeSelection(selection)) {
421 if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
422 const isHoldingShift = event.shiftKey;
423 event.preventDefault();
424 $moveCharacter(selection, isHoldingShift, true);
429 COMMAND_PRIORITY_EDITOR,
431 editor.registerCommand<KeyboardEvent>(
432 KEY_ARROW_RIGHT_COMMAND,
434 const selection = $getSelection();
436 $isNodeSelection(selection) &&
437 !$isTargetWithinDecorator(event.target as HTMLElement)
439 // If selection is on a node, let's try and move selection
440 // back to being a range selection.
441 const nodes = selection.getNodes();
442 if (nodes.length > 0) {
443 event.preventDefault();
444 nodes[0].selectNext(0, 0);
448 if (!$isRangeSelection(selection)) {
451 const isHoldingShift = event.shiftKey;
452 if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
453 event.preventDefault();
454 $moveCharacter(selection, isHoldingShift, false);
459 COMMAND_PRIORITY_EDITOR,
461 editor.registerCommand<KeyboardEvent>(
462 KEY_BACKSPACE_COMMAND,
464 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
467 const selection = $getSelection();
468 if (!$isRangeSelection(selection)) {
471 event.preventDefault();
473 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
475 COMMAND_PRIORITY_EDITOR,
477 editor.registerCommand<KeyboardEvent>(
480 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
483 const selection = $getSelection();
484 if (!$isRangeSelection(selection)) {
487 event.preventDefault();
488 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
490 COMMAND_PRIORITY_EDITOR,
492 editor.registerCommand<KeyboardEvent | null>(
495 const selection = $getSelection();
496 if (!$isRangeSelection(selection)) {
499 if (event !== null) {
500 // If we have beforeinput, then we can avoid blocking
501 // the default behavior. This ensures that the iOS can
502 // intercept that we're actually inserting a paragraph,
503 // and autocomplete, autocapitalize etc work as intended.
504 // This can also cause a strange performance issue in
505 // Safari, where there is a noticeable pause due to
506 // preventing the key down of enter.
508 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
513 event.preventDefault();
514 if (event.shiftKey) {
515 return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
518 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
520 COMMAND_PRIORITY_EDITOR,
522 editor.registerCommand(
525 const selection = $getSelection();
526 if (!$isRangeSelection(selection)) {
532 COMMAND_PRIORITY_EDITOR,
534 editor.registerCommand<DragEvent>(
537 const [, files] = eventFiles(event);
538 if (files.length > 0) {
539 const x = event.clientX;
540 const y = event.clientY;
541 const eventRange = caretFromPoint(x, y);
542 if (eventRange !== null) {
543 const {offset: domOffset, node: domNode} = eventRange;
544 const node = $getNearestNodeFromDOMNode(domNode);
546 const selection = $createRangeSelection();
547 if ($isTextNode(node)) {
548 selection.anchor.set(node.getKey(), domOffset, 'text');
549 selection.focus.set(node.getKey(), domOffset, 'text');
551 const parentKey = node.getParentOrThrow().getKey();
552 const offset = node.getIndexWithinParent() + 1;
553 selection.anchor.set(parentKey, offset, 'element');
554 selection.focus.set(parentKey, offset, 'element');
556 const normalizedSelection =
557 $normalizeSelection__EXPERIMENTAL(selection);
558 $setSelection(normalizedSelection);
560 editor.dispatchCommand(DRAG_DROP_PASTE, files);
562 event.preventDefault();
566 const selection = $getSelection();
567 if ($isRangeSelection(selection)) {
573 COMMAND_PRIORITY_EDITOR,
575 editor.registerCommand<DragEvent>(
578 const [isFileTransfer] = eventFiles(event);
579 const selection = $getSelection();
580 if (isFileTransfer && !$isRangeSelection(selection)) {
585 COMMAND_PRIORITY_EDITOR,
587 editor.registerCommand<DragEvent>(
590 const [isFileTransfer] = eventFiles(event);
591 const selection = $getSelection();
592 if (isFileTransfer && !$isRangeSelection(selection)) {
595 const x = event.clientX;
596 const y = event.clientY;
597 const eventRange = caretFromPoint(x, y);
598 if (eventRange !== null) {
599 const node = $getNearestNodeFromDOMNode(eventRange.node);
600 if ($isDecoratorNode(node)) {
601 // Show browser caret as the user is dragging the media across the screen. Won't work
602 // for DecoratorNode nor it's relevant.
603 event.preventDefault();
608 COMMAND_PRIORITY_EDITOR,
610 editor.registerCommand(
617 COMMAND_PRIORITY_EDITOR,
619 editor.registerCommand(
624 objectKlassEquals(event, ClipboardEvent)
625 ? (event as ClipboardEvent)
630 COMMAND_PRIORITY_EDITOR,
632 editor.registerCommand(
635 onCutForRichText(event, editor);
638 COMMAND_PRIORITY_EDITOR,
640 editor.registerCommand(
643 const [, files, hasTextContent] = eventFiles(event);
644 if (files.length > 0 && !hasTextContent) {
645 editor.dispatchCommand(DRAG_DROP_PASTE, files);
649 // if inputs then paste within the input ignore creating a new node on paste event
650 if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
654 const selection = $getSelection();
655 if (selection !== null) {
656 onPasteForRichText(event, editor);
662 COMMAND_PRIORITY_EDITOR,
665 return removeListener;