From 992433b4193faf4de0d394d4806e172c4dfad4f2 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 17 Jul 2025 19:07:53 +0530 Subject: [PATCH 01/15] fix:handle the missing link on the draggable image --- .../src/plugins/ImagesPlugin/index.tsx | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx index 4097c58a47f..a825e9295f0 100644 --- a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx @@ -8,11 +8,13 @@ import type {JSX} from 'react'; +import {$createLinkNode, LinkNode} from '@lexical/link'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$wrapNodeInElement, mergeRegister} from '@lexical/utils'; import { $createParagraphNode, $createRangeSelection, + $getNodeByKey, $getSelection, $insertNodes, $isNodeSelection, @@ -272,6 +274,7 @@ function $onDragStart(event: DragEvent): boolean { if (!dataTransfer) { return false; } + dataTransfer.setData('text/plain', '_'); dataTransfer.setDragImage(img, 0, 0); dataTransfer.setData( @@ -306,25 +309,67 @@ function $onDragover(event: DragEvent): boolean { } function $onDrop(event: DragEvent, editor: LexicalEditor): boolean { + // Get the currently selected image node const node = $getImageNodeInSelection(); if (!node) { - return false; + return false; // No image node selected, exit early + } + + // Get the parent node's key + const parent_key = String(node.__parent); + let link = ''; + if (node) { + // Check if the parent node is a link + const nodelink = $getNodeByKey(parent_key); + if (nodelink?.__type === 'link') { + // Cast to LinkNode to safely access the URL + const linkNode = nodelink as LinkNode; + link = linkNode.__url || ''; // Extract the URL or default to empty string + } } + + // Retrieve image data from the drag event const data = getDragImageData(event); if (!data) { - return false; + return false; // No valid image data, exit early } + + // Prevent default browser drop behavior event.preventDefault(); + + // Check if the image can be dropped at the current location if (canDropImage(event)) { + // Get the drop range from the event const range = getDragSelection(event); + + // Remove the original image node node.remove(); + + // Create a new range selection for the drop position const rangeSelection = $createRangeSelection(); if (range !== null && range !== undefined) { + // Apply the drop range to the selection rangeSelection.applyDOMRange(range); } + + // Set the editor's selection to the new range $setSelection(rangeSelection); - editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); + + if (link) { + // If a link exists, wrap the image in a link node + editor.update(() => { + const linkNode = $createLinkNode(link); // Create a new link node + const imageNode = $createImageNode(data); // Create a new image node + linkNode.append(imageNode); // Set image as child of link node + $insertNodes([linkNode]); // Insert the link node (with image) into the editor + }); + } else { + // If no link, insert the image directly + editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); + } } + + // Indicate that the drop event was handled return true; } From fcc767ff5c623f20dbc6fc9ff2a27ff9962c22aa Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 17 Jul 2025 23:34:05 +0530 Subject: [PATCH 02/15] fix:Code Block formatting applies to unintended adjacent lines --- .../src/plugins/ToolbarPlugin/utils.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts index 6c1501a7c68..3407f085afd 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts @@ -24,6 +24,7 @@ import {$isTableSelection} from '@lexical/table'; import {$getNearestBlockElementAncestorOrThrow} from '@lexical/utils'; import { $createParagraphNode, + $createTextNode, $getSelection, $isRangeSelection, $isTextNode, @@ -214,7 +215,7 @@ export const formatQuote = (editor: LexicalEditor, blockType: string) => { export const formatCode = (editor: LexicalEditor, blockType: string) => { if (blockType !== 'code') { editor.update(() => { - let selection = $getSelection(); + const selection = $getSelection(); if (!selection) { return; } @@ -223,11 +224,14 @@ export const formatCode = (editor: LexicalEditor, blockType: string) => { } else { const textContent = selection.getTextContent(); const codeNode = $createCodeNode(); - selection.insertNodes([codeNode]); - selection = $getSelection(); - if ($isRangeSelection(selection)) { - selection.insertRawText(textContent); - } + const anchorNode = selection.anchor.getNode(); + + // Insert code node just below the selected node (as its sibling) + anchorNode.insertAfter(codeNode); + // Insert the selected text into the code node + codeNode.append($createTextNode(textContent)); + // Remove the selected content + selection.removeText(); } }); } From d59b9254ed1eae8cc942dfadc6be0400f3b1caed Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 17 Jul 2025 23:42:49 +0530 Subject: [PATCH 03/15] revert_changes --- .../src/plugins/ImagesPlugin/index.tsx | 51 ++----------------- 1 file changed, 3 insertions(+), 48 deletions(-) diff --git a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx index a825e9295f0..4097c58a47f 100644 --- a/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ImagesPlugin/index.tsx @@ -8,13 +8,11 @@ import type {JSX} from 'react'; -import {$createLinkNode, LinkNode} from '@lexical/link'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$wrapNodeInElement, mergeRegister} from '@lexical/utils'; import { $createParagraphNode, $createRangeSelection, - $getNodeByKey, $getSelection, $insertNodes, $isNodeSelection, @@ -274,7 +272,6 @@ function $onDragStart(event: DragEvent): boolean { if (!dataTransfer) { return false; } - dataTransfer.setData('text/plain', '_'); dataTransfer.setDragImage(img, 0, 0); dataTransfer.setData( @@ -309,67 +306,25 @@ function $onDragover(event: DragEvent): boolean { } function $onDrop(event: DragEvent, editor: LexicalEditor): boolean { - // Get the currently selected image node const node = $getImageNodeInSelection(); if (!node) { - return false; // No image node selected, exit early - } - - // Get the parent node's key - const parent_key = String(node.__parent); - let link = ''; - if (node) { - // Check if the parent node is a link - const nodelink = $getNodeByKey(parent_key); - if (nodelink?.__type === 'link') { - // Cast to LinkNode to safely access the URL - const linkNode = nodelink as LinkNode; - link = linkNode.__url || ''; // Extract the URL or default to empty string - } + return false; } - - // Retrieve image data from the drag event const data = getDragImageData(event); if (!data) { - return false; // No valid image data, exit early + return false; } - - // Prevent default browser drop behavior event.preventDefault(); - - // Check if the image can be dropped at the current location if (canDropImage(event)) { - // Get the drop range from the event const range = getDragSelection(event); - - // Remove the original image node node.remove(); - - // Create a new range selection for the drop position const rangeSelection = $createRangeSelection(); if (range !== null && range !== undefined) { - // Apply the drop range to the selection rangeSelection.applyDOMRange(range); } - - // Set the editor's selection to the new range $setSelection(rangeSelection); - - if (link) { - // If a link exists, wrap the image in a link node - editor.update(() => { - const linkNode = $createLinkNode(link); // Create a new link node - const imageNode = $createImageNode(data); // Create a new image node - linkNode.append(imageNode); // Set image as child of link node - $insertNodes([linkNode]); // Insert the link node (with image) into the editor - }); - } else { - // If no link, insert the image directly - editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); - } + editor.dispatchCommand(INSERT_IMAGE_COMMAND, data); } - - // Indicate that the drop event was handled return true; } From 354ead86dd5a91dff456a013dad931c822fb531a Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Fri, 18 Jul 2025 19:19:33 +0530 Subject: [PATCH 04/15] table break changes --- .../src/LexicalTableSelectionHelpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 6c591e1e88e..966c86afd67 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -691,10 +691,23 @@ export function applyTableHandlers( selection, tableNode, ); + const lastChild = tableCellNode.getLastChild(); + const isLastChildPageBreak = + lastChild && lastChild.getType() === 'page-break'; // Adjust 'page-break' to the actual node type for page breaks if (edgePosition) { $insertParagraphAtTableEdge(edgePosition, tableNode, [ $createTextNode(payload), ]); + return true; + } else if (isLastChildPageBreak && edgePosition === undefined) { + // Create a new paragraph node with the payload text + const newParagraph = $createParagraphNode(); + newParagraph.append($createTextNode(payload)); + // Insert the new paragraph after the last child (page-break) + lastChild.insertAfter(newParagraph); + // Optionally, move the selection to the new paragraph + newParagraph.selectEnd(); + return true; } } From baead425c09984d74ab09cab666c2f7996c31db2 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Fri, 18 Jul 2025 19:23:52 +0530 Subject: [PATCH 05/15] utils changed to the main one --- .../src/plugins/ToolbarPlugin/utils.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts index 3407f085afd..6c1501a7c68 100644 --- a/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts +++ b/packages/lexical-playground/src/plugins/ToolbarPlugin/utils.ts @@ -24,7 +24,6 @@ import {$isTableSelection} from '@lexical/table'; import {$getNearestBlockElementAncestorOrThrow} from '@lexical/utils'; import { $createParagraphNode, - $createTextNode, $getSelection, $isRangeSelection, $isTextNode, @@ -215,7 +214,7 @@ export const formatQuote = (editor: LexicalEditor, blockType: string) => { export const formatCode = (editor: LexicalEditor, blockType: string) => { if (blockType !== 'code') { editor.update(() => { - const selection = $getSelection(); + let selection = $getSelection(); if (!selection) { return; } @@ -224,14 +223,11 @@ export const formatCode = (editor: LexicalEditor, blockType: string) => { } else { const textContent = selection.getTextContent(); const codeNode = $createCodeNode(); - const anchorNode = selection.anchor.getNode(); - - // Insert code node just below the selected node (as its sibling) - anchorNode.insertAfter(codeNode); - // Insert the selected text into the code node - codeNode.append($createTextNode(textContent)); - // Remove the selected content - selection.removeText(); + selection.insertNodes([codeNode]); + selection = $getSelection(); + if ($isRangeSelection(selection)) { + selection.insertRawText(textContent); + } } }); } From e2caf5a894f4f739b7740fd61f0e09ffb8fd0be5 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Wed, 23 Jul 2025 22:22:38 +0530 Subject: [PATCH 06/15] equation_changes --- .../src/nodes/InlineParagraphNode.tsx | 212 ++++++++++++++++++ .../src/nodes/PlaygroundNodes.ts | 2 + .../src/plugins/EquationsPlugin/index.tsx | 10 +- 3 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 packages/lexical-playground/src/nodes/InlineParagraphNode.tsx diff --git a/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx b/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx new file mode 100644 index 00000000000..cda15e52f6e --- /dev/null +++ b/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx @@ -0,0 +1,212 @@ +/** + * 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 { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + EditorThemeClasses, + ElementFormatType, + KlassConstructor, + LexicalEditor, + RangeSelection, + SerializedParagraphNode, +} from 'lexical'; + +import { + $applyNodeReplacement, + $createParagraphNode, + $isTextNode, + ElementNode, +} from 'lexical'; +import normalizeClassNames from 'shared/normalizeClassNames'; + +export const DOM_ELEMENT_TYPE = 1; + +export function setNodeIndentFromDOM( + elementDom: HTMLElement, + elementNode: ElementNode, +) { + const indentSize = parseInt(elementDom.style.paddingInlineStart, 10) || 0; + const indent = Math.round(indentSize / 40); + elementNode.setIndent(indent); +} + +export function getCachedClassNameArray( + classNamesTheme: EditorThemeClasses, + classNameThemeType: string, +): Array { + 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 isDOMNode(x: unknown): x is Node { + return ( + typeof x === 'object' && + x !== null && + 'nodeType' in x && + typeof x.nodeType === 'number' + ); +} + +export function isHTMLElement(x: unknown): x is HTMLElement { + return isDOMNode(x) && x.nodeType === DOM_ELEMENT_TYPE; +} + +export class InlineParagraphNode extends ElementNode { + ['constructor']!: KlassConstructor; + + static getType(): string { + return 'inline-paragraph'; + } + + static clone(node: InlineParagraphNode): InlineParagraphNode { + return new InlineParagraphNode(node.__key); + } + + // View + + createDOM(config: EditorConfig): HTMLElement { + const dom = document.createElement('p'); + const classNames = getCachedClassNameArray(config.theme, 'paragraph'); + if (classNames !== undefined) { + const domClassList = dom.classList; + domClassList.add(...classNames); + } + // Apply inline styles for width, minWidth, and display + dom.style.width = 'auto'; + dom.style.minWidth = '1px'; + dom.style.display = 'inline-flex'; + return dom; + } + + updateDOM( + prevNode: InlineParagraphNode, + dom: HTMLElement, + config: EditorConfig, + ): boolean { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + p: (node: Node) => ({ + conversion: $convertParagraphElement, + priority: 0, + }), + }; + } + + exportDOM(editor: LexicalEditor): DOMExportOutput { + const {element} = super.exportDOM(editor); + + if (isHTMLElement(element)) { + if (this.isEmpty()) { + element.append(document.createElement('br')); + } + + const formatType = this.getFormatType(); + if (formatType) { + element.style.textAlign = formatType; + } + } + + return { + element, + }; + } + + static importJSON( + serializedNode: SerializedParagraphNode, + ): InlineParagraphNode { + return $createInlineParagraphNode().updateFromJSON(serializedNode); + } + + exportJSON(): SerializedParagraphNode { + return { + ...super.exportJSON(), + // These are included explicitly for backwards compatibility + textFormat: this.getTextFormat(), + textStyle: this.getTextStyle(), + type: 'inline-paragraph', + }; + } + + // Mutation + + insertNewAfter( + rangeSelection: RangeSelection, + restoreSelection: boolean, + ): InlineParagraphNode { + const newElement = $createInlineParagraphNode(); + newElement.setTextFormat(rangeSelection.format); + newElement.setTextStyle(rangeSelection.style); + const direction = this.getDirection(); + newElement.setDirection(direction); + newElement.setFormat(this.getFormatType()); + newElement.setStyle(this.getStyle()); + this.insertAfter(newElement, restoreSelection); + return newElement; + } + + collapseAtStart(): boolean { + const children = this.getChildren(); + // If we have an empty (trimmed) first paragraph and try and remove it, + // delete the paragraph as long as we have another sibling to go to + if ( + children.length === 0 || + ($isTextNode(children[0]) && children[0].getTextContent().trim() === '') + ) { + const nextSibling = this.getNextSibling(); + if (nextSibling !== null) { + this.selectNext(); + this.remove(); + return true; + } + const prevSibling = this.getPreviousSibling(); + if (prevSibling !== null) { + this.selectPrevious(); + this.remove(); + return true; + } + } + return false; + } +} + +function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { + const node = $createParagraphNode(); + if (element.style) { + node.setFormat(element.style.textAlign as ElementFormatType); + setNodeIndentFromDOM(element, node); + } + return {node}; +} + +export function $createInlineParagraphNode(): InlineParagraphNode { + return $applyNodeReplacement(new InlineParagraphNode()); +} diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index 1048fb82ce2..b1aea68c1b1 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -28,6 +28,7 @@ import {ExcalidrawNode} from './ExcalidrawNode'; import {FigmaNode} from './FigmaNode'; import {ImageNode} from './ImageNode'; import {InlineImageNode} from './InlineImageNode/InlineImageNode'; +import {InlineParagraphNode} from './InlineParagraphNode'; import {KeywordNode} from './KeywordNode'; import {LayoutContainerNode} from './LayoutContainerNode'; import {LayoutItemNode} from './LayoutItemNode'; @@ -75,6 +76,7 @@ const PlaygroundNodes: Array> = [ LayoutContainerNode, LayoutItemNode, SpecialTextNode, + InlineParagraphNode, ]; export default PlaygroundNodes; diff --git a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx index 46f1bf7ae1e..307ca3987d3 100644 --- a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx @@ -13,7 +13,6 @@ import 'katex/dist/katex.css'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$wrapNodeInElement} from '@lexical/utils'; import { - $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR, @@ -22,9 +21,9 @@ import { LexicalEditor, } from 'lexical'; import {useCallback, useEffect} from 'react'; -import * as React from 'react'; import {$createEquationNode, EquationNode} from '../../nodes/EquationNode'; +import {$createInlineParagraphNode} from '../../nodes/InlineParagraphNode'; import KatexEquationAlterer from '../../ui/KatexEquationAlterer'; type CommandPayload = { @@ -68,10 +67,15 @@ export default function EquationsPlugin(): JSX.Element | null { (payload) => { const {equation, inline} = payload; const equationNode = $createEquationNode(equation, inline); + const paragraphNode = $createInlineParagraphNode(); $insertNodes([equationNode]); + equationNode.insertAfter(paragraphNode); if ($isRootOrShadowRoot(equationNode.getParentOrThrow())) { - $wrapNodeInElement(equationNode, $createParagraphNode).selectEnd(); + $wrapNodeInElement( + equationNode, + $createInlineParagraphNode, + ).selectEnd(); } return true; From 9ceaaec97ee4453bc14f1658e0c2b33cc2e6a7df Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Wed, 23 Jul 2025 22:26:12 +0530 Subject: [PATCH 07/15] refactor: simplify applyTableHandlers by removing page-break handling logic --- .../src/LexicalTableSelectionHelpers.ts | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts index 966c86afd67..6c591e1e88e 100644 --- a/packages/lexical-table/src/LexicalTableSelectionHelpers.ts +++ b/packages/lexical-table/src/LexicalTableSelectionHelpers.ts @@ -691,23 +691,10 @@ export function applyTableHandlers( selection, tableNode, ); - const lastChild = tableCellNode.getLastChild(); - const isLastChildPageBreak = - lastChild && lastChild.getType() === 'page-break'; // Adjust 'page-break' to the actual node type for page breaks if (edgePosition) { $insertParagraphAtTableEdge(edgePosition, tableNode, [ $createTextNode(payload), ]); - return true; - } else if (isLastChildPageBreak && edgePosition === undefined) { - // Create a new paragraph node with the payload text - const newParagraph = $createParagraphNode(); - newParagraph.append($createTextNode(payload)); - // Insert the new paragraph after the last child (page-break) - lastChild.insertAfter(newParagraph); - // Optionally, move the selection to the new paragraph - newParagraph.selectEnd(); - return true; } } From 5ea306f6d146fcae72a89a9a072576c3e7be2501 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 24 Jul 2025 23:47:35 +0530 Subject: [PATCH 08/15] feat: add style handling for text nodes in $createNodesFromDOM --- packages/lexical-html/src/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index cd5946f8341..4fc127f92c4 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -14,6 +14,7 @@ import type { ElementFormatType, LexicalEditor, LexicalNode, + TextNode, } from 'lexical'; import {$sliceSelectedTextNodeContent} from '@lexical/selection'; @@ -222,6 +223,23 @@ function $createNodesFromDOM( : null; let postTransform = null; + if ( + node.nodeName === '#text' && + (node.nodeValue !== '' || node.nodeValue !== null) && + ((node as HTMLElement).parentNode as HTMLElement).getAttribute('style') !== + null + ) { + if (transformOutput) { + const firstTextNode = (transformOutput.node as TextNode[])[0]; + if (firstTextNode) { + firstTextNode.__style = + ((node as HTMLElement).parentNode as HTMLElement).getAttribute( + 'style', + ) || ''; + } + } + } + if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; From 3cbf85cf612af53f4c5515158fb91fc3ff69249e Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 24 Jul 2025 23:53:17 +0530 Subject: [PATCH 09/15] refactor: remove InlineParagraphNode and update EquationsPlugin to use $createParagraphNode --- .../src/nodes/InlineParagraphNode.tsx | 212 ------------------ .../src/nodes/PlaygroundNodes.ts | 2 - .../src/plugins/EquationsPlugin/index.tsx | 10 +- 3 files changed, 3 insertions(+), 221 deletions(-) delete mode 100644 packages/lexical-playground/src/nodes/InlineParagraphNode.tsx diff --git a/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx b/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx deleted file mode 100644 index cda15e52f6e..00000000000 --- a/packages/lexical-playground/src/nodes/InlineParagraphNode.tsx +++ /dev/null @@ -1,212 +0,0 @@ -/** - * 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 { - DOMConversionMap, - DOMConversionOutput, - DOMExportOutput, - EditorConfig, - EditorThemeClasses, - ElementFormatType, - KlassConstructor, - LexicalEditor, - RangeSelection, - SerializedParagraphNode, -} from 'lexical'; - -import { - $applyNodeReplacement, - $createParagraphNode, - $isTextNode, - ElementNode, -} from 'lexical'; -import normalizeClassNames from 'shared/normalizeClassNames'; - -export const DOM_ELEMENT_TYPE = 1; - -export function setNodeIndentFromDOM( - elementDom: HTMLElement, - elementNode: ElementNode, -) { - const indentSize = parseInt(elementDom.style.paddingInlineStart, 10) || 0; - const indent = Math.round(indentSize / 40); - elementNode.setIndent(indent); -} - -export function getCachedClassNameArray( - classNamesTheme: EditorThemeClasses, - classNameThemeType: string, -): Array { - 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 isDOMNode(x: unknown): x is Node { - return ( - typeof x === 'object' && - x !== null && - 'nodeType' in x && - typeof x.nodeType === 'number' - ); -} - -export function isHTMLElement(x: unknown): x is HTMLElement { - return isDOMNode(x) && x.nodeType === DOM_ELEMENT_TYPE; -} - -export class InlineParagraphNode extends ElementNode { - ['constructor']!: KlassConstructor; - - static getType(): string { - return 'inline-paragraph'; - } - - static clone(node: InlineParagraphNode): InlineParagraphNode { - return new InlineParagraphNode(node.__key); - } - - // View - - createDOM(config: EditorConfig): HTMLElement { - const dom = document.createElement('p'); - const classNames = getCachedClassNameArray(config.theme, 'paragraph'); - if (classNames !== undefined) { - const domClassList = dom.classList; - domClassList.add(...classNames); - } - // Apply inline styles for width, minWidth, and display - dom.style.width = 'auto'; - dom.style.minWidth = '1px'; - dom.style.display = 'inline-flex'; - return dom; - } - - updateDOM( - prevNode: InlineParagraphNode, - dom: HTMLElement, - config: EditorConfig, - ): boolean { - return false; - } - - static importDOM(): DOMConversionMap | null { - return { - p: (node: Node) => ({ - conversion: $convertParagraphElement, - priority: 0, - }), - }; - } - - exportDOM(editor: LexicalEditor): DOMExportOutput { - const {element} = super.exportDOM(editor); - - if (isHTMLElement(element)) { - if (this.isEmpty()) { - element.append(document.createElement('br')); - } - - const formatType = this.getFormatType(); - if (formatType) { - element.style.textAlign = formatType; - } - } - - return { - element, - }; - } - - static importJSON( - serializedNode: SerializedParagraphNode, - ): InlineParagraphNode { - return $createInlineParagraphNode().updateFromJSON(serializedNode); - } - - exportJSON(): SerializedParagraphNode { - return { - ...super.exportJSON(), - // These are included explicitly for backwards compatibility - textFormat: this.getTextFormat(), - textStyle: this.getTextStyle(), - type: 'inline-paragraph', - }; - } - - // Mutation - - insertNewAfter( - rangeSelection: RangeSelection, - restoreSelection: boolean, - ): InlineParagraphNode { - const newElement = $createInlineParagraphNode(); - newElement.setTextFormat(rangeSelection.format); - newElement.setTextStyle(rangeSelection.style); - const direction = this.getDirection(); - newElement.setDirection(direction); - newElement.setFormat(this.getFormatType()); - newElement.setStyle(this.getStyle()); - this.insertAfter(newElement, restoreSelection); - return newElement; - } - - collapseAtStart(): boolean { - const children = this.getChildren(); - // If we have an empty (trimmed) first paragraph and try and remove it, - // delete the paragraph as long as we have another sibling to go to - if ( - children.length === 0 || - ($isTextNode(children[0]) && children[0].getTextContent().trim() === '') - ) { - const nextSibling = this.getNextSibling(); - if (nextSibling !== null) { - this.selectNext(); - this.remove(); - return true; - } - const prevSibling = this.getPreviousSibling(); - if (prevSibling !== null) { - this.selectPrevious(); - this.remove(); - return true; - } - } - return false; - } -} - -function $convertParagraphElement(element: HTMLElement): DOMConversionOutput { - const node = $createParagraphNode(); - if (element.style) { - node.setFormat(element.style.textAlign as ElementFormatType); - setNodeIndentFromDOM(element, node); - } - return {node}; -} - -export function $createInlineParagraphNode(): InlineParagraphNode { - return $applyNodeReplacement(new InlineParagraphNode()); -} diff --git a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts index b1aea68c1b1..1048fb82ce2 100644 --- a/packages/lexical-playground/src/nodes/PlaygroundNodes.ts +++ b/packages/lexical-playground/src/nodes/PlaygroundNodes.ts @@ -28,7 +28,6 @@ import {ExcalidrawNode} from './ExcalidrawNode'; import {FigmaNode} from './FigmaNode'; import {ImageNode} from './ImageNode'; import {InlineImageNode} from './InlineImageNode/InlineImageNode'; -import {InlineParagraphNode} from './InlineParagraphNode'; import {KeywordNode} from './KeywordNode'; import {LayoutContainerNode} from './LayoutContainerNode'; import {LayoutItemNode} from './LayoutItemNode'; @@ -76,7 +75,6 @@ const PlaygroundNodes: Array> = [ LayoutContainerNode, LayoutItemNode, SpecialTextNode, - InlineParagraphNode, ]; export default PlaygroundNodes; diff --git a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx index 307ca3987d3..46f1bf7ae1e 100644 --- a/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/EquationsPlugin/index.tsx @@ -13,6 +13,7 @@ import 'katex/dist/katex.css'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$wrapNodeInElement} from '@lexical/utils'; import { + $createParagraphNode, $insertNodes, $isRootOrShadowRoot, COMMAND_PRIORITY_EDITOR, @@ -21,9 +22,9 @@ import { LexicalEditor, } from 'lexical'; import {useCallback, useEffect} from 'react'; +import * as React from 'react'; import {$createEquationNode, EquationNode} from '../../nodes/EquationNode'; -import {$createInlineParagraphNode} from '../../nodes/InlineParagraphNode'; import KatexEquationAlterer from '../../ui/KatexEquationAlterer'; type CommandPayload = { @@ -67,15 +68,10 @@ export default function EquationsPlugin(): JSX.Element | null { (payload) => { const {equation, inline} = payload; const equationNode = $createEquationNode(equation, inline); - const paragraphNode = $createInlineParagraphNode(); $insertNodes([equationNode]); - equationNode.insertAfter(paragraphNode); if ($isRootOrShadowRoot(equationNode.getParentOrThrow())) { - $wrapNodeInElement( - equationNode, - $createInlineParagraphNode, - ).selectEnd(); + $wrapNodeInElement(equationNode, $createParagraphNode).selectEnd(); } return true; From 4785a283efb6c8c8a4cc64d7bdef64791d5923ea Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Fri, 25 Jul 2025 10:12:11 +0530 Subject: [PATCH 10/15] fix: ensure style is applied to the first text node in $createNodesFromDOM --- packages/lexical-html/src/index.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 4fc127f92c4..181f7856651 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -230,12 +230,14 @@ function $createNodesFromDOM( null ) { if (transformOutput) { - const firstTextNode = (transformOutput.node as TextNode[])[0]; - if (firstTextNode) { - firstTextNode.__style = - ((node as HTMLElement).parentNode as HTMLElement).getAttribute( - 'style', - ) || ''; + if (transformOutput.node) { + const firstTextNode = (transformOutput.node as TextNode[])[0]; + if (firstTextNode) { + firstTextNode.__style = + ((node as HTMLElement).parentNode as HTMLElement).getAttribute( + 'style', + ) || ''; + } } } } From 67719f7c14a65d065a422647860fe2002833ad26 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Tue, 16 Dec 2025 22:45:11 +0530 Subject: [PATCH 11/15] fix: prevent deletion when the previous element is a table in RangeSelection --- packages/lexical/src/LexicalSelection.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 7df7d50b4d2..2be9573ccc1 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1734,12 +1734,17 @@ export class RangeSelection implements BaseSelection { const direction = isBackward ? 'previous' : 'next'; const initialCaret = $caretFromPoint(anchor, direction); const initialRange = $extendCaretToRange(initialCaret); + const elementBefore = + anchorNode.__prev && $getNodeByKey(anchorNode.__prev); if ( initialRange .getTextSlices() .every((slice) => slice === null || slice.distance === 0) ) { // There's no text in the direction of the deletion so we can explore our options + if (elementBefore && elementBefore.__type === 'table') { + return; + } let state: | {type: 'initial'} | { From eabdd08205a610c402d6f037b9cf67c8ece33c54 Mon Sep 17 00:00:00 2001 From: Ajinkya Nikam Date: Tue, 16 Dec 2025 22:49:40 +0530 Subject: [PATCH 12/15] Refactor text node style handling logic Removed unnecessary checks and code related to text node styles. --- packages/lexical-html/src/index.ts | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/packages/lexical-html/src/index.ts b/packages/lexical-html/src/index.ts index 181f7856651..cd5946f8341 100644 --- a/packages/lexical-html/src/index.ts +++ b/packages/lexical-html/src/index.ts @@ -14,7 +14,6 @@ import type { ElementFormatType, LexicalEditor, LexicalNode, - TextNode, } from 'lexical'; import {$sliceSelectedTextNodeContent} from '@lexical/selection'; @@ -223,25 +222,6 @@ function $createNodesFromDOM( : null; let postTransform = null; - if ( - node.nodeName === '#text' && - (node.nodeValue !== '' || node.nodeValue !== null) && - ((node as HTMLElement).parentNode as HTMLElement).getAttribute('style') !== - null - ) { - if (transformOutput) { - if (transformOutput.node) { - const firstTextNode = (transformOutput.node as TextNode[])[0]; - if (firstTextNode) { - firstTextNode.__style = - ((node as HTMLElement).parentNode as HTMLElement).getAttribute( - 'style', - ) || ''; - } - } - } - } - if (transformOutput !== null) { postTransform = transformOutput.after; const transformNodes = transformOutput.node; From 5bff69cb07a6295d2fc877b941e962d40c1bda12 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Thu, 18 Dec 2025 23:32:36 +0530 Subject: [PATCH 13/15] fix: prevent deletion of paragraph before table in RangeSelection --- packages/lexical/src/LexicalSelection.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 2be9573ccc1..5a7a52476e3 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1734,15 +1734,16 @@ export class RangeSelection implements BaseSelection { const direction = isBackward ? 'previous' : 'next'; const initialCaret = $caretFromPoint(anchor, direction); const initialRange = $extendCaretToRange(initialCaret); - const elementBefore = - anchorNode.__prev && $getNodeByKey(anchorNode.__prev); + const elementBefore = anchorNode.getPreviousSibling(); if ( initialRange .getTextSlices() .every((slice) => slice === null || slice.distance === 0) ) { // There's no text in the direction of the deletion so we can explore our options - if (elementBefore && elementBefore.__type === 'table') { + + // Case where we prevent the deletion of paragraph just before table + if (elementBefore && elementBefore.getType() === 'table') { return; } let state: From 26b86a3f98090ba2f8d0fc920e5e6af5178b4d44 Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Wed, 25 Feb 2026 13:05:41 +0530 Subject: [PATCH 14/15] fix: handle insertion of nodes within links in $insertGeneratedNodes --- packages/lexical-clipboard/src/clipboard.ts | 51 ++++++++++++++++++++- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/packages/lexical-clipboard/src/clipboard.ts b/packages/lexical-clipboard/src/clipboard.ts index e531235a1f8..e53c9ca450b 100644 --- a/packages/lexical-clipboard/src/clipboard.ts +++ b/packages/lexical-clipboard/src/clipboard.ts @@ -225,15 +225,62 @@ export function $insertGeneratedNodes( nodes: Array, selection: BaseSelection, ): void { + // If the selection is entirely within a link, + // we want to insert the text content of the nodes into the link + function isSameLinkParent(sel: BaseSelection): boolean { + const points = sel.getStartEndPoints(); + if (points == null) { + return false; + } + + const startPoint = points[0]; + const endPoint = points[1]; + + if (startPoint == null || endPoint == null) { + return false; + } + + const startNode = startPoint.getNode(); + const endNode = endPoint.getNode(); + + if (startNode == null || endNode == null) { + return false; + } + + const parentElementBefore = startNode.getParent(); + const parentElementAfter = endNode.getParent(); + + if (parentElementBefore == null || parentElementAfter == null) { + return false; + } + + return ( + parentElementAfter.getType() === 'link' && + parentElementAfter.getType() === parentElementBefore.getType() + ); + } + if ( !editor.dispatchCommand(SELECTION_INSERT_CLIPBOARD_NODES_COMMAND, { nodes, selection, }) ) { - selection.insertNodes(nodes); - $updateSelectionOnInsert(selection); + if (isSameLinkParent(selection)) { + // Only insert the content of the first text node + const textNodes = nodes.filter((node) => $isTextNode(node)); + + if (textNodes.length > 0) { + const firstText = textNodes[0]; + selection.insertText(firstText.getTextContent()); + $updateSelectionOnInsert(selection); + } + } else { + selection.insertNodes(nodes); + $updateSelectionOnInsert(selection); + } } + return; } From 8dceaecd290df287181a374a36cab3943cfc7c2d Mon Sep 17 00:00:00 2001 From: Ajinkya Date: Wed, 25 Feb 2026 13:47:05 +0530 Subject: [PATCH 15/15] fix: remove unnecessary check for deletion prevention before table in RangeSelection --- packages/lexical/src/LexicalSelection.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/lexical/src/LexicalSelection.ts b/packages/lexical/src/LexicalSelection.ts index 5a7a52476e3..7df7d50b4d2 100644 --- a/packages/lexical/src/LexicalSelection.ts +++ b/packages/lexical/src/LexicalSelection.ts @@ -1734,18 +1734,12 @@ export class RangeSelection implements BaseSelection { const direction = isBackward ? 'previous' : 'next'; const initialCaret = $caretFromPoint(anchor, direction); const initialRange = $extendCaretToRange(initialCaret); - const elementBefore = anchorNode.getPreviousSibling(); if ( initialRange .getTextSlices() .every((slice) => slice === null || slice.distance === 0) ) { // There's no text in the direction of the deletion so we can explore our options - - // Case where we prevent the deletion of paragraph just before table - if (elementBefore && elementBefore.getType() === 'table') { - return; - } let state: | {type: 'initial'} | {