@lexical/plain-text
Version:
This package contains plain text helpers for Lexical.
464 lines (413 loc) • 12.2 kB
text/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 {
$getHtmlContent,
$handlePlainTextDrop,
$insertDataTransferForPlainText,
$writeDragSourceToDataTransfer,
} from '@lexical/clipboard';
import {DragonExtension} from '@lexical/dragon';
import {
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
} from '@lexical/extension';
import {
$moveCharacter,
$shouldOverrideDefaultCharacterSelection,
} from '@lexical/selection';
import {objectKlassEquals} from '@lexical/utils';
import {
$getSelection,
$getSlotFrame,
$isRangeSelection,
$selectAll,
CAN_USE_BEFORE_INPUT,
COMMAND_PRIORITY_EDITOR,
type CommandPayloadType,
CONTROLLED_TEXT_INSERTION_COMMAND,
COPY_COMMAND,
CUT_COMMAND,
CUT_TAG,
defineExtension,
DELETE_CHARACTER_COMMAND,
DELETE_LINE_COMMAND,
DELETE_WORD_COMMAND,
DRAGSTART_COMMAND,
DROP_COMMAND,
INSERT_LINE_BREAK_COMMAND,
INSERT_PARAGRAPH_COMMAND,
IS_APPLE_WEBKIT,
IS_IOS,
IS_SAFARI,
KEY_ARROW_LEFT_COMMAND,
KEY_ARROW_RIGHT_COMMAND,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
KEY_ENTER_COMMAND,
type LexicalEditor,
mergeRegister,
PASTE_COMMAND,
PASTE_TAG,
REMOVE_TEXT_COMMAND,
SELECT_ALL_COMMAND,
} from 'lexical';
function onCopyForPlainText(
event: CommandPayloadType<typeof COPY_COMMAND>,
editor: LexicalEditor,
): void {
editor.update(() => {
if (event !== null) {
const clipboardData = objectKlassEquals(event, KeyboardEvent)
? null
: event.clipboardData;
const selection = $getSelection();
if (
selection !== null &&
!selection.isCollapsed() &&
clipboardData != null
) {
event.preventDefault();
const htmlString = $getHtmlContent(editor);
if (htmlString !== null) {
clipboardData.setData('text/html', htmlString);
}
clipboardData.setData('text/plain', selection.getTextContent());
}
}
});
}
function onPasteForPlainText(
event: CommandPayloadType<typeof PASTE_COMMAND>,
editor: LexicalEditor,
): void {
event.preventDefault();
editor.update(
() => {
const selection = $getSelection();
const clipboardData = objectKlassEquals(event, ClipboardEvent)
? event.clipboardData
: null;
if (clipboardData != null && $isRangeSelection(selection)) {
$insertDataTransferForPlainText(clipboardData, selection);
}
},
{
// PASTE_TAG gives the paste its own undo entry: @lexical/history treats
// the tag as a history boundary so undoing a paste does not also undo any
// typing that preceded it (see #8609).
tag: PASTE_TAG,
},
);
}
function onCutForPlainText(
event: CommandPayloadType<typeof CUT_COMMAND>,
editor: LexicalEditor,
): void {
onCopyForPlainText(event, editor);
editor.update(
() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
selection.removeText();
}
},
{
// CUT_TAG gives the cut its own undo entry: @lexical/history treats the
// tag as a history boundary so undoing a cut does not also undo any typing
// that preceded it (see #8609).
tag: CUT_TAG,
},
);
}
export function registerPlainText(editor: LexicalEditor): () => void {
const removeListener = mergeRegister(
editor.registerCommand(
DELETE_CHARACTER_COMMAND,
isBackward => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.deleteCharacter(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DELETE_WORD_COMMAND,
isBackward => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.deleteWord(isBackward);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
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 (!$isRangeSelection(selection)) {
return false;
}
if (typeof eventOrText === 'string') {
selection.insertText(eventOrText);
} else {
const dataTransfer = eventOrText.dataTransfer;
if (dataTransfer != null) {
$insertDataTransferForPlainText(dataTransfer, selection);
} else {
const data = eventOrText.data;
if (data) {
selection.insertText(data);
}
}
}
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(
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.insertLineBreak();
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
KEY_ARROW_LEFT_COMMAND,
payload => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const event = payload;
const isHoldingShift = event.shiftKey;
if ($shouldOverrideDefaultCharacterSelection(selection, true)) {
event.preventDefault();
$moveCharacter(selection, isHoldingShift, true);
return true;
}
return false;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
KEY_ARROW_RIGHT_COMMAND,
payload => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
const event = payload;
const isHoldingShift = event.shiftKey;
if ($shouldOverrideDefaultCharacterSelection(selection, false)) {
event.preventDefault();
$moveCharacter(selection, isHoldingShift, false);
return true;
}
return false;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// On iOS, blocking the keydown event's default prevents the system
// keyboard from updating its autocomplete/autocorrect suggestion bar
// after Backspace. Returning false here skips event.preventDefault()
// on keydown; the beforeinput deleteContentBackward handler still runs
// and performs the deletion, so editing behavior is unchanged.
// See https://github.com/facebook/lexical/issues/5841
if (IS_IOS && CAN_USE_BEFORE_INPUT) {
return false;
}
event.preventDefault();
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, true);
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
KEY_DELETE_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
event.preventDefault();
return editor.dispatchCommand(DELETE_CHARACTER_COMMAND, false);
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
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();
}
return editor.dispatchCommand(INSERT_LINE_BREAK_COMMAND, false);
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
SELECT_ALL_COMMAND,
() => {
// Scope SELECT_ALL only when the caret is inside a named-slot frame:
// slots are shadow-root isolated, so a whole-document select-all
// would escape the slot and let a single keystroke replace the host.
// Every other context (including TableCell shadow roots) keeps the
// legacy whole-document behavior; block/document scoping elsewhere
// is provided by the opt-in SelectBlockExtension.
const selection = $getSelection();
$selectAll(
$isRangeSelection(selection) &&
$getSlotFrame(selection.anchor.getNode()) !== null
? selection
: null,
);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
COPY_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
onCopyForPlainText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
CUT_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
onCutForPlainText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
PASTE_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
onPasteForPlainText(event, editor);
return true;
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DROP_COMMAND,
event => $handlePlainTextDrop(event, editor),
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
DRAGSTART_COMMAND,
event => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
// Mark the drag source so a drop in a different editor can remove
// the source range to produce cut-and-paste semantics.
if (!selection.isCollapsed() && event.dataTransfer !== null) {
$writeDragSourceToDataTransfer(event.dataTransfer, editor);
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
);
return removeListener;
}
/**
* An extension to register \@lexical/plain-text behavior
*/
export const PlainTextExtension = /* @__PURE__ */ defineExtension({
conflictsWith: ['@lexical/rich-text'],
dependencies: [
DragonExtension,
NormalizeInlineElementsExtension,
NormalizeTripleClickSelectionExtension,
],
name: '@lexical/plain-text',
register: registerPlainText,
});