From bab76263ba8688f4d9de0f866b354c123fd93fee Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Tue, 17 Dec 2024 21:40:12 -0800 Subject: [PATCH] Clean up DOM instanceof and nodeType checks --- .../src/plugins/ImagesPlugin/index.tsx | 11 +-- .../src/plugins/InlineImagePlugin/index.tsx | 11 +-- packages/lexical/src/LexicalConstants.ts | 2 + packages/lexical/src/LexicalEditor.ts | 4 +- packages/lexical/src/LexicalEvents.ts | 82 ++++++++----------- packages/lexical/src/LexicalMutations.ts | 12 +-- packages/lexical/src/LexicalSelection.ts | 7 +- packages/lexical/src/LexicalUtils.ts | 63 ++++++++++---- packages/lexical/src/index.ts | 3 + .../lexical/src/nodes/LexicalLineBreakNode.ts | 12 +-- packages/lexical/src/nodes/LexicalTextNode.ts | 25 +++--- 11 files changed, 121 insertions(+), 111 deletions(-) diff --git a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx index 4fb80b2fab2..41ff0662a1f 100644 --- a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx @@ -22,7 +22,7 @@ import { DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, - getDOMSelection, + getDOMSelectionFromTarget, isHTMLElement, LexicalCommand, LexicalEditor, @@ -367,14 +367,7 @@ function canDropImage(event: DragEvent): boolean { function getDragSelection(event: DragEvent): Range | null | undefined { let range; - const target = event.target as null | Element | Document; - const targetWindow = - target == null - ? null - : target.nodeType === 9 - ? (target as Document).defaultView - : (target as Element).ownerDocument.defaultView; - const domSelection = getDOMSelection(targetWindow); + const domSelection = getDOMSelectionFromTarget(event.target); if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(event.clientX, event.clientY); } else if (event.rangeParent && domSelection !== null) { diff --git a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx index 4ba2f6b40d6..c54d72e8999 100644 --- a/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/InlineImagePlugin/index.tsx @@ -26,7 +26,7 @@ import { DRAGOVER_COMMAND, DRAGSTART_COMMAND, DROP_COMMAND, - getDOMSelection, + getDOMSelectionFromTarget, isHTMLElement, LexicalCommand, LexicalEditor, @@ -319,14 +319,7 @@ function canDropImage(event: DragEvent): boolean { function getDragSelection(event: DragEvent): Range | null | undefined { let range; - const target = event.target as null | Element | Document; - const targetWindow = - target == null - ? null - : target.nodeType === 9 - ? (target as Document).defaultView - : (target as Element).ownerDocument.defaultView; - const domSelection = getDOMSelection(targetWindow); + const domSelection = getDOMSelectionFromTarget(event.target); if (document.caretRangeFromPoint) { range = document.caretRangeFromPoint(event.clientX, event.clientY); } else if (event.rangeParent && domSelection !== null) { diff --git a/packages/lexical/src/LexicalConstants.ts b/packages/lexical/src/LexicalConstants.ts index aead3dbddff..2204cc6d09f 100644 --- a/packages/lexical/src/LexicalConstants.ts +++ b/packages/lexical/src/LexicalConstants.ts @@ -23,6 +23,8 @@ import { // DOM export const DOM_ELEMENT_TYPE = 1; export const DOM_TEXT_TYPE = 3; +export const DOM_DOCUMENT_TYPE = 9; +export const DOM_DOCUMENT_FRAGMENT_TYPE = 11; // Reconciling export const NO_DIRTY_NODES = 0; diff --git a/packages/lexical/src/LexicalEditor.ts b/packages/lexical/src/LexicalEditor.ts index 1961c8f4f34..883dee473c1 100644 --- a/packages/lexical/src/LexicalEditor.ts +++ b/packages/lexical/src/LexicalEditor.ts @@ -40,7 +40,7 @@ import { markNodesWithTypesAsDirty, } from './LexicalUtils'; import {ArtificialNode__DO_NOT_USE} from './nodes/ArtificialNode'; -import {DecoratorNode} from './nodes/LexicalDecoratorNode'; +import {$isDecoratorNode} from './nodes/LexicalDecoratorNode'; import {LineBreakNode} from './nodes/LexicalLineBreakNode'; import {ParagraphNode} from './nodes/LexicalParagraphNode'; import {RootNode} from './nodes/LexicalRootNode'; @@ -498,7 +498,7 @@ export function createEditor(editorConfig?: CreateEditorArgs): LexicalEditor { `${name} should implement "importDOM" if using a custom "exportDOM" method to ensure HTML serialization (important for copy & paste) works as expected`, ); } - if (proto instanceof DecoratorNode) { + if ($isDecoratorNode(proto)) { // eslint-disable-next-line no-prototype-builtins if (!proto.hasOwnProperty('decorate')) { console.warn( diff --git a/packages/lexical/src/LexicalEvents.ts b/packages/lexical/src/LexicalEvents.ts index 662cd81fe2d..0ccd47e2910 100644 --- a/packages/lexical/src/LexicalEvents.ts +++ b/packages/lexical/src/LexicalEvents.ts @@ -59,7 +59,6 @@ import { KEY_TAB_COMMAND, MOVE_TO_END, MOVE_TO_START, - ParagraphNode, PASTE_COMMAND, REDO_COMMAND, REMOVE_TEXT_COMMAND, @@ -69,8 +68,6 @@ import { import {KEY_MODIFIER_COMMAND, SELECT_ALL_COMMAND} from './LexicalCommands'; import { COMPOSITION_START_CHAR, - DOM_ELEMENT_TYPE, - DOM_TEXT_TYPE, DOUBLE_LINE_BREAK, IS_ALL_FORMATTING, } from './LexicalConstants'; @@ -92,6 +89,7 @@ import { doesContainGrapheme, getAnchorTextFromDOM, getDOMSelection, + getDOMSelectionFromTarget, getDOMTextNode, getEditorPropertyFromDOMNode, getEditorsToPropagate, @@ -109,8 +107,10 @@ import { isDeleteWordBackward, isDeleteWordForward, isDOMNode, + isDOMTextNode, isEscape, isFirefoxClipboardEvents, + isHTMLElement, isItalic, isLexicalEditor, isLineBreak, @@ -254,9 +254,8 @@ function shouldSkipSelectionChange( offset: number, ): boolean { return ( - domNode !== null && + isDOMTextNode(domNode) && domNode.nodeValue !== null && - domNode.nodeType === DOM_TEXT_TYPE && offset !== 0 && offset !== domNode.nodeValue.length ); @@ -349,11 +348,15 @@ function onSelectionChange( selection.format = anchorNode.getFormat(); selection.style = anchorNode.getStyle(); } else if (anchor.type === 'element' && !isRootTextContentEmpty) { + invariant( + $isElementNode(anchorNode), + 'Point.getNode() must return ElementNode when type is element', + ); const lastNode = anchor.getNode(); selection.style = ''; if ( - lastNode instanceof ParagraphNode && - lastNode.getChildrenSize() === 0 + // This previously applied to all ParagraphNode + lastNode.isEmpty() ) { selection.format = lastNode.getTextFormat(); selection.style = lastNode.getTextStyle(); @@ -455,21 +458,18 @@ function onClick(event: PointerEvent, editor: LexicalEditor): void { // This is used to update the selection on touch devices when the user clicks on text after a // node selection. See isSelectionChangeFromMouseDown for the inverse const domAnchorNode = domSelection.anchorNode; - if (domAnchorNode !== null) { - const nodeType = domAnchorNode.nodeType; - // If the user is attempting to click selection back onto text, then - // we should attempt create a range selection. - // When we click on an empty paragraph node or the end of a paragraph that ends - // with an image/poll, the nodeType will be ELEMENT_NODE - if (nodeType === DOM_ELEMENT_TYPE || nodeType === DOM_TEXT_TYPE) { - const newSelection = $internalCreateRangeSelection( - lastSelection, - domSelection, - editor, - event, - ); - $setSelection(newSelection); - } + // If the user is attempting to click selection back onto text, then + // we should attempt create a range selection. + // When we click on an empty paragraph node or the end of a paragraph that ends + // with an image/poll, the nodeType will be ELEMENT_NODE + if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) { + const newSelection = $internalCreateRangeSelection( + lastSelection, + domSelection, + editor, + event, + ); + $setSelection(newSelection); } } } @@ -1133,14 +1133,7 @@ function getRootElementRemoveHandles( const activeNestedEditorsMap: Map = new Map(); function onDocumentSelectionChange(event: Event): void { - const target = event.target as null | Element | Document; - const targetWindow = - target == null - ? null - : target.nodeType === 9 - ? (target as Document).defaultView - : (target as Element).ownerDocument.defaultView; - const domSelection = getDOMSelection(targetWindow); + const domSelection = getDOMSelectionFromTarget(event.target); if (domSelection === null) { return; } @@ -1154,24 +1147,19 @@ function onDocumentSelectionChange(event: Event): void { updateEditor(nextActiveEditor, () => { const lastSelection = $getPreviousSelection(); const domAnchorNode = domSelection.anchorNode; - if (domAnchorNode === null) { - return; - } - const nodeType = domAnchorNode.nodeType; - // If the user is attempting to click selection back onto text, then - // we should attempt create a range selection. - // When we click on an empty paragraph node or the end of a paragraph that ends - // with an image/poll, the nodeType will be ELEMENT_NODE - if (nodeType !== DOM_ELEMENT_TYPE && nodeType !== DOM_TEXT_TYPE) { - return; + if (isHTMLElement(domAnchorNode) || isDOMTextNode(domAnchorNode)) { + // If the user is attempting to click selection back onto text, then + // we should attempt create a range selection. + // When we click on an empty paragraph node or the end of a paragraph that ends + // with an image/poll, the nodeType will be ELEMENT_NODE + const newSelection = $internalCreateRangeSelection( + lastSelection, + domSelection, + nextActiveEditor, + event, + ); + $setSelection(newSelection); } - const newSelection = $internalCreateRangeSelection( - lastSelection, - domSelection, - nextActiveEditor, - event, - ); - $setSelection(newSelection); }); } diff --git a/packages/lexical/src/LexicalMutations.ts b/packages/lexical/src/LexicalMutations.ts index f4864f60f67..c14c301edcd 100644 --- a/packages/lexical/src/LexicalMutations.ts +++ b/packages/lexical/src/LexicalMutations.ts @@ -21,7 +21,6 @@ import { $isTextNode, $setSelection, } from '.'; -import {DOM_TEXT_TYPE} from './LexicalConstants'; import {updateEditor} from './LexicalUpdates'; import { $getNodeByKey, @@ -32,6 +31,7 @@ import { getParentElement, getWindow, internalGetRoot, + isDOMTextNode, isDOMUnmanaged, isFirefoxClipboardEvents, isHTMLElement, @@ -112,7 +112,7 @@ function shouldUpdateTextNodeFromMutation( return false; } } - return targetDOM.nodeType === DOM_TEXT_TYPE && targetNode.isAttached(); + return isDOMTextNode(targetDOM) && targetNode.isAttached(); } function $getNearestManagedNodePairFromDOMNode( @@ -183,14 +183,10 @@ export function $flushMutations( if ( shouldFlushTextMutations && $isTextNode(targetNode) && + isDOMTextNode(targetDOM) && shouldUpdateTextNodeFromMutation(selection, targetDOM, targetNode) ) { - $handleTextMutation( - // nodeType === DOM_TEXT_TYPE is a Text DOM node - targetDOM as Text, - targetNode, - editor, - ); + $handleTextMutation(targetDOM, targetNode, editor); } } else if (type === 'childList') { shouldRevertSelection = true; diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index fe6ebb316f6..b606d75aab0 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -27,7 +27,7 @@ import { SELECTION_CHANGE_COMMAND, TextNode, } from '.'; -import {DOM_ELEMENT_TYPE, TEXT_TYPE_TO_FORMAT} from './LexicalConstants'; +import {TEXT_TYPE_TO_FORMAT} from './LexicalConstants'; import { markCollapsedSelectionFormat, markSelectionChangeFromDOMUpdate, @@ -56,6 +56,7 @@ import { getElementByKeyOrThrow, getTextNodeOffset, INTERNAL_$isBlock, + isHTMLElement, isSelectionCapturedInDecoratorInput, isSelectionWithinEditor, removeDOMBlockCursorElement, @@ -2065,7 +2066,7 @@ function $internalResolveSelectionPoint( // need to figure out (using the offset) what text // node should be selected. - if (dom.nodeType === DOM_ELEMENT_TYPE) { + if (isHTMLElement(dom)) { // Resolve element to a ElementNode, or TextNode, or null let moveSelectionToEnd = false; // Given we're moving selection to another node, selection is @@ -2903,7 +2904,7 @@ export function updateDOMSelection( rootElement === document.activeElement ) { const selectionTarget: null | Range | HTMLElement | Text = - nextSelection instanceof RangeSelection && + $isRangeSelection(nextSelection) && nextSelection.anchor.type === 'element' ? (nextAnchorNode.childNodes[nextAnchorOffset] as HTMLElement | Text) || null diff --git a/packages/lexical/src/LexicalUtils.ts b/packages/lexical/src/LexicalUtils.ts index de2e019dd20..1cedfae5d78 100644 --- a/packages/lexical/src/LexicalUtils.ts +++ b/packages/lexical/src/LexicalUtils.ts @@ -55,6 +55,9 @@ import { } from '.'; import { COMPOSITION_SUFFIX, + DOM_DOCUMENT_FRAGMENT_TYPE, + DOM_DOCUMENT_TYPE, + DOM_ELEMENT_TYPE, DOM_TEXT_TYPE, HAS_DIRTY_NODES, LTR_REGEX, @@ -193,8 +196,20 @@ export function $isTokenOrSegmented(node: TextNode): boolean { return node.isToken() || node.isSegmented(); } -export function isDOMTextNode(node: Node): node is Text { - return node.nodeType === DOM_TEXT_TYPE; +/** + * @param node - The element being tested + * @returns Returns true if node is an DOM Text node, false otherwise. + */ +export function isDOMTextNode(node: unknown): node is Text { + return isDOMNode(node) && node.nodeType === DOM_TEXT_TYPE; +} + +/** + * @param node - The element being tested + * @returns Returns true if node is an DOM Document node, false otherwise. + */ +export function isDOMDocumentNode(node: unknown): node is Document { + return isDOMNode(node) && node.nodeType === DOM_DOCUMENT_TYPE; } export function getDOMTextNode(element: Node | null): Text | null { @@ -633,10 +648,7 @@ export function createUID(): string { } export function getAnchorTextFromDOM(anchorNode: Node): null | string { - if (anchorNode.nodeType === DOM_TEXT_TYPE) { - return anchorNode.nodeValue; - } - return null; + return isDOMTextNode(anchorNode) ? anchorNode.nodeValue : null; } export function $updateSelectedTextFromDOM( @@ -1304,15 +1316,25 @@ export function getParentElement(node: Node): HTMLElement | null { : parentElement; } +export function getDOMOwnerDocument( + target: EventTarget | null, +): Document | null { + return isDOMDocumentNode(target) + ? target + : isHTMLElement(target) + ? target.ownerDocument + : null; +} + export function scrollIntoViewIfNeeded( editor: LexicalEditor, selectionRect: DOMRect, rootElement: HTMLElement, ): void { - const doc = rootElement.ownerDocument; - const defaultView = doc.defaultView; + const doc = getDOMOwnerDocument(rootElement); + const defaultView = getDefaultView(doc); - if (defaultView === null) { + if (doc === null || defaultView === null) { return; } let {top: currentTop, bottom: currentBottom} = selectionRect; @@ -1414,9 +1436,9 @@ export function $hasAncestor( return false; } -export function getDefaultView(domElem: HTMLElement): Window | null { - const ownerDoc = domElem.ownerDocument; - return (ownerDoc && ownerDoc.defaultView) || null; +export function getDefaultView(domElem: EventTarget | null): Window | null { + const ownerDoc = getDOMOwnerDocument(domElem); + return ownerDoc ? ownerDoc.defaultView : null; } export function getWindow(editor: LexicalEditor): Window { @@ -1658,6 +1680,19 @@ export function getDOMSelection(targetWindow: null | Window): null | Selection { return !CAN_USE_DOM ? null : (targetWindow || window).getSelection(); } +/** + * Returns the selection for the defaultView of the ownerDocument of given EventTarget. + * + * @param eventTarget The node to get the selection from + * @returns a Selection or null + */ +export function getDOMSelectionFromTarget( + eventTarget: null | EventTarget, +): null | Selection { + const defaultView = getDefaultView(eventTarget); + return defaultView ? defaultView.getSelection() : null; +} + export function $splitNode( node: ElementNode, offset: number, @@ -1736,7 +1771,7 @@ export function isHTMLAnchorElement(x: unknown): x is HTMLAnchorElement { * @returns Returns true if x is an HTML element, false otherwise. */ export function isHTMLElement(x: unknown): x is HTMLElement { - return isDOMNode(x) && x.nodeType === 1; + return isDOMNode(x) && x.nodeType === DOM_ELEMENT_TYPE; } /** @@ -1757,7 +1792,7 @@ export function isDOMNode(x: unknown): x is Node { * @returns Returns true if x is a document fragment, false otherwise. */ export function isDocumentFragment(x: unknown): x is DocumentFragment { - return isDOMNode(x) && x.nodeType === 11; + return isDOMNode(x) && x.nodeType === DOM_DOCUMENT_FRAGMENT_TYPE; } /** diff --git a/packages/lexical/src/index.ts b/packages/lexical/src/index.ts index 0c7e1d7318b..c81ae795e72 100644 --- a/packages/lexical/src/index.ts +++ b/packages/lexical/src/index.ts @@ -180,12 +180,15 @@ export { $setCompositionKey, $setSelection, $splitNode, + getDOMOwnerDocument, getDOMSelection, + getDOMSelectionFromTarget, getDOMTextNode, getEditorPropertyFromDOMNode, getNearestEditorFromDOMNode, isBlockDomNode, isDocumentFragment, + isDOMDocumentNode, isDOMNode, isDOMTextNode, isDOMUnmanaged, diff --git a/packages/lexical/src/nodes/LexicalLineBreakNode.ts b/packages/lexical/src/nodes/LexicalLineBreakNode.ts index 2d28db08c12..e2b5b64427b 100644 --- a/packages/lexical/src/nodes/LexicalLineBreakNode.ts +++ b/packages/lexical/src/nodes/LexicalLineBreakNode.ts @@ -14,9 +14,12 @@ import type { SerializedLexicalNode, } from '../LexicalNode'; -import {DOM_TEXT_TYPE} from '../LexicalConstants'; import {LexicalNode} from '../LexicalNode'; -import {$applyNodeReplacement, isBlockDomNode} from '../LexicalUtils'; +import { + $applyNodeReplacement, + isBlockDomNode, + isDOMTextNode, +} from '../LexicalUtils'; export type SerializedLineBreakNode = SerializedLexicalNode; @@ -135,8 +138,5 @@ function isLastChildInBlockNode(node: Node): boolean { } function isWhitespaceDomTextNode(node: Node): boolean { - return ( - node.nodeType === DOM_TEXT_TYPE && - /^( |\t|\r?\n)+$/.test(node.textContent || '') - ); + return isDOMTextNode(node) && /^( |\t|\r?\n)+$/.test(node.textContent || ''); } diff --git a/packages/lexical/src/nodes/LexicalTextNode.ts b/packages/lexical/src/nodes/LexicalTextNode.ts index 7566a0416d4..9c7280960a2 100644 --- a/packages/lexical/src/nodes/LexicalTextNode.ts +++ b/packages/lexical/src/nodes/LexicalTextNode.ts @@ -29,8 +29,6 @@ import invariant from 'shared/invariant'; import { COMPOSITION_SUFFIX, DETAIL_TYPE_TO_DETAIL, - DOM_ELEMENT_TYPE, - DOM_TEXT_TYPE, IS_BOLD, IS_CODE, IS_DIRECTIONLESS, @@ -62,6 +60,7 @@ import { $setCompositionKey, getCachedClassNameArray, internalMarkSiblingsAsDirty, + isDOMTextNode, isHTMLElement, isInlineDomNode, toggleTextFormatType, @@ -1142,13 +1141,13 @@ function convertBringAttentionToElement( const preParentCache = new WeakMap(); function isNodePre(node: Node): boolean { - return ( - node.nodeName === 'PRE' || - (node.nodeType === DOM_ELEMENT_TYPE && - (node as HTMLElement).style !== undefined && - (node as HTMLElement).style.whiteSpace !== undefined && - (node as HTMLElement).style.whiteSpace.startsWith('pre')) - ); + if (!isHTMLElement(node)) { + return false; + } else if (node.nodeName === 'PRE') { + return true; + } + const whiteSpace = node.style.whiteSpace; + return typeof whiteSpace === 'string' && whiteSpace.startsWith('pre'); } export function findParentPreDOMNode(node: Node) { @@ -1264,8 +1263,8 @@ function findTextInLine(text: Text, forward: boolean): null | Text { node = parentElement; } node = sibling; - if (node.nodeType === DOM_ELEMENT_TYPE) { - const display = (node as HTMLElement).style.display; + if (isHTMLElement(node)) { + const display = node.style.display; if ( (display === '' && !isInlineDomNode(node)) || (display !== '' && !display.startsWith('inline')) @@ -1277,8 +1276,8 @@ function findTextInLine(text: Text, forward: boolean): null | Text { while ((descendant = forward ? node.firstChild : node.lastChild) !== null) { node = descendant; } - if (node.nodeType === DOM_TEXT_TYPE) { - return node as Text; + if (isDOMTextNode(node)) { + return node; } else if (node.nodeName === 'BR') { return null; }