Skip to content

Commit cd9addf

Browse files
authored
fix(richtext-lexical): copying and pasting a single block in Lexical results in an error due to duplicate ID (#14738)
Fixes #14425, #14560 (duplicated) Which are a specific instance of the issue #14130 that wasn’t resolved in its PR (#14137) ### Description When the selection is on a decoratorNode, it's synthetic. In other words, `windows.selection` doesn't actually contain anything from the editor, and therefore the `COPY_COMMAND` event received with `cmd+c` is a `KeyboardEvent`, not a `ClipboardEvent`. I resolved this with a conditional block for the case where the selection is of type `NodeSelection`. ### Tests I added tests for the RangeSelection case as well as for NodeSelection
1 parent ee632d4 commit cd9addf

File tree

2 files changed

+115
-18
lines changed
  • packages/richtext-lexical/src/lexical/plugins/ClipboardPlugin
  • test/lexical/collections/_LexicalFullyFeatured

2 files changed

+115
-18
lines changed

packages/richtext-lexical/src/lexical/plugins/ClipboardPlugin/index.tsx

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,38 @@ import { copyToClipboard } from '@lexical/clipboard'
44
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
55
import { objectKlassEquals } from '@lexical/utils'
66
import ObjectID from 'bson-objectid'
7-
import { COMMAND_PRIORITY_LOW, COPY_COMMAND } from 'lexical'
7+
import { $getSelection, $isNodeSelection, COMMAND_PRIORITY_LOW, COPY_COMMAND } from 'lexical'
88
import { useEffect } from 'react'
99

1010
type SerializedUnknownLexicalNode = {
1111
children?: SerializedUnknownLexicalNode[]
1212
type: string
1313
}
1414

15+
type LexicalClipboardData = {
16+
namespace: string
17+
nodes: SerializedUnknownLexicalNode[]
18+
}
19+
20+
const changeIds = (node: SerializedUnknownLexicalNode) => {
21+
if (
22+
'fields' in node &&
23+
typeof node.fields === 'object' &&
24+
node.fields !== null &&
25+
'id' in node.fields
26+
) {
27+
node.fields.id = new ObjectID.default().toHexString()
28+
} else if ('id' in node) {
29+
node.id = new ObjectID.default().toHexString()
30+
}
31+
32+
if (node.children) {
33+
for (const child of node.children) {
34+
changeIds(child)
35+
}
36+
}
37+
}
38+
1539
export function ClipboardPlugin() {
1640
const [editor] = useLexicalComposerContext()
1741

@@ -22,6 +46,32 @@ export function ClipboardPlugin() {
2246
return editor.registerCommand(
2347
COPY_COMMAND,
2448
(event) => {
49+
// Handle decorator node case
50+
const selection = $getSelection()
51+
if ($isNodeSelection(selection)) {
52+
const node = selection.getNodes()[0]
53+
54+
const serializedNode = node?.exportJSON() as SerializedUnknownLexicalNode
55+
const deepCloneSerializedNode = JSON.parse(JSON.stringify(serializedNode))
56+
changeIds(deepCloneSerializedNode)
57+
58+
const lexicalClipboardData: LexicalClipboardData = {
59+
namespace: editor._config.namespace,
60+
nodes: [deepCloneSerializedNode],
61+
}
62+
63+
const stringifiedLexicalClipboardData = JSON.stringify(lexicalClipboardData)
64+
65+
copyToClipboard(editor, null, {
66+
'application/x-lexical-editor': stringifiedLexicalClipboardData,
67+
'text/plain': '',
68+
}).catch((error) => {
69+
throw error
70+
})
71+
return true
72+
}
73+
74+
// Handle range selection case
2575
copyToClipboard(editor, objectKlassEquals(event, ClipboardEvent) ? event : null)
2676
.then(() => {
2777
if (!(event instanceof ClipboardEvent) || !event.clipboardData) {
@@ -35,24 +85,7 @@ export function ClipboardPlugin() {
3585
const lexical = JSON.parse(lexicalStringified) as {
3686
nodes: SerializedUnknownLexicalNode[]
3787
}
38-
const changeIds = (node: SerializedUnknownLexicalNode) => {
39-
if (
40-
'fields' in node &&
41-
typeof node.fields === 'object' &&
42-
node.fields !== null &&
43-
'id' in node.fields
44-
) {
45-
node.fields.id = new ObjectID.default().toHexString()
46-
} else if ('id' in node) {
47-
node.id = new ObjectID.default().toHexString()
48-
}
4988

50-
if (node.children) {
51-
for (const child of node.children) {
52-
changeIds(child)
53-
}
54-
}
55-
}
5689
for (const node of lexical.nodes) {
5790
changeIds(node)
5891
}

test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,70 @@ describe('Lexical Fully Featured', () => {
230230
await page.keyboard.type("import { APIError } from 'payload'")
231231
await expect(codeBlock.locator('.monaco-editor .view-overlays .squiggly-error')).toHaveCount(0)
232232
})
233+
234+
test('copy pasting a inline block within range selection should not duplicate the inline block id', async ({
235+
page,
236+
}) => {
237+
await page.keyboard.type('Hello ')
238+
await lexical.slashCommand('inline')
239+
await lexical.drawer.locator('input').first().fill('World')
240+
await lexical.drawer.getByText('Save changes').click()
241+
await expect(lexical.drawer).toBeHidden()
242+
const inlineBlock = lexical.editor.locator('.LexicalEditorTheme__inlineBlock')
243+
await expect(inlineBlock).toHaveCount(1)
244+
245+
await page.keyboard.press('ControlOrMeta+A')
246+
await page.keyboard.press('ControlOrMeta+C')
247+
// needed for some reason
248+
// eslint-disable-next-line playwright/no-wait-for-timeout
249+
await page.waitForTimeout(1000)
250+
await page.keyboard.press('ArrowRight')
251+
await page.keyboard.press('ControlOrMeta+V')
252+
await expect(inlineBlock).toHaveCount(2)
253+
await inlineBlock.nth(1).locator('button').first().click()
254+
await expect(lexical.drawer).toBeVisible()
255+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World')
256+
await lexical.drawer.locator('input').first().fill('World changed')
257+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World changed')
258+
await lexical.drawer.getByText('Save changes').click()
259+
await inlineBlock.nth(0).locator('button').first().click()
260+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World')
261+
await lexical.drawer.getByLabel('Close').click()
262+
await expect(lexical.drawer).toBeHidden()
263+
await inlineBlock.nth(1).locator('button').first().click()
264+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World changed')
265+
})
266+
267+
test('copy pasting a inline block within node selection should not duplicate the inline block id', async ({
268+
page,
269+
}) => {
270+
await page.keyboard.type('Hello ')
271+
await lexical.slashCommand('inline')
272+
await lexical.drawer.locator('input').first().fill('World')
273+
await lexical.drawer.getByText('Save changes').click()
274+
await expect(lexical.drawer).toBeHidden()
275+
const inlineBlock = lexical.editor.locator('.LexicalEditorTheme__inlineBlock')
276+
await expect(inlineBlock).toHaveCount(1)
277+
await inlineBlock.click()
278+
await expect(lexical.drawer).toBeHidden()
279+
await page.keyboard.press('ControlOrMeta+C')
280+
await page.keyboard.press('ArrowRight')
281+
await page.keyboard.press('ControlOrMeta+V')
282+
await expect(inlineBlock).toHaveCount(2)
283+
await inlineBlock.nth(1).locator('button').first().click()
284+
await expect(lexical.drawer).toBeVisible()
285+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World')
286+
await lexical.drawer.locator('input').first().fill('World changed')
287+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World changed')
288+
await lexical.drawer.getByText('Save changes').click()
289+
await expect(lexical.drawer).toBeHidden()
290+
await inlineBlock.nth(0).locator('button').first().click()
291+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World')
292+
await lexical.drawer.getByLabel('Close').click()
293+
await expect(lexical.drawer).toBeHidden()
294+
await inlineBlock.nth(1).locator('button').first().click()
295+
await expect(lexical.drawer.locator('input').first()).toHaveValue('World changed')
296+
})
233297
})
234298

235299
describe('Lexical Fully Featured, admin panel in RTL', () => {

0 commit comments

Comments
 (0)