diff --git a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/types.ts b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/types.ts index 3cf5f8bf5..3eed9d040 100644 --- a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/types.ts +++ b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/types.ts @@ -16,4 +16,4 @@ export type LinkPayload = { * The text content of the link node - will be displayed in the drawer */ text: null | string -} +} | null diff --git a/packages/richtext-lexical/src/features/link/client/plugins/link/index.tsx b/packages/richtext-lexical/src/features/link/client/plugins/link/index.tsx index ef3ec554c..da4a144d6 100644 --- a/packages/richtext-lexical/src/features/link/client/plugins/link/index.tsx +++ b/packages/richtext-lexical/src/features/link/client/plugins/link/index.tsx @@ -29,6 +29,10 @@ export const LinkPlugin: PluginComponent = ({ 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 } diff --git a/packages/richtext-lexical/src/features/link/nodes/LinkNode.ts b/packages/richtext-lexical/src/features/link/nodes/LinkNode.ts index fcf3e5072..b6b72467d 100644 --- a/packages/richtext-lexical/src/features/link/nodes/LinkNode.ts +++ b/packages/richtext-lexical/src/features/link/nodes/LinkNode.ts @@ -266,13 +266,17 @@ export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkN export const TOGGLE_LINK_COMMAND: LexicalCommand = 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 { diff --git a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts index d3809f080..b02ea190d 100644 --- a/test/fields/collections/Lexical/e2e/main/e2e.spec.ts +++ b/test/fields/collections/Lexical/e2e/main/e2e.spec.ts @@ -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 = 'Google' + 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)