Skip to content

Commit 9f6c7b3

Browse files
authored
[lexical-link] Feature: Enable Selective Removal Within Linked Text (#7944)
1 parent 9978ef8 commit 9f6c7b3

File tree

2 files changed

+256
-11
lines changed

2 files changed

+256
-11
lines changed

packages/lexical-link/src/LexicalLinkNode.ts

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,68 @@ function $withSelectedNodes<T>($fn: () => T): T {
532532
return rval;
533533
}
534534

535+
/**
536+
* Splits a LinkNode by removing selected children from it.
537+
* Handles three cases: selection at start, end, or middle of the link.
538+
* @param parentLink - The LinkNode to split
539+
* @param extractedNodes - The nodes that were extracted from the selection
540+
*/
541+
function $splitLinkAtSelection(
542+
parentLink: LinkNode,
543+
extractedNodes: LexicalNode[],
544+
): void {
545+
const extractedKeys = new Set(
546+
extractedNodes
547+
.filter((n) => parentLink.isParentOf(n))
548+
.map((n) => n.getKey()),
549+
);
550+
551+
const allChildren = parentLink.getChildren();
552+
const extractedChildren = allChildren.filter((child) =>
553+
extractedKeys.has(child.getKey()),
554+
);
555+
556+
if (extractedChildren.length === allChildren.length) {
557+
allChildren.forEach((child) => parentLink.insertBefore(child));
558+
parentLink.remove();
559+
return;
560+
}
561+
562+
const firstExtractedIndex = allChildren.findIndex((child) =>
563+
extractedKeys.has(child.getKey()),
564+
);
565+
const lastExtractedIndex = allChildren.findLastIndex((child) =>
566+
extractedKeys.has(child.getKey()),
567+
);
568+
569+
const isAtStart = firstExtractedIndex === 0;
570+
const isAtEnd = lastExtractedIndex === allChildren.length - 1;
571+
572+
if (isAtStart) {
573+
extractedChildren.forEach((child) => parentLink.insertBefore(child));
574+
} else if (isAtEnd) {
575+
for (let i = extractedChildren.length - 1; i >= 0; i--) {
576+
parentLink.insertAfter(extractedChildren[i]);
577+
}
578+
} else {
579+
for (let i = extractedChildren.length - 1; i >= 0; i--) {
580+
parentLink.insertAfter(extractedChildren[i]);
581+
}
582+
583+
const trailingChildren = allChildren.slice(lastExtractedIndex + 1);
584+
if (trailingChildren.length > 0) {
585+
const newLink = $createLinkNode(parentLink.getURL(), {
586+
rel: parentLink.getRel(),
587+
target: parentLink.getTarget(),
588+
title: parentLink.getTitle(),
589+
});
590+
591+
extractedChildren[extractedChildren.length - 1].insertAfter(newLink);
592+
trailingChildren.forEach((child) => newLink.append(child));
593+
}
594+
}
595+
}
596+
535597
/**
536598
* Generates or updates a LinkNode. It can also delete a LinkNode if the URL is null,
537599
* but saves any children and brings them up to the parent node.
@@ -611,22 +673,20 @@ export function $toggleLink(
611673
const nodes = selection.extract();
612674

613675
if (url === null) {
614-
// Remove LinkNodes
676+
const processedLinks = new Set<NodeKey>();
677+
615678
nodes.forEach((node) => {
616-
const parentLink = $findMatchingParent(
617-
node,
618-
(parent): parent is LinkNode =>
619-
!$isAutoLinkNode(parent) && $isLinkNode(parent),
620-
);
679+
const parentLink = node.getParent();
621680

622-
if (parentLink) {
623-
const children = parentLink.getChildren();
681+
if ($isLinkNode(parentLink) && !$isAutoLinkNode(parentLink)) {
682+
const linkKey = parentLink.getKey();
624683

625-
for (let i = 0; i < children.length; i++) {
626-
parentLink.insertBefore(children[i]);
684+
if (processedLinks.has(linkKey)) {
685+
return;
627686
}
628687

629-
parentLink.remove();
688+
$splitLinkAtSelection(parentLink, nodes);
689+
processedLinks.add(linkKey);
630690
}
631691
});
632692
return;

packages/lexical-link/src/__tests__/unit/LexicalLinkNode.test.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
$createLineBreakNode,
2020
$createParagraphNode,
2121
$createTextNode,
22+
$getNodeByKey,
2223
$getRoot,
2324
$getSelection,
2425
$isLineBreakNode,
@@ -582,6 +583,190 @@ describe('LexicalLinkNode tests', () => {
582583
expect($isLineBreakNode(lineBreakNode)).toBe(true);
583584
});
584585
});
586+
587+
test('$toggleLink removes link from trailing space only', async () => {
588+
const {editor} = testEnv;
589+
let textNodeKey: string;
590+
591+
// Create a link with text and a trailing space
592+
await editor.update(() => {
593+
const paragraph = $createParagraphNode();
594+
const textNode = $createTextNode('hello ');
595+
textNodeKey = textNode.getKey();
596+
const linkNode = $createLinkNode('https://example.com');
597+
linkNode.append(textNode);
598+
paragraph.append(linkNode);
599+
$getRoot().clear().append(paragraph);
600+
});
601+
602+
// Verify initial structure
603+
editor.read(() => {
604+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
605+
const linkNode = paragraph.getFirstChild();
606+
expect($isLinkNode(linkNode)).toBe(true);
607+
if ($isLinkNode(linkNode)) {
608+
expect(linkNode.getTextContent()).toBe('hello ');
609+
}
610+
});
611+
612+
// Select only the trailing space and remove the link from it
613+
await editor.update(() => {
614+
const textNode = $getNodeByKey(textNodeKey);
615+
if ($isTextNode(textNode)) {
616+
textNode.select(5, 6); // Select the trailing space
617+
$toggleLink(null);
618+
}
619+
});
620+
621+
// Verify that the link was removed only from the space
622+
editor.read(() => {
623+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
624+
const children = paragraph.getChildren();
625+
626+
// Should have two children: linkNode with 'hello' and a text node with ' '
627+
expect(children.length).toBe(2);
628+
629+
const [linkNode, spaceNode] = children;
630+
631+
// First child should still be a link containing 'hello'
632+
expect($isLinkNode(linkNode)).toBe(true);
633+
if ($isLinkNode(linkNode)) {
634+
expect(linkNode.getTextContent()).toBe('hello');
635+
expect(linkNode.getURL()).toBe('https://example.com');
636+
}
637+
638+
// Second child should be a text node with just the space
639+
expect($isTextNode(spaceNode)).toBe(true);
640+
if ($isTextNode(spaceNode)) {
641+
expect(spaceNode.getTextContent()).toBe(' ');
642+
}
643+
});
644+
});
645+
646+
test('$toggleLink removes link from leading space only', async () => {
647+
const {editor} = testEnv;
648+
let textNodeKey: string;
649+
650+
// Create a link with a leading space and text
651+
await editor.update(() => {
652+
const paragraph = $createParagraphNode();
653+
const textNode = $createTextNode(' hello');
654+
textNodeKey = textNode.getKey();
655+
const linkNode = $createLinkNode('https://example.com');
656+
linkNode.append(textNode);
657+
paragraph.append(linkNode);
658+
$getRoot().clear().append(paragraph);
659+
});
660+
661+
// Verify initial structure
662+
editor.read(() => {
663+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
664+
const linkNode = paragraph.getFirstChild();
665+
expect($isLinkNode(linkNode)).toBe(true);
666+
if ($isLinkNode(linkNode)) {
667+
expect(linkNode.getTextContent()).toBe(' hello');
668+
}
669+
});
670+
671+
// Select only the leading space and remove the link from it
672+
await editor.update(() => {
673+
const textNode = $getNodeByKey(textNodeKey);
674+
if ($isTextNode(textNode)) {
675+
textNode.select(0, 1); // Select the leading space
676+
$toggleLink(null);
677+
}
678+
});
679+
680+
// Verify that the link was removed only from the space
681+
editor.read(() => {
682+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
683+
const children = paragraph.getChildren();
684+
685+
// Should have two children: a text node with ' ' and linkNode with 'hello'
686+
expect(children.length).toBe(2);
687+
688+
const [spaceNode, linkNode] = children;
689+
690+
// First child should be a text node with just the space
691+
expect($isTextNode(spaceNode)).toBe(true);
692+
if ($isTextNode(spaceNode)) {
693+
expect(spaceNode.getTextContent()).toBe(' ');
694+
}
695+
696+
// Second child should still be a link containing 'hello'
697+
expect($isLinkNode(linkNode)).toBe(true);
698+
if ($isLinkNode(linkNode)) {
699+
expect(linkNode.getTextContent()).toBe('hello');
700+
expect(linkNode.getURL()).toBe('https://example.com');
701+
}
702+
});
703+
});
704+
705+
test('$toggleLink removes link from middle word only, preserving surrounding spaces', async () => {
706+
const {editor} = testEnv;
707+
let textNodeKey: string;
708+
709+
// Create a link with leading space, text, and trailing space
710+
await editor.update(() => {
711+
const paragraph = $createParagraphNode();
712+
const textNode = $createTextNode(' hello ');
713+
textNodeKey = textNode.getKey();
714+
const linkNode = $createLinkNode('https://example.com');
715+
linkNode.append(textNode);
716+
paragraph.append(linkNode);
717+
$getRoot().clear().append(paragraph);
718+
});
719+
720+
// Verify initial structure
721+
editor.read(() => {
722+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
723+
const linkNode = paragraph.getFirstChild();
724+
expect($isLinkNode(linkNode)).toBe(true);
725+
if ($isLinkNode(linkNode)) {
726+
expect(linkNode.getTextContent()).toBe(' hello ');
727+
}
728+
});
729+
730+
// Select only 'hello' (without spaces) and remove the link from it
731+
await editor.update(() => {
732+
const textNode = $getNodeByKey(textNodeKey);
733+
if ($isTextNode(textNode)) {
734+
textNode.select(1, 6); // Select 'hello'
735+
$toggleLink(null);
736+
}
737+
});
738+
739+
// Verify that the link was removed only from 'hello'
740+
editor.read(() => {
741+
const paragraph = $getRoot().getFirstChild() as ParagraphNode;
742+
const children = paragraph.getChildren();
743+
744+
// Should have three children: link with ' ', text with 'hello', link with ' '
745+
expect(children.length).toBe(3);
746+
747+
const [leadingSpaceLink, middleText, trailingSpaceLink] = children;
748+
749+
// First child should be a link with leading space
750+
expect($isLinkNode(leadingSpaceLink)).toBe(true);
751+
if ($isLinkNode(leadingSpaceLink)) {
752+
expect(leadingSpaceLink.getTextContent()).toBe(' ');
753+
expect(leadingSpaceLink.getURL()).toBe('https://example.com');
754+
}
755+
756+
// Middle child should be plain text 'hello'
757+
expect($isTextNode(middleText)).toBe(true);
758+
if ($isTextNode(middleText)) {
759+
expect(middleText.getTextContent()).toBe('hello');
760+
}
761+
762+
// Third child should be a link with trailing space
763+
expect($isLinkNode(trailingSpaceLink)).toBe(true);
764+
if ($isLinkNode(trailingSpaceLink)) {
765+
expect(trailingSpaceLink.getTextContent()).toBe(' ');
766+
expect(trailingSpaceLink.getURL()).toBe('https://example.com');
767+
}
768+
});
769+
});
585770
});
586771
});
587772

0 commit comments

Comments
 (0)