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.
18 $createRangeSelection,
21 $getNearestNodeFromDOMNode,
30 $normalizeSelection__EXPERIMENTAL,
34 COMMAND_PRIORITY_EDITOR,
35 CONTROLLED_TEXT_INSERTION_COMMAND,
39 DELETE_CHARACTER_COMMAND,
47 INSERT_LINE_BREAK_COMMAND,
48 INSERT_PARAGRAPH_COMMAND,
50 isSelectionCapturedInDecoratorInput,
51 KEY_ARROW_DOWN_COMMAND,
52 KEY_ARROW_LEFT_COMMAND,
53 KEY_ARROW_RIGHT_COMMAND,
55 KEY_BACKSPACE_COMMAND,
64 import {$insertDataTransferForRichText, copyToClipboard,} from '@lexical/clipboard';
65 import {$moveCharacter, $shouldOverrideDefaultCharacterSelection,} from '@lexical/selection';
66 import {$findMatchingParent, mergeRegister, objectKlassEquals,} from '@lexical/utils';
67 import caretFromPoint from 'lexical/shared/caretFromPoint';
68 import {CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI,} from 'lexical/shared/environment';
70 export const DRAG_DROP_PASTE: LexicalCommand<Array<File>> = createCommand(
71 'DRAG_DROP_PASTE_FILE',
76 function onPasteForRichText(
77 event: CommandPayloadType<typeof PASTE_COMMAND>,
78 editor: LexicalEditor,
80 event.preventDefault();
83 const selection = $getSelection();
85 objectKlassEquals(event, InputEvent) ||
86 objectKlassEquals(event, KeyboardEvent)
88 : (event as ClipboardEvent).clipboardData;
89 if (clipboardData != null && selection !== null) {
90 $insertDataTransferForRichText(clipboardData, selection, editor);
99 async function onCutForRichText(
100 event: CommandPayloadType<typeof CUT_COMMAND>,
101 editor: LexicalEditor,
103 await copyToClipboard(
105 objectKlassEquals(event, ClipboardEvent) ? (event as ClipboardEvent) : null,
107 editor.update(() => {
108 const selection = $getSelection();
109 if ($isRangeSelection(selection)) {
110 selection.removeText();
111 } else if ($isNodeSelection(selection)) {
112 selection.getNodes().forEach((node) => node.remove());
117 // Clipboard may contain files that we aren't allowed to read. While the event is arguably useless,
118 // in certain occasions, we want to know whether it was a file transfer, as opposed to text. We
119 // control this with the first boolean flag.
120 export function eventFiles(
121 event: DragEvent | PasteCommandType,
122 ): [boolean, Array<File>, boolean] {
123 let dataTransfer: null | DataTransfer = null;
124 if (objectKlassEquals(event, DragEvent)) {
125 dataTransfer = (event as DragEvent).dataTransfer;
126 } else if (objectKlassEquals(event, ClipboardEvent)) {
127 dataTransfer = (event as ClipboardEvent).clipboardData;
130 if (dataTransfer === null) {
131 return [false, [], false];
134 const types = dataTransfer.types;
135 const hasFiles = types.includes('Files');
137 types.includes('text/html') || types.includes('text/plain');
138 return [hasFiles, Array.from(dataTransfer.files), hasContent];
141 function $handleIndentAndOutdent(
142 indentOrOutdent: (block: ElementNode) => void,
144 const selection = $getSelection();
145 if (!$isRangeSelection(selection)) {
148 const alreadyHandled = new Set();
149 const nodes = selection.getNodes();
150 for (let i = 0; i < nodes.length; i++) {
151 const node = nodes[i];
152 const key = node.getKey();
153 if (alreadyHandled.has(key)) {
156 const parentBlock = $findMatchingParent(
158 (parentNode): parentNode is ElementNode =>
159 $isElementNode(parentNode) && !parentNode.isInline(),
161 if (parentBlock === null) {
164 const parentKey = parentBlock.getKey();
165 if (parentBlock.canIndent() && !alreadyHandled.has(parentKey)) {
166 alreadyHandled.add(parentKey);
167 indentOrOutdent(parentBlock);
170 return alreadyHandled.size > 0;
173 function $isTargetWithinDecorator(target: HTMLElement): boolean {
174 const node = $getNearestNodeFromDOMNode(target);
175 return $isDecoratorNode(node);
178 function $isSelectionAtEndOfRoot(selection: RangeSelection) {
179 const focus = selection.focus;
180 return focus.key === 'root' && focus.offset === $getRoot().getChildrenSize();
183 export function registerRichText(editor: LexicalEditor): () => void {
184 const removeListener = mergeRegister(
185 editor.registerCommand(
188 const selection = $getSelection();
189 if ($isNodeSelection(selection)) {
197 editor.registerCommand<boolean>(
198 DELETE_CHARACTER_COMMAND,
200 const selection = $getSelection();
201 if (!$isRangeSelection(selection)) {
204 selection.deleteCharacter(isBackward);
207 COMMAND_PRIORITY_EDITOR,
209 editor.registerCommand<boolean>(
212 const selection = $getSelection();
213 if (!$isRangeSelection(selection)) {
216 selection.deleteWord(isBackward);
219 COMMAND_PRIORITY_EDITOR,
221 editor.registerCommand<boolean>(
224 const selection = $getSelection();
225 if (!$isRangeSelection(selection)) {
228 selection.deleteLine(isBackward);
231 COMMAND_PRIORITY_EDITOR,
233 editor.registerCommand(
234 CONTROLLED_TEXT_INSERTION_COMMAND,
236 const selection = $getSelection();
238 if (typeof eventOrText === 'string') {
239 if (selection !== null) {
240 selection.insertText(eventOrText);
243 if (selection === null) {
247 const dataTransfer = eventOrText.dataTransfer;
248 if (dataTransfer != null) {
249 $insertDataTransferForRichText(dataTransfer, selection, editor);
250 } else if ($isRangeSelection(selection)) {
251 const data = eventOrText.data;
253 selection.insertText(data);
260 COMMAND_PRIORITY_EDITOR,
262 editor.registerCommand(
265 const selection = $getSelection();
266 if (!$isRangeSelection(selection)) {
269 selection.removeText();
272 COMMAND_PRIORITY_EDITOR,
274 editor.registerCommand<TextFormatType>(
277 const selection = $getSelection();
278 if (!$isRangeSelection(selection)) {
281 selection.formatText(format);
284 COMMAND_PRIORITY_EDITOR,
286 editor.registerCommand<boolean>(
287 INSERT_LINE_BREAK_COMMAND,
289 const selection = $getSelection();
290 if (!$isRangeSelection(selection)) {
293 selection.insertLineBreak(selectStart);
296 COMMAND_PRIORITY_EDITOR,
298 editor.registerCommand(
299 INSERT_PARAGRAPH_COMMAND,
301 const selection = $getSelection();
302 if (!$isRangeSelection(selection)) {
305 selection.insertParagraph();
308 COMMAND_PRIORITY_EDITOR,
310 editor.registerCommand(
313 $insertNodes([$createTabNode()]);
316 COMMAND_PRIORITY_EDITOR,
318 editor.registerCommand<KeyboardEvent>(
319 KEY_ARROW_UP_COMMAND,
321 const selection = $getSelection();
323 $isNodeSelection(selection) &&
324 !$isTargetWithinDecorator(event.target as HTMLElement)
326 // If selection is on a node, let's try and move selection
327 // back to being a range selection.
328 const nodes = selection.getNodes();
329 if (nodes.length > 0) {
330 nodes[0].selectPrevious();
333 } else if ($isRangeSelection(selection)) {
334 const possibleNode = $getAdjacentNode(selection.focus, true);
337 $isDecoratorNode(possibleNode) &&
338 !possibleNode.isIsolated() &&
339 !possibleNode.isInline()
341 possibleNode.selectPrevious();
342 event.preventDefault();
348 COMMAND_PRIORITY_EDITOR,
350 editor.registerCommand<KeyboardEvent>(
351 KEY_ARROW_DOWN_COMMAND,
353 const selection = $getSelection();
354 if ($isNodeSelection(selection)) {
355 // If selection is on a node, let's try and move selection
356 // back to being a range selection.
357 const nodes = selection.getNodes();
358 if (nodes.length > 0) {
359 nodes[0].selectNext(0, 0);
362 } else if ($isRangeSelection(selection)) {
363 if ($isSelectionAtEndOfRoot(selection)) {
364 event.preventDefault();
367 const possibleNode = $getAdjacentNode(selection.focus, false);
370 $isDecoratorNode(possibleNode) &&
371 !possibleNode.isIsolated() &&
372 !possibleNode.isInline()
374 possibleNode.selectNext();
375 event.preventDefault();
381 COMMAND_PRIORITY_EDITOR,
383 editor.registerCommand<KeyboardEvent>(
384 KEY_ARROW_LEFT_COMMAND,
386 const selection = $getSelection();
387 if ($isNodeSelection(selection)) {
388 // If selection is on a node, let's try and move selection
389 // back to being a range selection.
390 const nodes = selection.getNodes();
391 if (nodes.length > 0) {
392 event.preventDefault();
393 nodes[0].selectPrevious();
397 if (!$isRangeSelection(selection)) {
400 if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
401 const isHoldingShift = event.shiftKey;
402 event.preventDefault();
403 $moveCharacter(selection, isHoldingShift, true);
408 COMMAND_PRIORITY_EDITOR,
410 editor.registerCommand<KeyboardEvent>(
411 KEY_ARROW_RIGHT_COMMAND,
413 const selection = $getSelection();
415 $isNodeSelection(selection) &&
416 !$isTargetWithinDecorator(event.target as HTMLElement)
418 // If selection is on a node, let's try and move selection
419 // back to being a range selection.
420 const nodes = selection.getNodes();
421 if (nodes.length > 0) {
422 event.preventDefault();
423 nodes[0].selectNext(0, 0);
427 if (!$isRangeSelection(selection)) {
430 const isHoldingShift = event.shiftKey;
431 if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
432 event.preventDefault();
433 $moveCharacter(selection, isHoldingShift, false);
438 COMMAND_PRIORITY_EDITOR,
440 editor.registerCommand<KeyboardEvent>(
441 KEY_BACKSPACE_COMMAND,
443 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
446 const selection = $getSelection();
447 if (!$isRangeSelection(selection)) {
450 event.preventDefault();
452 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
454 COMMAND_PRIORITY_EDITOR,
456 editor.registerCommand<KeyboardEvent>(
459 if ($isTargetWithinDecorator(event.target as HTMLElement)) {
462 const selection = $getSelection();
463 if (!$isRangeSelection(selection)) {
466 event.preventDefault();
467 return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
469 COMMAND_PRIORITY_EDITOR,
471 editor.registerCommand<KeyboardEvent | null>(
474 const selection = $getSelection();
475 if (!$isRangeSelection(selection)) {
478 if (event !== null) {
479 // If we have beforeinput, then we can avoid blocking
480 // the default behavior. This ensures that the iOS can
481 // intercept that we're actually inserting a paragraph,
482 // and autocomplete, autocapitalize etc work as intended.
483 // This can also cause a strange performance issue in
484 // Safari, where there is a noticeable pause due to
485 // preventing the key down of enter.
487 (IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
492 event.preventDefault();
493 if (event.shiftKey) {
494 return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
497 return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
499 COMMAND_PRIORITY_EDITOR,
501 editor.registerCommand(
504 const selection = $getSelection();
505 if (!$isRangeSelection(selection)) {
511 COMMAND_PRIORITY_EDITOR,
513 editor.registerCommand<DragEvent>(
516 const [, files] = eventFiles(event);
517 if (files.length > 0) {
518 const x = event.clientX;
519 const y = event.clientY;
520 const eventRange = caretFromPoint(x, y);
521 if (eventRange !== null) {
522 const {offset: domOffset, node: domNode} = eventRange;
523 const node = $getNearestNodeFromDOMNode(domNode);
525 const selection = $createRangeSelection();
526 if ($isTextNode(node)) {
527 selection.anchor.set(node.getKey(), domOffset, 'text');
528 selection.focus.set(node.getKey(), domOffset, 'text');
530 const parentKey = node.getParentOrThrow().getKey();
531 const offset = node.getIndexWithinParent() + 1;
532 selection.anchor.set(parentKey, offset, 'element');
533 selection.focus.set(parentKey, offset, 'element');
535 const normalizedSelection =
536 $normalizeSelection__EXPERIMENTAL(selection);
537 $setSelection(normalizedSelection);
539 editor.dispatchCommand(DRAG_DROP_PASTE, files);
541 event.preventDefault();
545 const selection = $getSelection();
546 if ($isRangeSelection(selection)) {
552 COMMAND_PRIORITY_EDITOR,
554 editor.registerCommand<DragEvent>(
557 const [isFileTransfer] = eventFiles(event);
558 const selection = $getSelection();
559 if (isFileTransfer && !$isRangeSelection(selection)) {
564 COMMAND_PRIORITY_EDITOR,
566 editor.registerCommand<DragEvent>(
569 const [isFileTransfer] = eventFiles(event);
570 const selection = $getSelection();
571 if (isFileTransfer && !$isRangeSelection(selection)) {
574 const x = event.clientX;
575 const y = event.clientY;
576 const eventRange = caretFromPoint(x, y);
577 if (eventRange !== null) {
578 const node = $getNearestNodeFromDOMNode(eventRange.node);
579 if ($isDecoratorNode(node)) {
580 // Show browser caret as the user is dragging the media across the screen. Won't work
581 // for DecoratorNode nor it's relevant.
582 event.preventDefault();
587 COMMAND_PRIORITY_EDITOR,
589 editor.registerCommand(
596 COMMAND_PRIORITY_EDITOR,
598 editor.registerCommand(
603 objectKlassEquals(event, ClipboardEvent)
604 ? (event as ClipboardEvent)
609 COMMAND_PRIORITY_EDITOR,
611 editor.registerCommand(
614 onCutForRichText(event, editor);
617 COMMAND_PRIORITY_EDITOR,
619 editor.registerCommand(
622 const [, files, hasTextContent] = eventFiles(event);
623 if (files.length > 0 && !hasTextContent) {
624 editor.dispatchCommand(DRAG_DROP_PASTE, files);
628 // if inputs then paste within the input ignore creating a new node on paste event
629 if (isSelectionCapturedInDecoratorInput(event.target as Node)) {
633 const selection = $getSelection();
634 if (selection !== null) {
635 onPasteForRichText(event, editor);
641 COMMAND_PRIORITY_EDITOR,
644 return removeListener;