1789 lines
48 KiB
TypeScript
1789 lines
48 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,
|
||
|
EditorConfig,
|
||
|
EditorThemeClasses,
|
||
|
Klass,
|
||
|
LexicalCommand,
|
||
|
MutatedNodes,
|
||
|
MutationListeners,
|
||
|
NodeMutation,
|
||
|
RegisteredNode,
|
||
|
RegisteredNodes,
|
||
|
Spread,
|
||
|
} from './LexicalEditor';
|
||
|
import type {EditorState} from './LexicalEditorState';
|
||
|
import type {LexicalNode, NodeKey, NodeMap} from './LexicalNode';
|
||
|
import type {
|
||
|
BaseSelection,
|
||
|
PointType,
|
||
|
RangeSelection,
|
||
|
} from './LexicalSelection';
|
||
|
import type {RootNode} from './nodes/LexicalRootNode';
|
||
|
import type {TextFormatType, TextNode} from './nodes/LexicalTextNode';
|
||
|
|
||
|
import {CAN_USE_DOM} from 'lexical/shared/canUseDOM';
|
||
|
import {IS_APPLE, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI} from 'lexical/shared/environment';
|
||
|
import invariant from 'lexical/shared/invariant';
|
||
|
import normalizeClassNames from 'lexical/shared/normalizeClassNames';
|
||
|
|
||
|
import {
|
||
|
$createTextNode,
|
||
|
$getPreviousSelection,
|
||
|
$getSelection,
|
||
|
$isDecoratorNode,
|
||
|
$isElementNode,
|
||
|
$isLineBreakNode,
|
||
|
$isRangeSelection,
|
||
|
$isRootNode,
|
||
|
$isTextNode,
|
||
|
DecoratorNode,
|
||
|
ElementNode,
|
||
|
LineBreakNode,
|
||
|
} from '.';
|
||
|
import {
|
||
|
COMPOSITION_SUFFIX,
|
||
|
DOM_TEXT_TYPE,
|
||
|
HAS_DIRTY_NODES,
|
||
|
LTR_REGEX,
|
||
|
RTL_REGEX,
|
||
|
TEXT_TYPE_TO_FORMAT,
|
||
|
} from './LexicalConstants';
|
||
|
import {LexicalEditor} from './LexicalEditor';
|
||
|
import {$flushRootMutations} from './LexicalMutations';
|
||
|
import {$normalizeSelection} from './LexicalNormalization';
|
||
|
import {
|
||
|
errorOnInfiniteTransforms,
|
||
|
errorOnReadOnly,
|
||
|
getActiveEditor,
|
||
|
getActiveEditorState,
|
||
|
internalGetActiveEditorState,
|
||
|
isCurrentlyReadOnlyMode,
|
||
|
triggerCommandListeners,
|
||
|
updateEditor,
|
||
|
} from './LexicalUpdates';
|
||
|
|
||
|
export const emptyFunction = () => {
|
||
|
return;
|
||
|
};
|
||
|
|
||
|
let keyCounter = 1;
|
||
|
|
||
|
export function resetRandomKey(): void {
|
||
|
keyCounter = 1;
|
||
|
}
|
||
|
|
||
|
export function generateRandomKey(): string {
|
||
|
return '' + keyCounter++;
|
||
|
}
|
||
|
|
||
|
export function getRegisteredNodeOrThrow(
|
||
|
editor: LexicalEditor,
|
||
|
nodeType: string,
|
||
|
): RegisteredNode {
|
||
|
const registeredNode = editor._nodes.get(nodeType);
|
||
|
if (registeredNode === undefined) {
|
||
|
invariant(false, 'registeredNode: Type %s not found', nodeType);
|
||
|
}
|
||
|
return registeredNode;
|
||
|
}
|
||
|
|
||
|
export const isArray = Array.isArray;
|
||
|
|
||
|
export const scheduleMicroTask: (fn: () => void) => void =
|
||
|
typeof queueMicrotask === 'function'
|
||
|
? queueMicrotask
|
||
|
: (fn) => {
|
||
|
// No window prefix intended (#1400)
|
||
|
Promise.resolve().then(fn);
|
||
|
};
|
||
|
|
||
|
export function $isSelectionCapturedInDecorator(node: Node): boolean {
|
||
|
return $isDecoratorNode($getNearestNodeFromDOMNode(node));
|
||
|
}
|
||
|
|
||
|
export function isSelectionCapturedInDecoratorInput(anchorDOM: Node): boolean {
|
||
|
const activeElement = document.activeElement as HTMLElement;
|
||
|
|
||
|
if (activeElement === null) {
|
||
|
return false;
|
||
|
}
|
||
|
const nodeName = activeElement.nodeName;
|
||
|
|
||
|
return (
|
||
|
$isDecoratorNode($getNearestNodeFromDOMNode(anchorDOM)) &&
|
||
|
(nodeName === 'INPUT' ||
|
||
|
nodeName === 'TEXTAREA' ||
|
||
|
(activeElement.contentEditable === 'true' &&
|
||
|
getEditorPropertyFromDOMNode(activeElement) == null))
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isSelectionWithinEditor(
|
||
|
editor: LexicalEditor,
|
||
|
anchorDOM: null | Node,
|
||
|
focusDOM: null | Node,
|
||
|
): boolean {
|
||
|
const rootElement = editor.getRootElement();
|
||
|
try {
|
||
|
return (
|
||
|
rootElement !== null &&
|
||
|
rootElement.contains(anchorDOM) &&
|
||
|
rootElement.contains(focusDOM) &&
|
||
|
// Ignore if selection is within nested editor
|
||
|
anchorDOM !== null &&
|
||
|
!isSelectionCapturedInDecoratorInput(anchorDOM as Node) &&
|
||
|
getNearestEditorFromDOMNode(anchorDOM) === editor
|
||
|
);
|
||
|
} catch (error) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @returns true if the given argument is a LexicalEditor instance from this build of Lexical
|
||
|
*/
|
||
|
export function isLexicalEditor(editor: unknown): editor is LexicalEditor {
|
||
|
// Check instanceof to prevent issues with multiple embedded Lexical installations
|
||
|
return editor instanceof LexicalEditor;
|
||
|
}
|
||
|
|
||
|
export function getNearestEditorFromDOMNode(
|
||
|
node: Node | null,
|
||
|
): LexicalEditor | null {
|
||
|
let currentNode = node;
|
||
|
while (currentNode != null) {
|
||
|
const editor = getEditorPropertyFromDOMNode(currentNode);
|
||
|
if (isLexicalEditor(editor)) {
|
||
|
return editor;
|
||
|
}
|
||
|
currentNode = getParentElement(currentNode);
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/** @internal */
|
||
|
export function getEditorPropertyFromDOMNode(node: Node | null): unknown {
|
||
|
// @ts-expect-error: internal field
|
||
|
return node ? node.__lexicalEditor : null;
|
||
|
}
|
||
|
|
||
|
export function getTextDirection(text: string): 'ltr' | 'rtl' | null {
|
||
|
if (RTL_REGEX.test(text)) {
|
||
|
return 'rtl';
|
||
|
}
|
||
|
if (LTR_REGEX.test(text)) {
|
||
|
return 'ltr';
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function $isTokenOrSegmented(node: TextNode): boolean {
|
||
|
return node.isToken() || node.isSegmented();
|
||
|
}
|
||
|
|
||
|
function isDOMNodeLexicalTextNode(node: Node): node is Text {
|
||
|
return node.nodeType === DOM_TEXT_TYPE;
|
||
|
}
|
||
|
|
||
|
export function getDOMTextNode(element: Node | null): Text | null {
|
||
|
let node = element;
|
||
|
while (node != null) {
|
||
|
if (isDOMNodeLexicalTextNode(node)) {
|
||
|
return node;
|
||
|
}
|
||
|
node = node.firstChild;
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function toggleTextFormatType(
|
||
|
format: number,
|
||
|
type: TextFormatType,
|
||
|
alignWithFormat: null | number,
|
||
|
): number {
|
||
|
const activeFormat = TEXT_TYPE_TO_FORMAT[type];
|
||
|
if (
|
||
|
alignWithFormat !== null &&
|
||
|
(format & activeFormat) === (alignWithFormat & activeFormat)
|
||
|
) {
|
||
|
return format;
|
||
|
}
|
||
|
let newFormat = format ^ activeFormat;
|
||
|
if (type === 'subscript') {
|
||
|
newFormat &= ~TEXT_TYPE_TO_FORMAT.superscript;
|
||
|
} else if (type === 'superscript') {
|
||
|
newFormat &= ~TEXT_TYPE_TO_FORMAT.subscript;
|
||
|
}
|
||
|
return newFormat;
|
||
|
}
|
||
|
|
||
|
export function $isLeafNode(
|
||
|
node: LexicalNode | null | undefined,
|
||
|
): node is TextNode | LineBreakNode | DecoratorNode<unknown> {
|
||
|
return $isTextNode(node) || $isLineBreakNode(node) || $isDecoratorNode(node);
|
||
|
}
|
||
|
|
||
|
export function $setNodeKey(
|
||
|
node: LexicalNode,
|
||
|
existingKey: NodeKey | null | undefined,
|
||
|
): void {
|
||
|
if (existingKey != null) {
|
||
|
if (__DEV__) {
|
||
|
errorOnNodeKeyConstructorMismatch(node, existingKey);
|
||
|
}
|
||
|
node.__key = existingKey;
|
||
|
return;
|
||
|
}
|
||
|
errorOnReadOnly();
|
||
|
errorOnInfiniteTransforms();
|
||
|
const editor = getActiveEditor();
|
||
|
const editorState = getActiveEditorState();
|
||
|
const key = generateRandomKey();
|
||
|
editorState._nodeMap.set(key, node);
|
||
|
// TODO Split this function into leaf/element
|
||
|
if ($isElementNode(node)) {
|
||
|
editor._dirtyElements.set(key, true);
|
||
|
} else {
|
||
|
editor._dirtyLeaves.add(key);
|
||
|
}
|
||
|
editor._cloneNotNeeded.add(key);
|
||
|
editor._dirtyType = HAS_DIRTY_NODES;
|
||
|
node.__key = key;
|
||
|
}
|
||
|
|
||
|
function errorOnNodeKeyConstructorMismatch(
|
||
|
node: LexicalNode,
|
||
|
existingKey: NodeKey,
|
||
|
) {
|
||
|
const editorState = internalGetActiveEditorState();
|
||
|
if (!editorState) {
|
||
|
// tests expect to be able to do this kind of clone without an active editor state
|
||
|
return;
|
||
|
}
|
||
|
const existingNode = editorState._nodeMap.get(existingKey);
|
||
|
if (existingNode && existingNode.constructor !== node.constructor) {
|
||
|
// Lifted condition to if statement because the inverted logic is a bit confusing
|
||
|
if (node.constructor.name !== existingNode.constructor.name) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'Lexical node with constructor %s attempted to re-use key from node in active editor state with constructor %s. Keys must not be re-used when the type is changed.',
|
||
|
node.constructor.name,
|
||
|
existingNode.constructor.name,
|
||
|
);
|
||
|
} else {
|
||
|
invariant(
|
||
|
false,
|
||
|
'Lexical node with constructor %s attempted to re-use key from node in active editor state with different constructor with the same name (possibly due to invalid Hot Module Replacement). Keys must not be re-used when the type is changed.',
|
||
|
node.constructor.name,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
type IntentionallyMarkedAsDirtyElement = boolean;
|
||
|
|
||
|
function internalMarkParentElementsAsDirty(
|
||
|
parentKey: NodeKey,
|
||
|
nodeMap: NodeMap,
|
||
|
dirtyElements: Map<NodeKey, IntentionallyMarkedAsDirtyElement>,
|
||
|
): void {
|
||
|
let nextParentKey: string | null = parentKey;
|
||
|
while (nextParentKey !== null) {
|
||
|
if (dirtyElements.has(nextParentKey)) {
|
||
|
return;
|
||
|
}
|
||
|
const node = nodeMap.get(nextParentKey);
|
||
|
if (node === undefined) {
|
||
|
break;
|
||
|
}
|
||
|
dirtyElements.set(nextParentKey, false);
|
||
|
nextParentKey = node.__parent;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// TODO #6031 this function or their callers have to adjust selection (i.e. insertBefore)
|
||
|
export function removeFromParent(node: LexicalNode): void {
|
||
|
const oldParent = node.getParent();
|
||
|
if (oldParent !== null) {
|
||
|
const writableNode = node.getWritable();
|
||
|
const writableParent = oldParent.getWritable();
|
||
|
const prevSibling = node.getPreviousSibling();
|
||
|
const nextSibling = node.getNextSibling();
|
||
|
// TODO: this function duplicates a bunch of operations, can be simplified.
|
||
|
if (prevSibling === null) {
|
||
|
if (nextSibling !== null) {
|
||
|
const writableNextSibling = nextSibling.getWritable();
|
||
|
writableParent.__first = nextSibling.__key;
|
||
|
writableNextSibling.__prev = null;
|
||
|
} else {
|
||
|
writableParent.__first = null;
|
||
|
}
|
||
|
} else {
|
||
|
const writablePrevSibling = prevSibling.getWritable();
|
||
|
if (nextSibling !== null) {
|
||
|
const writableNextSibling = nextSibling.getWritable();
|
||
|
writableNextSibling.__prev = writablePrevSibling.__key;
|
||
|
writablePrevSibling.__next = writableNextSibling.__key;
|
||
|
} else {
|
||
|
writablePrevSibling.__next = null;
|
||
|
}
|
||
|
writableNode.__prev = null;
|
||
|
}
|
||
|
if (nextSibling === null) {
|
||
|
if (prevSibling !== null) {
|
||
|
const writablePrevSibling = prevSibling.getWritable();
|
||
|
writableParent.__last = prevSibling.__key;
|
||
|
writablePrevSibling.__next = null;
|
||
|
} else {
|
||
|
writableParent.__last = null;
|
||
|
}
|
||
|
} else {
|
||
|
const writableNextSibling = nextSibling.getWritable();
|
||
|
if (prevSibling !== null) {
|
||
|
const writablePrevSibling = prevSibling.getWritable();
|
||
|
writablePrevSibling.__next = writableNextSibling.__key;
|
||
|
writableNextSibling.__prev = writablePrevSibling.__key;
|
||
|
} else {
|
||
|
writableNextSibling.__prev = null;
|
||
|
}
|
||
|
writableNode.__next = null;
|
||
|
}
|
||
|
writableParent.__size--;
|
||
|
writableNode.__parent = null;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Never use this function directly! It will break
|
||
|
// the cloning heuristic. Instead use node.getWritable().
|
||
|
export function internalMarkNodeAsDirty(node: LexicalNode): void {
|
||
|
errorOnInfiniteTransforms();
|
||
|
const latest = node.getLatest();
|
||
|
const parent = latest.__parent;
|
||
|
const editorState = getActiveEditorState();
|
||
|
const editor = getActiveEditor();
|
||
|
const nodeMap = editorState._nodeMap;
|
||
|
const dirtyElements = editor._dirtyElements;
|
||
|
if (parent !== null) {
|
||
|
internalMarkParentElementsAsDirty(parent, nodeMap, dirtyElements);
|
||
|
}
|
||
|
const key = latest.__key;
|
||
|
editor._dirtyType = HAS_DIRTY_NODES;
|
||
|
if ($isElementNode(node)) {
|
||
|
dirtyElements.set(key, true);
|
||
|
} else {
|
||
|
// TODO split internally MarkNodeAsDirty into two dedicated Element/leave functions
|
||
|
editor._dirtyLeaves.add(key);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function internalMarkSiblingsAsDirty(node: LexicalNode) {
|
||
|
const previousNode = node.getPreviousSibling();
|
||
|
const nextNode = node.getNextSibling();
|
||
|
if (previousNode !== null) {
|
||
|
internalMarkNodeAsDirty(previousNode);
|
||
|
}
|
||
|
if (nextNode !== null) {
|
||
|
internalMarkNodeAsDirty(nextNode);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $setCompositionKey(compositionKey: null | NodeKey): void {
|
||
|
errorOnReadOnly();
|
||
|
const editor = getActiveEditor();
|
||
|
const previousCompositionKey = editor._compositionKey;
|
||
|
if (compositionKey !== previousCompositionKey) {
|
||
|
editor._compositionKey = compositionKey;
|
||
|
if (previousCompositionKey !== null) {
|
||
|
const node = $getNodeByKey(previousCompositionKey);
|
||
|
if (node !== null) {
|
||
|
node.getWritable();
|
||
|
}
|
||
|
}
|
||
|
if (compositionKey !== null) {
|
||
|
const node = $getNodeByKey(compositionKey);
|
||
|
if (node !== null) {
|
||
|
node.getWritable();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $getCompositionKey(): null | NodeKey {
|
||
|
if (isCurrentlyReadOnlyMode()) {
|
||
|
return null;
|
||
|
}
|
||
|
const editor = getActiveEditor();
|
||
|
return editor._compositionKey;
|
||
|
}
|
||
|
|
||
|
export function $getNodeByKey<T extends LexicalNode>(
|
||
|
key: NodeKey,
|
||
|
_editorState?: EditorState,
|
||
|
): T | null {
|
||
|
const editorState = _editorState || getActiveEditorState();
|
||
|
const node = editorState._nodeMap.get(key) as T;
|
||
|
if (node === undefined) {
|
||
|
return null;
|
||
|
}
|
||
|
return node;
|
||
|
}
|
||
|
|
||
|
export function $getNodeFromDOMNode(
|
||
|
dom: Node,
|
||
|
editorState?: EditorState,
|
||
|
): LexicalNode | null {
|
||
|
const editor = getActiveEditor();
|
||
|
// @ts-ignore We intentionally add this to the Node.
|
||
|
const key = dom[`__lexicalKey_${editor._key}`];
|
||
|
if (key !== undefined) {
|
||
|
return $getNodeByKey(key, editorState);
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function $getNearestNodeFromDOMNode(
|
||
|
startingDOM: Node,
|
||
|
editorState?: EditorState,
|
||
|
): LexicalNode | null {
|
||
|
let dom: Node | null = startingDOM;
|
||
|
while (dom != null) {
|
||
|
const node = $getNodeFromDOMNode(dom, editorState);
|
||
|
if (node !== null) {
|
||
|
return node;
|
||
|
}
|
||
|
dom = getParentElement(dom);
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function cloneDecorators(
|
||
|
editor: LexicalEditor,
|
||
|
): Record<NodeKey, unknown> {
|
||
|
const currentDecorators = editor._decorators;
|
||
|
const pendingDecorators = Object.assign({}, currentDecorators);
|
||
|
editor._pendingDecorators = pendingDecorators;
|
||
|
return pendingDecorators;
|
||
|
}
|
||
|
|
||
|
export function getEditorStateTextContent(editorState: EditorState): string {
|
||
|
return editorState.read(() => $getRoot().getTextContent());
|
||
|
}
|
||
|
|
||
|
export function markAllNodesAsDirty(editor: LexicalEditor, type: string): void {
|
||
|
// Mark all existing text nodes as dirty
|
||
|
updateEditor(
|
||
|
editor,
|
||
|
() => {
|
||
|
const editorState = getActiveEditorState();
|
||
|
if (editorState.isEmpty()) {
|
||
|
return;
|
||
|
}
|
||
|
if (type === 'root') {
|
||
|
$getRoot().markDirty();
|
||
|
return;
|
||
|
}
|
||
|
const nodeMap = editorState._nodeMap;
|
||
|
for (const [, node] of nodeMap) {
|
||
|
node.markDirty();
|
||
|
}
|
||
|
},
|
||
|
editor._pendingEditorState === null
|
||
|
? {
|
||
|
tag: 'history-merge',
|
||
|
}
|
||
|
: undefined,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function $getRoot(): RootNode {
|
||
|
return internalGetRoot(getActiveEditorState());
|
||
|
}
|
||
|
|
||
|
export function internalGetRoot(editorState: EditorState): RootNode {
|
||
|
return editorState._nodeMap.get('root') as RootNode;
|
||
|
}
|
||
|
|
||
|
export function $setSelection(selection: null | BaseSelection): void {
|
||
|
errorOnReadOnly();
|
||
|
const editorState = getActiveEditorState();
|
||
|
if (selection !== null) {
|
||
|
if (__DEV__) {
|
||
|
if (Object.isFrozen(selection)) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'$setSelection called on frozen selection object. Ensure selection is cloned before passing in.',
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
selection.dirty = true;
|
||
|
selection.setCachedNodes(null);
|
||
|
}
|
||
|
editorState._selection = selection;
|
||
|
}
|
||
|
|
||
|
export function $flushMutations(): void {
|
||
|
errorOnReadOnly();
|
||
|
const editor = getActiveEditor();
|
||
|
$flushRootMutations(editor);
|
||
|
}
|
||
|
|
||
|
export function $getNodeFromDOM(dom: Node): null | LexicalNode {
|
||
|
const editor = getActiveEditor();
|
||
|
const nodeKey = getNodeKeyFromDOM(dom, editor);
|
||
|
if (nodeKey === null) {
|
||
|
const rootElement = editor.getRootElement();
|
||
|
if (dom === rootElement) {
|
||
|
return $getNodeByKey('root');
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
return $getNodeByKey(nodeKey);
|
||
|
}
|
||
|
|
||
|
export function getTextNodeOffset(
|
||
|
node: TextNode,
|
||
|
moveSelectionToEnd: boolean,
|
||
|
): number {
|
||
|
return moveSelectionToEnd ? node.getTextContentSize() : 0;
|
||
|
}
|
||
|
|
||
|
function getNodeKeyFromDOM(
|
||
|
// Note that node here refers to a DOM Node, not an Lexical Node
|
||
|
dom: Node,
|
||
|
editor: LexicalEditor,
|
||
|
): NodeKey | null {
|
||
|
let node: Node | null = dom;
|
||
|
while (node != null) {
|
||
|
// @ts-ignore We intentionally add this to the Node.
|
||
|
const key: NodeKey = node[`__lexicalKey_${editor._key}`];
|
||
|
if (key !== undefined) {
|
||
|
return key;
|
||
|
}
|
||
|
node = getParentElement(node);
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function doesContainGrapheme(str: string): boolean {
|
||
|
return /[\uD800-\uDBFF][\uDC00-\uDFFF]/g.test(str);
|
||
|
}
|
||
|
|
||
|
export function getEditorsToPropagate(
|
||
|
editor: LexicalEditor,
|
||
|
): Array<LexicalEditor> {
|
||
|
const editorsToPropagate = [];
|
||
|
let currentEditor: LexicalEditor | null = editor;
|
||
|
while (currentEditor !== null) {
|
||
|
editorsToPropagate.push(currentEditor);
|
||
|
currentEditor = currentEditor._parentEditor;
|
||
|
}
|
||
|
return editorsToPropagate;
|
||
|
}
|
||
|
|
||
|
export function createUID(): string {
|
||
|
return Math.random()
|
||
|
.toString(36)
|
||
|
.replace(/[^a-z]+/g, '')
|
||
|
.substr(0, 5);
|
||
|
}
|
||
|
|
||
|
export function getAnchorTextFromDOM(anchorNode: Node): null | string {
|
||
|
if (anchorNode.nodeType === DOM_TEXT_TYPE) {
|
||
|
return anchorNode.nodeValue;
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function $updateSelectedTextFromDOM(
|
||
|
isCompositionEnd: boolean,
|
||
|
editor: LexicalEditor,
|
||
|
data?: string,
|
||
|
): void {
|
||
|
// Update the text content with the latest composition text
|
||
|
const domSelection = getDOMSelection(editor._window);
|
||
|
if (domSelection === null) {
|
||
|
return;
|
||
|
}
|
||
|
const anchorNode = domSelection.anchorNode;
|
||
|
let {anchorOffset, focusOffset} = domSelection;
|
||
|
if (anchorNode !== null) {
|
||
|
let textContent = getAnchorTextFromDOM(anchorNode);
|
||
|
const node = $getNearestNodeFromDOMNode(anchorNode);
|
||
|
if (textContent !== null && $isTextNode(node)) {
|
||
|
// Data is intentionally truthy, as we check for boolean, null and empty string.
|
||
|
if (textContent === COMPOSITION_SUFFIX && data) {
|
||
|
const offset = data.length;
|
||
|
textContent = data;
|
||
|
anchorOffset = offset;
|
||
|
focusOffset = offset;
|
||
|
}
|
||
|
|
||
|
if (textContent !== null) {
|
||
|
$updateTextNodeFromDOMContent(
|
||
|
node,
|
||
|
textContent,
|
||
|
anchorOffset,
|
||
|
focusOffset,
|
||
|
isCompositionEnd,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $updateTextNodeFromDOMContent(
|
||
|
textNode: TextNode,
|
||
|
textContent: string,
|
||
|
anchorOffset: null | number,
|
||
|
focusOffset: null | number,
|
||
|
compositionEnd: boolean,
|
||
|
): void {
|
||
|
let node = textNode;
|
||
|
|
||
|
if (node.isAttached() && (compositionEnd || !node.isDirty())) {
|
||
|
const isComposing = node.isComposing();
|
||
|
let normalizedTextContent = textContent;
|
||
|
|
||
|
if (
|
||
|
(isComposing || compositionEnd) &&
|
||
|
textContent[textContent.length - 1] === COMPOSITION_SUFFIX
|
||
|
) {
|
||
|
normalizedTextContent = textContent.slice(0, -1);
|
||
|
}
|
||
|
const prevTextContent = node.getTextContent();
|
||
|
|
||
|
if (compositionEnd || normalizedTextContent !== prevTextContent) {
|
||
|
if (normalizedTextContent === '') {
|
||
|
$setCompositionKey(null);
|
||
|
if (!IS_SAFARI && !IS_IOS && !IS_APPLE_WEBKIT) {
|
||
|
// For composition (mainly Android), we have to remove the node on a later update
|
||
|
const editor = getActiveEditor();
|
||
|
setTimeout(() => {
|
||
|
editor.update(() => {
|
||
|
if (node.isAttached()) {
|
||
|
node.remove();
|
||
|
}
|
||
|
});
|
||
|
}, 20);
|
||
|
} else {
|
||
|
node.remove();
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
const parent = node.getParent();
|
||
|
const prevSelection = $getPreviousSelection();
|
||
|
const prevTextContentSize = node.getTextContentSize();
|
||
|
const compositionKey = $getCompositionKey();
|
||
|
const nodeKey = node.getKey();
|
||
|
|
||
|
if (
|
||
|
node.isToken() ||
|
||
|
(compositionKey !== null &&
|
||
|
nodeKey === compositionKey &&
|
||
|
!isComposing) ||
|
||
|
// Check if character was added at the start or boundaries when not insertable, and we need
|
||
|
// to clear this input from occurring as that action wasn't permitted.
|
||
|
($isRangeSelection(prevSelection) &&
|
||
|
((parent !== null &&
|
||
|
!parent.canInsertTextBefore() &&
|
||
|
prevSelection.anchor.offset === 0) ||
|
||
|
(prevSelection.anchor.key === textNode.__key &&
|
||
|
prevSelection.anchor.offset === 0 &&
|
||
|
!node.canInsertTextBefore() &&
|
||
|
!isComposing) ||
|
||
|
(prevSelection.focus.key === textNode.__key &&
|
||
|
prevSelection.focus.offset === prevTextContentSize &&
|
||
|
!node.canInsertTextAfter() &&
|
||
|
!isComposing)))
|
||
|
) {
|
||
|
node.markDirty();
|
||
|
return;
|
||
|
}
|
||
|
const selection = $getSelection();
|
||
|
|
||
|
if (
|
||
|
!$isRangeSelection(selection) ||
|
||
|
anchorOffset === null ||
|
||
|
focusOffset === null
|
||
|
) {
|
||
|
node.setTextContent(normalizedTextContent);
|
||
|
return;
|
||
|
}
|
||
|
selection.setTextNodeRange(node, anchorOffset, node, focusOffset);
|
||
|
|
||
|
if (node.isSegmented()) {
|
||
|
const originalTextContent = node.getTextContent();
|
||
|
const replacement = $createTextNode(originalTextContent);
|
||
|
node.replace(replacement);
|
||
|
node = replacement;
|
||
|
}
|
||
|
node.setTextContent(normalizedTextContent);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function $previousSiblingDoesNotAcceptText(node: TextNode): boolean {
|
||
|
const previousSibling = node.getPreviousSibling();
|
||
|
|
||
|
return (
|
||
|
($isTextNode(previousSibling) ||
|
||
|
($isElementNode(previousSibling) && previousSibling.isInline())) &&
|
||
|
!previousSibling.canInsertTextAfter()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
// This function is connected to $shouldPreventDefaultAndInsertText and determines whether the
|
||
|
// TextNode boundaries are writable or we should use the previous/next sibling instead. For example,
|
||
|
// in the case of a LinkNode, boundaries are not writable.
|
||
|
export function $shouldInsertTextAfterOrBeforeTextNode(
|
||
|
selection: RangeSelection,
|
||
|
node: TextNode,
|
||
|
): boolean {
|
||
|
if (node.isSegmented()) {
|
||
|
return true;
|
||
|
}
|
||
|
if (!selection.isCollapsed()) {
|
||
|
return false;
|
||
|
}
|
||
|
const offset = selection.anchor.offset;
|
||
|
const parent = node.getParentOrThrow();
|
||
|
const isToken = node.isToken();
|
||
|
if (offset === 0) {
|
||
|
return (
|
||
|
!node.canInsertTextBefore() ||
|
||
|
(!parent.canInsertTextBefore() && !node.isComposing()) ||
|
||
|
isToken ||
|
||
|
$previousSiblingDoesNotAcceptText(node)
|
||
|
);
|
||
|
} else if (offset === node.getTextContentSize()) {
|
||
|
return (
|
||
|
!node.canInsertTextAfter() ||
|
||
|
(!parent.canInsertTextAfter() && !node.isComposing()) ||
|
||
|
isToken
|
||
|
);
|
||
|
} else {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function isTab(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return key === 'Tab' && !altKey && !ctrlKey && !metaKey;
|
||
|
}
|
||
|
|
||
|
export function isBold(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return (
|
||
|
key.toLowerCase() === 'b' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isItalic(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return (
|
||
|
key.toLowerCase() === 'i' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isUnderline(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return (
|
||
|
key.toLowerCase() === 'u' && !altKey && controlOrMeta(metaKey, ctrlKey)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isParagraph(key: string, shiftKey: boolean): boolean {
|
||
|
return isReturn(key) && !shiftKey;
|
||
|
}
|
||
|
|
||
|
export function isLineBreak(key: string, shiftKey: boolean): boolean {
|
||
|
return isReturn(key) && shiftKey;
|
||
|
}
|
||
|
|
||
|
// Inserts a new line after the selection
|
||
|
|
||
|
export function isOpenLineBreak(key: string, ctrlKey: boolean): boolean {
|
||
|
// 79 = KeyO
|
||
|
return IS_APPLE && ctrlKey && key.toLowerCase() === 'o';
|
||
|
}
|
||
|
|
||
|
export function isDeleteWordBackward(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return isBackspace(key) && (IS_APPLE ? altKey : ctrlKey);
|
||
|
}
|
||
|
|
||
|
export function isDeleteWordForward(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return isDelete(key) && (IS_APPLE ? altKey : ctrlKey);
|
||
|
}
|
||
|
|
||
|
export function isDeleteLineBackward(key: string, metaKey: boolean): boolean {
|
||
|
return IS_APPLE && metaKey && isBackspace(key);
|
||
|
}
|
||
|
|
||
|
export function isDeleteLineForward(key: string, metaKey: boolean): boolean {
|
||
|
return IS_APPLE && metaKey && isDelete(key);
|
||
|
}
|
||
|
|
||
|
export function isDeleteBackward(
|
||
|
key: string,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
if (IS_APPLE) {
|
||
|
if (altKey || metaKey) {
|
||
|
return false;
|
||
|
}
|
||
|
return isBackspace(key) || (key.toLowerCase() === 'h' && ctrlKey);
|
||
|
}
|
||
|
if (ctrlKey || altKey || metaKey) {
|
||
|
return false;
|
||
|
}
|
||
|
return isBackspace(key);
|
||
|
}
|
||
|
|
||
|
export function isDeleteForward(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
shiftKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
if (IS_APPLE) {
|
||
|
if (shiftKey || altKey || metaKey) {
|
||
|
return false;
|
||
|
}
|
||
|
return isDelete(key) || (key.toLowerCase() === 'd' && ctrlKey);
|
||
|
}
|
||
|
if (ctrlKey || altKey || metaKey) {
|
||
|
return false;
|
||
|
}
|
||
|
return isDelete(key);
|
||
|
}
|
||
|
|
||
|
export function isUndo(
|
||
|
key: string,
|
||
|
shiftKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return (
|
||
|
key.toLowerCase() === 'z' && !shiftKey && controlOrMeta(metaKey, ctrlKey)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isRedo(
|
||
|
key: string,
|
||
|
shiftKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
if (IS_APPLE) {
|
||
|
return key.toLowerCase() === 'z' && metaKey && shiftKey;
|
||
|
}
|
||
|
return (
|
||
|
(key.toLowerCase() === 'y' && ctrlKey) ||
|
||
|
(key.toLowerCase() === 'z' && ctrlKey && shiftKey)
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function isCopy(
|
||
|
key: string,
|
||
|
shiftKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
if (shiftKey) {
|
||
|
return false;
|
||
|
}
|
||
|
if (key.toLowerCase() === 'c') {
|
||
|
return IS_APPLE ? metaKey : ctrlKey;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
export function isCut(
|
||
|
key: string,
|
||
|
shiftKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
if (shiftKey) {
|
||
|
return false;
|
||
|
}
|
||
|
if (key.toLowerCase() === 'x') {
|
||
|
return IS_APPLE ? metaKey : ctrlKey;
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
function isArrowLeft(key: string): boolean {
|
||
|
return key === 'ArrowLeft';
|
||
|
}
|
||
|
|
||
|
function isArrowRight(key: string): boolean {
|
||
|
return key === 'ArrowRight';
|
||
|
}
|
||
|
|
||
|
function isArrowUp(key: string): boolean {
|
||
|
return key === 'ArrowUp';
|
||
|
}
|
||
|
|
||
|
function isArrowDown(key: string): boolean {
|
||
|
return key === 'ArrowDown';
|
||
|
}
|
||
|
|
||
|
export function isMoveBackward(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowLeft(key) && !ctrlKey && !metaKey && !altKey;
|
||
|
}
|
||
|
|
||
|
export function isMoveToStart(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
shiftKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowLeft(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
|
||
|
}
|
||
|
|
||
|
export function isMoveForward(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowRight(key) && !ctrlKey && !metaKey && !altKey;
|
||
|
}
|
||
|
|
||
|
export function isMoveToEnd(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
shiftKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowRight(key) && !altKey && !shiftKey && (ctrlKey || metaKey);
|
||
|
}
|
||
|
|
||
|
export function isMoveUp(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowUp(key) && !ctrlKey && !metaKey;
|
||
|
}
|
||
|
|
||
|
export function isMoveDown(
|
||
|
key: string,
|
||
|
ctrlKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return isArrowDown(key) && !ctrlKey && !metaKey;
|
||
|
}
|
||
|
|
||
|
export function isModifier(
|
||
|
ctrlKey: boolean,
|
||
|
shiftKey: boolean,
|
||
|
altKey: boolean,
|
||
|
metaKey: boolean,
|
||
|
): boolean {
|
||
|
return ctrlKey || shiftKey || altKey || metaKey;
|
||
|
}
|
||
|
|
||
|
export function isSpace(key: string): boolean {
|
||
|
return key === ' ';
|
||
|
}
|
||
|
|
||
|
export function controlOrMeta(metaKey: boolean, ctrlKey: boolean): boolean {
|
||
|
if (IS_APPLE) {
|
||
|
return metaKey;
|
||
|
}
|
||
|
return ctrlKey;
|
||
|
}
|
||
|
|
||
|
export function isReturn(key: string): boolean {
|
||
|
return key === 'Enter';
|
||
|
}
|
||
|
|
||
|
export function isBackspace(key: string): boolean {
|
||
|
return key === 'Backspace';
|
||
|
}
|
||
|
|
||
|
export function isEscape(key: string): boolean {
|
||
|
return key === 'Escape';
|
||
|
}
|
||
|
|
||
|
export function isDelete(key: string): boolean {
|
||
|
return key === 'Delete';
|
||
|
}
|
||
|
|
||
|
export function isSelectAll(
|
||
|
key: string,
|
||
|
metaKey: boolean,
|
||
|
ctrlKey: boolean,
|
||
|
): boolean {
|
||
|
return key.toLowerCase() === 'a' && controlOrMeta(metaKey, ctrlKey);
|
||
|
}
|
||
|
|
||
|
export function $selectAll(): void {
|
||
|
const root = $getRoot();
|
||
|
const selection = root.select(0, root.getChildrenSize());
|
||
|
$setSelection($normalizeSelection(selection));
|
||
|
}
|
||
|
|
||
|
export function getCachedClassNameArray(
|
||
|
classNamesTheme: EditorThemeClasses,
|
||
|
classNameThemeType: string,
|
||
|
): Array<string> {
|
||
|
if (classNamesTheme.__lexicalClassNameCache === undefined) {
|
||
|
classNamesTheme.__lexicalClassNameCache = {};
|
||
|
}
|
||
|
const classNamesCache = classNamesTheme.__lexicalClassNameCache;
|
||
|
const cachedClassNames = classNamesCache[classNameThemeType];
|
||
|
if (cachedClassNames !== undefined) {
|
||
|
return cachedClassNames;
|
||
|
}
|
||
|
const classNames = classNamesTheme[classNameThemeType];
|
||
|
// As we're using classList, we need
|
||
|
// to handle className tokens that have spaces.
|
||
|
// The easiest way to do this to convert the
|
||
|
// className tokens to an array that can be
|
||
|
// applied to classList.add()/remove().
|
||
|
if (typeof classNames === 'string') {
|
||
|
const classNamesArr = normalizeClassNames(classNames);
|
||
|
classNamesCache[classNameThemeType] = classNamesArr;
|
||
|
return classNamesArr;
|
||
|
}
|
||
|
return classNames;
|
||
|
}
|
||
|
|
||
|
export function setMutatedNode(
|
||
|
mutatedNodes: MutatedNodes,
|
||
|
registeredNodes: RegisteredNodes,
|
||
|
mutationListeners: MutationListeners,
|
||
|
node: LexicalNode,
|
||
|
mutation: NodeMutation,
|
||
|
) {
|
||
|
if (mutationListeners.size === 0) {
|
||
|
return;
|
||
|
}
|
||
|
const nodeType = node.__type;
|
||
|
const nodeKey = node.__key;
|
||
|
const registeredNode = registeredNodes.get(nodeType);
|
||
|
if (registeredNode === undefined) {
|
||
|
invariant(false, 'Type %s not in registeredNodes', nodeType);
|
||
|
}
|
||
|
const klass = registeredNode.klass;
|
||
|
let mutatedNodesByType = mutatedNodes.get(klass);
|
||
|
if (mutatedNodesByType === undefined) {
|
||
|
mutatedNodesByType = new Map();
|
||
|
mutatedNodes.set(klass, mutatedNodesByType);
|
||
|
}
|
||
|
const prevMutation = mutatedNodesByType.get(nodeKey);
|
||
|
// If the node has already been "destroyed", yet we are
|
||
|
// re-making it, then this means a move likely happened.
|
||
|
// We should change the mutation to be that of "updated"
|
||
|
// instead.
|
||
|
const isMove = prevMutation === 'destroyed' && mutation === 'created';
|
||
|
if (prevMutation === undefined || isMove) {
|
||
|
mutatedNodesByType.set(nodeKey, isMove ? 'updated' : mutation);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $nodesOfType<T extends LexicalNode>(klass: Klass<T>): Array<T> {
|
||
|
const klassType = klass.getType();
|
||
|
const editorState = getActiveEditorState();
|
||
|
if (editorState._readOnly) {
|
||
|
const nodes = getCachedTypeToNodeMap(editorState).get(klassType) as
|
||
|
| undefined
|
||
|
| Map<string, T>;
|
||
|
return nodes ? Array.from(nodes.values()) : [];
|
||
|
}
|
||
|
const nodes = editorState._nodeMap;
|
||
|
const nodesOfType: Array<T> = [];
|
||
|
for (const [, node] of nodes) {
|
||
|
if (
|
||
|
node instanceof klass &&
|
||
|
node.__type === klassType &&
|
||
|
node.isAttached()
|
||
|
) {
|
||
|
nodesOfType.push(node as T);
|
||
|
}
|
||
|
}
|
||
|
return nodesOfType;
|
||
|
}
|
||
|
|
||
|
function resolveElement(
|
||
|
element: ElementNode,
|
||
|
isBackward: boolean,
|
||
|
focusOffset: number,
|
||
|
): LexicalNode | null {
|
||
|
const parent = element.getParent();
|
||
|
let offset = focusOffset;
|
||
|
let block = element;
|
||
|
if (parent !== null) {
|
||
|
if (isBackward && focusOffset === 0) {
|
||
|
offset = block.getIndexWithinParent();
|
||
|
block = parent;
|
||
|
} else if (!isBackward && focusOffset === block.getChildrenSize()) {
|
||
|
offset = block.getIndexWithinParent() + 1;
|
||
|
block = parent;
|
||
|
}
|
||
|
}
|
||
|
return block.getChildAtIndex(isBackward ? offset - 1 : offset);
|
||
|
}
|
||
|
|
||
|
export function $getAdjacentNode(
|
||
|
focus: PointType,
|
||
|
isBackward: boolean,
|
||
|
): null | LexicalNode {
|
||
|
const focusOffset = focus.offset;
|
||
|
if (focus.type === 'element') {
|
||
|
const block = focus.getNode();
|
||
|
return resolveElement(block, isBackward, focusOffset);
|
||
|
} else {
|
||
|
const focusNode = focus.getNode();
|
||
|
if (
|
||
|
(isBackward && focusOffset === 0) ||
|
||
|
(!isBackward && focusOffset === focusNode.getTextContentSize())
|
||
|
) {
|
||
|
const possibleNode = isBackward
|
||
|
? focusNode.getPreviousSibling()
|
||
|
: focusNode.getNextSibling();
|
||
|
if (possibleNode === null) {
|
||
|
return resolveElement(
|
||
|
focusNode.getParentOrThrow(),
|
||
|
isBackward,
|
||
|
focusNode.getIndexWithinParent() + (isBackward ? 0 : 1),
|
||
|
);
|
||
|
}
|
||
|
return possibleNode;
|
||
|
}
|
||
|
}
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
export function isFirefoxClipboardEvents(editor: LexicalEditor): boolean {
|
||
|
const event = getWindow(editor).event;
|
||
|
const inputType = event && (event as InputEvent).inputType;
|
||
|
return (
|
||
|
inputType === 'insertFromPaste' ||
|
||
|
inputType === 'insertFromPasteAsQuotation'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function dispatchCommand<TCommand extends LexicalCommand<unknown>>(
|
||
|
editor: LexicalEditor,
|
||
|
command: TCommand,
|
||
|
payload: CommandPayloadType<TCommand>,
|
||
|
): boolean {
|
||
|
return triggerCommandListeners(editor, command, payload);
|
||
|
}
|
||
|
|
||
|
export function $textContentRequiresDoubleLinebreakAtEnd(
|
||
|
node: ElementNode,
|
||
|
): boolean {
|
||
|
return !$isRootNode(node) && !node.isLastChild() && !node.isInline();
|
||
|
}
|
||
|
|
||
|
export function getElementByKeyOrThrow(
|
||
|
editor: LexicalEditor,
|
||
|
key: NodeKey,
|
||
|
): HTMLElement {
|
||
|
const element = editor._keyToDOMMap.get(key);
|
||
|
|
||
|
if (element === undefined) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'Reconciliation: could not find DOM element for node key %s',
|
||
|
key,
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return element;
|
||
|
}
|
||
|
|
||
|
export function getParentElement(node: Node): HTMLElement | null {
|
||
|
const parentElement =
|
||
|
(node as HTMLSlotElement).assignedSlot || node.parentElement;
|
||
|
return parentElement !== null && parentElement.nodeType === 11
|
||
|
? ((parentElement as unknown as ShadowRoot).host as HTMLElement)
|
||
|
: parentElement;
|
||
|
}
|
||
|
|
||
|
export function scrollIntoViewIfNeeded(
|
||
|
editor: LexicalEditor,
|
||
|
selectionRect: DOMRect,
|
||
|
rootElement: HTMLElement,
|
||
|
): void {
|
||
|
const doc = rootElement.ownerDocument;
|
||
|
const defaultView = doc.defaultView;
|
||
|
|
||
|
if (defaultView === null) {
|
||
|
return;
|
||
|
}
|
||
|
let {top: currentTop, bottom: currentBottom} = selectionRect;
|
||
|
let targetTop = 0;
|
||
|
let targetBottom = 0;
|
||
|
let element: HTMLElement | null = rootElement;
|
||
|
|
||
|
while (element !== null) {
|
||
|
const isBodyElement = element === doc.body;
|
||
|
if (isBodyElement) {
|
||
|
targetTop = 0;
|
||
|
targetBottom = getWindow(editor).innerHeight;
|
||
|
} else {
|
||
|
const targetRect = element.getBoundingClientRect();
|
||
|
targetTop = targetRect.top;
|
||
|
targetBottom = targetRect.bottom;
|
||
|
}
|
||
|
let diff = 0;
|
||
|
|
||
|
if (currentTop < targetTop) {
|
||
|
diff = -(targetTop - currentTop);
|
||
|
} else if (currentBottom > targetBottom) {
|
||
|
diff = currentBottom - targetBottom;
|
||
|
}
|
||
|
|
||
|
if (diff !== 0) {
|
||
|
if (isBodyElement) {
|
||
|
// Only handles scrolling of Y axis
|
||
|
defaultView.scrollBy(0, diff);
|
||
|
} else {
|
||
|
const scrollTop = element.scrollTop;
|
||
|
element.scrollTop += diff;
|
||
|
const yOffset = element.scrollTop - scrollTop;
|
||
|
currentTop -= yOffset;
|
||
|
currentBottom -= yOffset;
|
||
|
}
|
||
|
}
|
||
|
if (isBodyElement) {
|
||
|
break;
|
||
|
}
|
||
|
element = getParentElement(element);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $hasUpdateTag(tag: string): boolean {
|
||
|
const editor = getActiveEditor();
|
||
|
return editor._updateTags.has(tag);
|
||
|
}
|
||
|
|
||
|
export function $addUpdateTag(tag: string): void {
|
||
|
errorOnReadOnly();
|
||
|
const editor = getActiveEditor();
|
||
|
editor._updateTags.add(tag);
|
||
|
}
|
||
|
|
||
|
export function $maybeMoveChildrenSelectionToParent(
|
||
|
parentNode: LexicalNode,
|
||
|
): BaseSelection | null {
|
||
|
const selection = $getSelection();
|
||
|
if (!$isRangeSelection(selection) || !$isElementNode(parentNode)) {
|
||
|
return selection;
|
||
|
}
|
||
|
const {anchor, focus} = selection;
|
||
|
const anchorNode = anchor.getNode();
|
||
|
const focusNode = focus.getNode();
|
||
|
if ($hasAncestor(anchorNode, parentNode)) {
|
||
|
anchor.set(parentNode.__key, 0, 'element');
|
||
|
}
|
||
|
if ($hasAncestor(focusNode, parentNode)) {
|
||
|
focus.set(parentNode.__key, 0, 'element');
|
||
|
}
|
||
|
return selection;
|
||
|
}
|
||
|
|
||
|
export function $hasAncestor(
|
||
|
child: LexicalNode,
|
||
|
targetNode: LexicalNode,
|
||
|
): boolean {
|
||
|
let parent = child.getParent();
|
||
|
while (parent !== null) {
|
||
|
if (parent.is(targetNode)) {
|
||
|
return true;
|
||
|
}
|
||
|
parent = parent.getParent();
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
export function getDefaultView(domElem: HTMLElement): Window | null {
|
||
|
const ownerDoc = domElem.ownerDocument;
|
||
|
return (ownerDoc && ownerDoc.defaultView) || null;
|
||
|
}
|
||
|
|
||
|
export function getWindow(editor: LexicalEditor): Window {
|
||
|
const windowObj = editor._window;
|
||
|
if (windowObj === null) {
|
||
|
invariant(false, 'window object not found');
|
||
|
}
|
||
|
return windowObj;
|
||
|
}
|
||
|
|
||
|
export function $isInlineElementOrDecoratorNode(node: LexicalNode): boolean {
|
||
|
return (
|
||
|
($isElementNode(node) && node.isInline()) ||
|
||
|
($isDecoratorNode(node) && node.isInline())
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function $getNearestRootOrShadowRoot(
|
||
|
node: LexicalNode,
|
||
|
): RootNode | ElementNode {
|
||
|
let parent = node.getParentOrThrow();
|
||
|
while (parent !== null) {
|
||
|
if ($isRootOrShadowRoot(parent)) {
|
||
|
return parent;
|
||
|
}
|
||
|
parent = parent.getParentOrThrow();
|
||
|
}
|
||
|
return parent;
|
||
|
}
|
||
|
|
||
|
const ShadowRootNodeBrand: unique symbol = Symbol.for(
|
||
|
'@lexical/ShadowRootNodeBrand',
|
||
|
);
|
||
|
type ShadowRootNode = Spread<
|
||
|
{isShadowRoot(): true; [ShadowRootNodeBrand]: never},
|
||
|
ElementNode
|
||
|
>;
|
||
|
export function $isRootOrShadowRoot(
|
||
|
node: null | LexicalNode,
|
||
|
): node is RootNode | ShadowRootNode {
|
||
|
return $isRootNode(node) || ($isElementNode(node) && node.isShadowRoot());
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a shallow clone of node with a new key
|
||
|
*
|
||
|
* @param node - The node to be copied.
|
||
|
* @returns The copy of the node.
|
||
|
*/
|
||
|
export function $copyNode<T extends LexicalNode>(node: T): T {
|
||
|
const copy = node.constructor.clone(node) as T;
|
||
|
$setNodeKey(copy, null);
|
||
|
return copy;
|
||
|
}
|
||
|
|
||
|
export function $applyNodeReplacement<N extends LexicalNode>(
|
||
|
node: LexicalNode,
|
||
|
): N {
|
||
|
const editor = getActiveEditor();
|
||
|
const nodeType = node.constructor.getType();
|
||
|
const registeredNode = editor._nodes.get(nodeType);
|
||
|
if (registeredNode === undefined) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'$initializeNode failed. Ensure node has been registered to the editor. You can do this by passing the node class via the "nodes" array in the editor config.',
|
||
|
);
|
||
|
}
|
||
|
const replaceFunc = registeredNode.replace;
|
||
|
if (replaceFunc !== null) {
|
||
|
const replacementNode = replaceFunc(node) as N;
|
||
|
if (!(replacementNode instanceof node.constructor)) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'$initializeNode failed. Ensure replacement node is a subclass of the original node.',
|
||
|
);
|
||
|
}
|
||
|
return replacementNode;
|
||
|
}
|
||
|
return node as N;
|
||
|
}
|
||
|
|
||
|
export function errorOnInsertTextNodeOnRoot(
|
||
|
node: LexicalNode,
|
||
|
insertNode: LexicalNode,
|
||
|
): void {
|
||
|
const parentNode = node.getParent();
|
||
|
if (
|
||
|
$isRootNode(parentNode) &&
|
||
|
!$isElementNode(insertNode) &&
|
||
|
!$isDecoratorNode(insertNode)
|
||
|
) {
|
||
|
invariant(
|
||
|
false,
|
||
|
'Only element or decorator nodes can be inserted in to the root node',
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function $getNodeByKeyOrThrow<N extends LexicalNode>(key: NodeKey): N {
|
||
|
const node = $getNodeByKey<N>(key);
|
||
|
if (node === null) {
|
||
|
invariant(
|
||
|
false,
|
||
|
"Expected node with key %s to exist but it's not in the nodeMap.",
|
||
|
key,
|
||
|
);
|
||
|
}
|
||
|
return node;
|
||
|
}
|
||
|
|
||
|
function createBlockCursorElement(editorConfig: EditorConfig): HTMLDivElement {
|
||
|
const theme = editorConfig.theme;
|
||
|
const element = document.createElement('div');
|
||
|
element.contentEditable = 'false';
|
||
|
element.setAttribute('data-lexical-cursor', 'true');
|
||
|
let blockCursorTheme = theme.blockCursor;
|
||
|
if (blockCursorTheme !== undefined) {
|
||
|
if (typeof blockCursorTheme === 'string') {
|
||
|
const classNamesArr = normalizeClassNames(blockCursorTheme);
|
||
|
// @ts-expect-error: intentional
|
||
|
blockCursorTheme = theme.blockCursor = classNamesArr;
|
||
|
}
|
||
|
if (blockCursorTheme !== undefined) {
|
||
|
element.classList.add(...blockCursorTheme);
|
||
|
}
|
||
|
}
|
||
|
return element;
|
||
|
}
|
||
|
|
||
|
function needsBlockCursor(node: null | LexicalNode): boolean {
|
||
|
return (
|
||
|
($isDecoratorNode(node) || ($isElementNode(node) && !node.canBeEmpty())) &&
|
||
|
!node.isInline()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
export function removeDOMBlockCursorElement(
|
||
|
blockCursorElement: HTMLElement,
|
||
|
editor: LexicalEditor,
|
||
|
rootElement: HTMLElement,
|
||
|
) {
|
||
|
rootElement.style.removeProperty('caret-color');
|
||
|
editor._blockCursorElement = null;
|
||
|
const parentElement = blockCursorElement.parentElement;
|
||
|
if (parentElement !== null) {
|
||
|
parentElement.removeChild(blockCursorElement);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function updateDOMBlockCursorElement(
|
||
|
editor: LexicalEditor,
|
||
|
rootElement: HTMLElement,
|
||
|
nextSelection: null | BaseSelection,
|
||
|
): void {
|
||
|
let blockCursorElement = editor._blockCursorElement;
|
||
|
|
||
|
if (
|
||
|
$isRangeSelection(nextSelection) &&
|
||
|
nextSelection.isCollapsed() &&
|
||
|
nextSelection.anchor.type === 'element' &&
|
||
|
rootElement.contains(document.activeElement)
|
||
|
) {
|
||
|
const anchor = nextSelection.anchor;
|
||
|
const elementNode = anchor.getNode();
|
||
|
const offset = anchor.offset;
|
||
|
const elementNodeSize = elementNode.getChildrenSize();
|
||
|
let isBlockCursor = false;
|
||
|
let insertBeforeElement: null | HTMLElement = null;
|
||
|
|
||
|
if (offset === elementNodeSize) {
|
||
|
const child = elementNode.getChildAtIndex(offset - 1);
|
||
|
if (needsBlockCursor(child)) {
|
||
|
isBlockCursor = true;
|
||
|
}
|
||
|
} else {
|
||
|
const child = elementNode.getChildAtIndex(offset);
|
||
|
if (needsBlockCursor(child)) {
|
||
|
const sibling = (child as LexicalNode).getPreviousSibling();
|
||
|
if (sibling === null || needsBlockCursor(sibling)) {
|
||
|
isBlockCursor = true;
|
||
|
insertBeforeElement = editor.getElementByKey(
|
||
|
(child as LexicalNode).__key,
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
if (isBlockCursor) {
|
||
|
const elementDOM = editor.getElementByKey(
|
||
|
elementNode.__key,
|
||
|
) as HTMLElement;
|
||
|
if (blockCursorElement === null) {
|
||
|
editor._blockCursorElement = blockCursorElement =
|
||
|
createBlockCursorElement(editor._config);
|
||
|
}
|
||
|
rootElement.style.caretColor = 'transparent';
|
||
|
if (insertBeforeElement === null) {
|
||
|
elementDOM.appendChild(blockCursorElement);
|
||
|
} else {
|
||
|
elementDOM.insertBefore(blockCursorElement, insertBeforeElement);
|
||
|
}
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
// Remove cursor
|
||
|
if (blockCursorElement !== null) {
|
||
|
removeDOMBlockCursorElement(blockCursorElement, editor, rootElement);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function getDOMSelection(targetWindow: null | Window): null | Selection {
|
||
|
return !CAN_USE_DOM ? null : (targetWindow || window).getSelection();
|
||
|
}
|
||
|
|
||
|
export function $splitNode(
|
||
|
node: ElementNode,
|
||
|
offset: number,
|
||
|
): [ElementNode | null, ElementNode] {
|
||
|
let startNode = node.getChildAtIndex(offset);
|
||
|
if (startNode == null) {
|
||
|
startNode = node;
|
||
|
}
|
||
|
|
||
|
invariant(
|
||
|
!$isRootOrShadowRoot(node),
|
||
|
'Can not call $splitNode() on root element',
|
||
|
);
|
||
|
|
||
|
const recurse = <T extends LexicalNode>(
|
||
|
currentNode: T,
|
||
|
): [ElementNode, ElementNode, T] => {
|
||
|
const parent = currentNode.getParentOrThrow();
|
||
|
const isParentRoot = $isRootOrShadowRoot(parent);
|
||
|
// The node we start split from (leaf) is moved, but its recursive
|
||
|
// parents are copied to create separate tree
|
||
|
const nodeToMove =
|
||
|
currentNode === startNode && !isParentRoot
|
||
|
? currentNode
|
||
|
: $copyNode(currentNode);
|
||
|
|
||
|
if (isParentRoot) {
|
||
|
invariant(
|
||
|
$isElementNode(currentNode) && $isElementNode(nodeToMove),
|
||
|
'Children of a root must be ElementNode',
|
||
|
);
|
||
|
|
||
|
currentNode.insertAfter(nodeToMove);
|
||
|
return [currentNode, nodeToMove, nodeToMove];
|
||
|
} else {
|
||
|
const [leftTree, rightTree, newParent] = recurse(parent);
|
||
|
const nextSiblings = currentNode.getNextSiblings();
|
||
|
|
||
|
newParent.append(nodeToMove, ...nextSiblings);
|
||
|
return [leftTree, rightTree, nodeToMove];
|
||
|
}
|
||
|
};
|
||
|
|
||
|
const [leftTree, rightTree] = recurse(startNode);
|
||
|
|
||
|
return [leftTree, rightTree];
|
||
|
}
|
||
|
|
||
|
export function $findMatchingParent(
|
||
|
startingNode: LexicalNode,
|
||
|
findFn: (node: LexicalNode) => boolean,
|
||
|
): LexicalNode | null {
|
||
|
let curr: ElementNode | LexicalNode | null = startingNode;
|
||
|
|
||
|
while (curr !== $getRoot() && curr != null) {
|
||
|
if (findFn(curr)) {
|
||
|
return curr;
|
||
|
}
|
||
|
|
||
|
curr = curr.getParent();
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param x - The element being tested
|
||
|
* @returns Returns true if x is an HTML anchor tag, false otherwise
|
||
|
*/
|
||
|
export function isHTMLAnchorElement(x: Node): x is HTMLAnchorElement {
|
||
|
return isHTMLElement(x) && x.tagName === 'A';
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param x - The element being testing
|
||
|
* @returns Returns true if x is an HTML element, false otherwise.
|
||
|
*/
|
||
|
export function isHTMLElement(x: Node | EventTarget): x is HTMLElement {
|
||
|
// @ts-ignore-next-line - strict check on nodeType here should filter out non-Element EventTarget implementors
|
||
|
return x.nodeType === 1;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* @param node - the Dom Node to check
|
||
|
* @returns if the Dom Node is an inline node
|
||
|
*/
|
||
|
export function isInlineDomNode(node: Node) {
|
||
|
const inlineNodes = new RegExp(
|
||
|
/^(a|abbr|acronym|b|cite|code|del|em|i|ins|kbd|label|output|q|ruby|s|samp|span|strong|sub|sup|time|u|tt|var|#text)$/,
|
||
|
'i',
|
||
|
);
|
||
|
return node.nodeName.match(inlineNodes) !== null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
*
|
||
|
* @param node - the Dom Node to check
|
||
|
* @returns if the Dom Node is a block node
|
||
|
*/
|
||
|
export function isBlockDomNode(node: Node) {
|
||
|
const blockNodes = new RegExp(
|
||
|
/^(address|article|aside|blockquote|canvas|dd|div|dl|dt|fieldset|figcaption|figure|footer|form|h1|h2|h3|h4|h5|h6|header|hr|li|main|nav|noscript|ol|p|pre|section|table|td|tfoot|ul|video)$/,
|
||
|
'i',
|
||
|
);
|
||
|
return node.nodeName.match(blockNodes) !== null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* This function is for internal use of the library.
|
||
|
* Please do not use it as it may change in the future.
|
||
|
*/
|
||
|
export function INTERNAL_$isBlock(
|
||
|
node: LexicalNode,
|
||
|
): node is ElementNode | DecoratorNode<unknown> {
|
||
|
if ($isRootNode(node) || ($isDecoratorNode(node) && !node.isInline())) {
|
||
|
return true;
|
||
|
}
|
||
|
if (!$isElementNode(node) || $isRootOrShadowRoot(node)) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
const firstChild = node.getFirstChild();
|
||
|
const isLeafElement =
|
||
|
firstChild === null ||
|
||
|
$isLineBreakNode(firstChild) ||
|
||
|
$isTextNode(firstChild) ||
|
||
|
firstChild.isInline();
|
||
|
|
||
|
return !node.isInline() && node.canBeEmpty() !== false && isLeafElement;
|
||
|
}
|
||
|
|
||
|
export function $getAncestor<NodeType extends LexicalNode = LexicalNode>(
|
||
|
node: LexicalNode,
|
||
|
predicate: (ancestor: LexicalNode) => ancestor is NodeType,
|
||
|
) {
|
||
|
let parent = node;
|
||
|
while (parent !== null && parent.getParent() !== null && !predicate(parent)) {
|
||
|
parent = parent.getParentOrThrow();
|
||
|
}
|
||
|
return predicate(parent) ? parent : null;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Utility function for accessing current active editor instance.
|
||
|
* @returns Current active editor
|
||
|
*/
|
||
|
export function $getEditor(): LexicalEditor {
|
||
|
return getActiveEditor();
|
||
|
}
|
||
|
|
||
|
/** @internal */
|
||
|
export type TypeToNodeMap = Map<string, NodeMap>;
|
||
|
/**
|
||
|
* @internal
|
||
|
* Compute a cached Map of node type to nodes for a frozen EditorState
|
||
|
*/
|
||
|
const cachedNodeMaps = new WeakMap<EditorState, TypeToNodeMap>();
|
||
|
const EMPTY_TYPE_TO_NODE_MAP: TypeToNodeMap = new Map();
|
||
|
export function getCachedTypeToNodeMap(
|
||
|
editorState: EditorState,
|
||
|
): TypeToNodeMap {
|
||
|
// If this is a new Editor it may have a writable this._editorState
|
||
|
// with only a 'root' entry.
|
||
|
if (!editorState._readOnly && editorState.isEmpty()) {
|
||
|
return EMPTY_TYPE_TO_NODE_MAP;
|
||
|
}
|
||
|
invariant(
|
||
|
editorState._readOnly,
|
||
|
'getCachedTypeToNodeMap called with a writable EditorState',
|
||
|
);
|
||
|
let typeToNodeMap = cachedNodeMaps.get(editorState);
|
||
|
if (!typeToNodeMap) {
|
||
|
typeToNodeMap = new Map();
|
||
|
cachedNodeMaps.set(editorState, typeToNodeMap);
|
||
|
for (const [nodeKey, node] of editorState._nodeMap) {
|
||
|
const nodeType = node.__type;
|
||
|
let nodeMap = typeToNodeMap.get(nodeType);
|
||
|
if (!nodeMap) {
|
||
|
nodeMap = new Map();
|
||
|
typeToNodeMap.set(nodeType, nodeMap);
|
||
|
}
|
||
|
nodeMap.set(nodeKey, node);
|
||
|
}
|
||
|
}
|
||
|
return typeToNodeMap;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a clone of a node using `node.constructor.clone()` followed by
|
||
|
* `clone.afterCloneFrom(node)`. The resulting clone must have the same key,
|
||
|
* parent/next/prev pointers, and other properties that are not set by
|
||
|
* `node.constructor.clone` (format, style, etc.). This is primarily used by
|
||
|
* {@link LexicalNode.getWritable} to create a writable version of an
|
||
|
* existing node. The clone is the same logical node as the original node,
|
||
|
* do not try and use this function to duplicate or copy an existing node.
|
||
|
*
|
||
|
* Does not mutate the EditorState.
|
||
|
* @param node - The node to be cloned.
|
||
|
* @returns The clone of the node.
|
||
|
*/
|
||
|
export function $cloneWithProperties<T extends LexicalNode>(latestNode: T): T {
|
||
|
const constructor = latestNode.constructor;
|
||
|
const mutableNode = constructor.clone(latestNode) as T;
|
||
|
mutableNode.afterCloneFrom(latestNode);
|
||
|
if (__DEV__) {
|
||
|
invariant(
|
||
|
mutableNode.__key === latestNode.__key,
|
||
|
"$cloneWithProperties: %s.clone(node) (with type '%s') did not return a node with the same key, make sure to specify node.__key as the last argument to the constructor",
|
||
|
constructor.name,
|
||
|
constructor.getType(),
|
||
|
);
|
||
|
invariant(
|
||
|
mutableNode.__parent === latestNode.__parent &&
|
||
|
mutableNode.__next === latestNode.__next &&
|
||
|
mutableNode.__prev === latestNode.__prev,
|
||
|
"$cloneWithProperties: %s.clone(node) (with type '%s') overrided afterCloneFrom but did not call super.afterCloneFrom(prevNode)",
|
||
|
constructor.name,
|
||
|
constructor.getType(),
|
||
|
);
|
||
|
}
|
||
|
return mutableNode;
|
||
|
}
|