Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions packages/lexical-link/src/LexicalLinkNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,22 +549,25 @@ function $splitLinkAtSelection(
);

const allChildren = parentLink.getChildren();
const extractedChildren = allChildren.filter((child) =>
extractedKeys.has(child.getKey()),
);
// Check if a child is an extracted node OR contains an extracted node
// This handles nested structures like LinkNode > HeadingNode > TextNode
const isExtractedChild = (child: LexicalNode): boolean =>
extractedKeys.has(child.getKey()) ||
($isElementNode(child) &&
extractedNodes.some(
(n) => parentLink.isParentOf(n) && child.isParentOf(n),
));

const extractedChildren = allChildren.filter(isExtractedChild);

if (extractedChildren.length === allChildren.length) {
allChildren.forEach((child) => parentLink.insertBefore(child));
parentLink.remove();
return;
}

const firstExtractedIndex = allChildren.findIndex((child) =>
extractedKeys.has(child.getKey()),
);
const lastExtractedIndex = allChildren.findLastIndex((child) =>
extractedKeys.has(child.getKey()),
);
const firstExtractedIndex = allChildren.findIndex(isExtractedChild);
const lastExtractedIndex = allChildren.findLastIndex(isExtractedChild);

const isAtStart = firstExtractedIndex === 0;
const isAtEnd = lastExtractedIndex === allChildren.length - 1;
Expand Down Expand Up @@ -676,9 +679,13 @@ export function $toggleLink(
const processedLinks = new Set<NodeKey>();

nodes.forEach((node) => {
const parentLink = node.getParent();
const parentLink = $findMatchingParent(
node,
(parent): parent is LinkNode =>
!$isAutoLinkNode(parent) && $isLinkNode(parent),
);

if ($isLinkNode(parentLink) && !$isAutoLinkNode(parentLink)) {
if (parentLink !== null) {
const linkKey = parentLink.getKey();

if (processedLinks.has(linkKey)) {
Expand Down
52 changes: 52 additions & 0 deletions packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
SerializedLinkNode,
} from '@lexical/link';
import {$createMarkNode, $isMarkNode} from '@lexical/mark';
import {$createHeadingNode} from '@lexical/rich-text';
import {
$createLineBreakNode,
$createParagraphNode,
Expand Down Expand Up @@ -525,6 +526,57 @@ describe('LexicalLinkNode tests', () => {
});
});

test('$toggleLink correctly removes link when link contains heading', async () => {
// This tests the structure: link > heading > text
const {editor} = testEnv;
await editor.update(() => {
const paragraph = $createParagraphNode();
const linkNode = $createLinkNode('https://example.com/foo');
const headingNode = $createHeadingNode('h3');
const textNode = $createTextNode('Example Link');

headingNode.append(textNode);
linkNode.append(headingNode);
paragraph.append(linkNode);
$getRoot().append(paragraph);
});

// Verify initial structure: paragraph > link > heading > text
editor.read(() => {
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
const linkNode = paragraph.getFirstChild();

expect($isLinkNode(linkNode)).toBe(true);
if ($isLinkNode(linkNode)) {
expect(linkNode.getURL()).toBe('https://example.com/foo');
const headingNode = linkNode.getFirstChild();
expect(headingNode?.getType()).toBe('heading');
expect(headingNode?.getTextContent()).toBe('Example Link');
}
});

// Select all and remove link
await editor.update(() => {
$selectAll();
$toggleLink(null);
});

// Verify structure after link removal: paragraph > heading > text
editor.read(() => {
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
const children = paragraph.getChildren();

// Link should be removed, heading should be moved up to paragraph level
expect(children.length).toBe(1);
const headingNode = children[0];
expect(headingNode.getType()).toBe('heading');
expect(headingNode.getTextContent()).toBe('Example Link');

// Verify no link nodes remain
expect($isLinkNode(headingNode)).toBe(false);
});
});

test('$toggleLink adds link with embedded LineBreakNode', async () => {
const {editor} = testEnv;
await editor.update(() => {
Expand Down