fix(richtext-lexical): error when deleting links (#10557)

When pressing the delete button in the floating link popup, the link was
not deleted and a console error was thrown
This commit is contained in:
Alessio Gravili
2025-01-13 14:11:18 -07:00
committed by GitHub
parent 5cfb1daaae
commit 9631060383
4 changed files with 149 additions and 81 deletions

View File

@@ -16,4 +16,4 @@ export type LinkPayload = {
* The text content of the link node - will be displayed in the drawer
*/
text: null | string
}
} | null

View File

@@ -29,6 +29,10 @@ export const LinkPlugin: PluginComponent<ClientProps> = ({ clientProps }) => {
editor.registerCommand(
TOGGLE_LINK_COMMAND,
(payload: LinkPayload) => {
if (payload === null) {
$toggleLink(null)
return true
}
if (!payload.fields?.linkType) {
payload.fields.linkType = clientProps.defaultLinkType as any
}

View File

@@ -266,13 +266,17 @@ export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkN
export const TOGGLE_LINK_COMMAND: LexicalCommand<LinkPayload | null> =
createCommand('TOGGLE_LINK_COMMAND')
export function $toggleLink(payload: { fields: LinkFields } & LinkPayload): void {
export function $toggleLink(payload: ({ fields: LinkFields } & LinkPayload) | null): void {
const selection = $getSelection()
if (!$isRangeSelection(selection) && !payload.selectedNodes?.length) {
if (!$isRangeSelection(selection) && (payload === null || !payload.selectedNodes?.length)) {
return
}
const nodes = $isRangeSelection(selection) ? selection.extract() : payload.selectedNodes
const nodes = $isRangeSelection(selection)
? selection.extract()
: payload === null
? []
: payload.selectedNodes
if (payload === null) {
// Remove LinkNodes
@@ -289,92 +293,93 @@ export function $toggleLink(payload: { fields: LinkFields } & LinkPayload): void
parent.remove()
}
})
} else {
// Add or merge LinkNodes
if (nodes?.length === 1) {
const firstNode = nodes[0]
// if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode: LinkNode | null = $isLinkNode(firstNode)
? firstNode
: $getLinkAncestor(firstNode)
if (linkNode !== null) {
linkNode.setFields(payload.fields)
if (payload.text != null && payload.text !== linkNode.getTextContent()) {
// remove all children and add child with new textcontent:
linkNode.append($createTextNode(payload.text))
linkNode.getChildren().forEach((child) => {
if (child !== linkNode.getLastChild()) {
child.remove()
}
})
return
}
// Add or merge LinkNodes
if (nodes?.length === 1) {
const firstNode = nodes[0]
// if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode: LinkNode | null = $isLinkNode(firstNode)
? firstNode
: $getLinkAncestor(firstNode)
if (linkNode !== null) {
linkNode.setFields(payload.fields)
if (payload.text != null && payload.text !== linkNode.getTextContent()) {
// remove all children and add child with new textcontent:
linkNode.append($createTextNode(payload.text))
linkNode.getChildren().forEach((child) => {
if (child !== linkNode.getLastChild()) {
child.remove()
}
})
}
return
}
}
let prevParent: ElementNodeType | LinkNode | null = null
let linkNode: LinkNode | null = null
nodes?.forEach((node) => {
const parent = node.getParent()
if (parent === linkNode || parent === null || ($isElementNode(node) && !node.isInline())) {
return
}
if ($isLinkNode(parent)) {
linkNode = parent
parent.setFields(payload.fields)
if (payload.text != null && payload.text !== parent.getTextContent()) {
// remove all children and add child with new textcontent:
parent.append($createTextNode(payload.text))
parent.getChildren().forEach((child) => {
if (child !== parent.getLastChild()) {
child.remove()
}
})
}
return
}
if (!parent.is(prevParent)) {
prevParent = parent
linkNode = $createLinkNode({ fields: payload.fields })
if ($isLinkNode(parent)) {
if (node.getPreviousSibling() === null) {
parent.insertBefore(linkNode)
} else {
parent.insertAfter(linkNode)
}
return
} else {
node.insertBefore(linkNode)
}
}
let prevParent: ElementNodeType | LinkNode | null = null
let linkNode: LinkNode | null = null
nodes?.forEach((node) => {
const parent = node.getParent()
if (parent === linkNode || parent === null || ($isElementNode(node) && !node.isInline())) {
if ($isLinkNode(node)) {
if (node.is(linkNode)) {
return
}
if ($isLinkNode(parent)) {
linkNode = parent
parent.setFields(payload.fields)
if (payload.text != null && payload.text !== parent.getTextContent()) {
// remove all children and add child with new textcontent:
parent.append($createTextNode(payload.text))
parent.getChildren().forEach((child) => {
if (child !== parent.getLastChild()) {
child.remove()
}
})
}
return
}
if (!parent.is(prevParent)) {
prevParent = parent
linkNode = $createLinkNode({ fields: payload.fields })
if ($isLinkNode(parent)) {
if (node.getPreviousSibling() === null) {
parent.insertBefore(linkNode)
} else {
parent.insertAfter(linkNode)
}
} else {
node.insertBefore(linkNode)
}
}
if ($isLinkNode(node)) {
if (node.is(linkNode)) {
return
}
if (linkNode !== null) {
const children = node.getChildren()
for (let i = 0; i < children.length; i += 1) {
linkNode.append(children[i])
}
}
node.remove()
return
}
if (linkNode !== null) {
linkNode.append(node)
const children = node.getChildren()
for (let i = 0; i < children.length; i += 1) {
linkNode.append(children[i])
}
}
})
}
node.remove()
return
}
if (linkNode !== null) {
linkNode.append(node)
}
})
}
function $getLinkAncestor(node: LexicalNode): LinkNode | null {

View File

@@ -1218,6 +1218,65 @@ describe('lexicalMain', () => {
await page.getByText('Creating new User')
})
test('ensure links can created from clipboard and deleted', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
// Wait until there at least 10 blocks visible in that richtext field - thus wait for it to be fully loaded
await expect(page.locator('.rich-text-lexical').nth(2).locator('.lexical-block')).toHaveCount(
10,
)
await expect(page.locator('.shimmer-effect')).toHaveCount(0)
await richTextField.locator('.ContentEditable__root').first().click()
const lastParagraph = richTextField.locator('p').first()
await lastParagraph.scrollIntoViewIfNeeded()
await expect(lastParagraph).toBeVisible()
await lastParagraph.click()
page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
// Paste in a link copied from a html page
const link = '<a href="https://www.google.com">Google</a>'
await page.evaluate(
async ([link]) => {
const blob = new Blob([link], { type: 'text/html' })
const clipboardItem = new ClipboardItem({ 'text/html': blob })
await navigator.clipboard.write([clipboardItem])
},
[link],
)
await page.keyboard.press('Meta+v')
await page.keyboard.press('Control+v')
const linkNode = richTextField.locator('a.LexicalEditorTheme__link').first()
await linkNode.scrollIntoViewIfNeeded()
await expect(linkNode).toBeVisible()
// Check link node text and attributes
await expect(linkNode).toHaveText('Google')
await expect(linkNode).toHaveAttribute('href', 'https://www.google.com/')
// Expect floating link editor link-input to be there
const linkInput = richTextField.locator('.link-input').first()
await expect(linkInput).toBeVisible()
const linkInInput = linkInput.locator('a').first()
expect(linkInInput).toBeVisible()
expect(linkInInput).toContainText('https://www.google.com/')
await expect(linkInInput).toHaveAttribute('href', 'https://www.google.com/')
// Click remove button
const removeButton = linkInput.locator('.link-trash').first()
await removeButton.click()
// Expect link to be removed
await expect(linkNode).not.toBeVisible()
})
describe('localization', () => {
test.skip('ensure simple localized lexical field works', async () => {
await navigateToLexicalFields(true, true)