fix(richtext-lexical): ensure editor cursor / selection state is preserved when working with drawers (#8872)

Previously, when opening e.g. a link drawer, clicking within the drawer,
and then closing it, the cursor / selection of the lexical editor will
reset to the beginning of the editor.

Now, we have dedicated logic to storing, preserving and restoring the
lexical selection when working with drawers.

This will work with all drawers. Links, uploads, relationships etc.


https://github.com/user-attachments/assets/ab3858b1-0f52-4ee5-813f-02b848355998
This commit is contained in:
Alessio Gravili
2024-10-27 16:32:31 -06:00
committed by GitHub
parent 07a8a37fbd
commit a8569b9e78
18 changed files with 574 additions and 65 deletions

View File

@@ -1,3 +1,4 @@
import type { SerializedLinkNode, SerializedUploadNode } from '@payloadcms/richtext-lexical'
import type { BrowserContext, Page } from '@playwright/test'
import type { SerializedEditorState, SerializedParagraphNode, SerializedTextNode } from 'lexical'
@@ -684,6 +685,216 @@ describe('lexicalMain', () => {
})
})
test('creating a link, then clicking in the link drawer, then saving the link, should preserve cursor position and not move cursor to beginning of richtext field', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
await paragraph.scrollIntoViewIfNeeded()
await expect(paragraph).toBeVisible()
/**
* Type some text
*/
await paragraph.click()
await page.keyboard.type('Some Text')
await page.keyboard.press('Enter')
await page.keyboard.type('Hello there')
// Select "there" by pressing shift + arrow left
for (let i = 0; i < 5; i++) {
await page.keyboard.press('Shift+ArrowLeft')
}
// Ensure inline toolbar appeared
const inlineToolbar = page.locator('.inline-toolbar-popup')
await expect(inlineToolbar).toBeVisible()
const linkButton = inlineToolbar.locator('.toolbar-popup__button-link')
await expect(linkButton).toBeVisible()
await linkButton.click()
/**
* Link Drawer
*/
const linkDrawer = page.locator('dialog[id^=drawer_1_lexical-rich-text-link-]').first() // IDs starting with drawer_1_lexical-rich-text-link- (there's some other symbol after the underscore)
await expect(linkDrawer).toBeVisible()
await wait(500)
const urlInput = linkDrawer.locator('#field-url').first()
// Click on the input to focus it
await urlInput.click()
// should be https:// value
await expect(urlInput).toHaveValue('https://')
// Change it to https://google.com
await urlInput.fill('https://google.com')
// Save drawer
await linkDrawer.locator('button').getByText('Save').first().click()
await expect(linkDrawer).toBeHidden()
await wait(1500)
// The entire link should be selected now => press arrow right to move cursor to the end of the link node before we type
await page.keyboard.press('ArrowRight')
// Just keep typing - the cursor should not have moved to the beginning of the richtext field
await page.keyboard.type(' xxx')
await saveDocAndAssert(page)
// Check if the text is bold. It's a self-relationship, so no need to follow relationship
await expect(async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const secondParagraph: SerializedParagraphNode = lexicalField.root
.children[1] as SerializedParagraphNode
expect(firstParagraph.children).toHaveLength(1)
expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some Text')
expect(secondParagraph.children).toHaveLength(3)
expect((secondParagraph.children[0] as SerializedTextNode).text).toBe('Hello ')
expect((secondParagraph.children[1] as SerializedLinkNode).type).toBe('link')
expect((secondParagraph.children[1] as SerializedLinkNode).children).toHaveLength(1)
expect(
((secondParagraph.children[1] as SerializedLinkNode).children[0] as SerializedTextNode)
.text,
).toBe('there')
expect((secondParagraph.children[2] as SerializedTextNode).text).toBe(' xxx')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
test('lexical cursor / selection should be preserved when swapping upload field and clicking within with its list drawer', async () => {
await navigateToLexicalFields()
const richTextField = page.locator('.rich-text-lexical').first()
await richTextField.scrollIntoViewIfNeeded()
await expect(richTextField).toBeVisible()
const paragraph = richTextField.locator('.LexicalEditorTheme__paragraph').first()
await paragraph.scrollIntoViewIfNeeded()
await expect(paragraph).toBeVisible()
/**
* Type some text
*/
await paragraph.click()
await page.keyboard.type('Some Text')
await page.keyboard.press('Enter')
await page.keyboard.press('/')
await page.keyboard.type('Upload')
// Create Upload node
const slashMenuPopover = page.locator('#slash-menu .slash-menu-popup')
await expect(slashMenuPopover).toBeVisible()
const uploadSelectButton = slashMenuPopover.locator('button').first()
await expect(uploadSelectButton).toBeVisible()
await expect(uploadSelectButton).toContainText('Upload')
await uploadSelectButton.click()
await expect(slashMenuPopover).toBeHidden()
await wait(500) // wait for drawer form state to initialize (it's a flake)
const uploadListDrawer = page.locator('dialog[id^=list-drawer_1_]').first() // IDs starting with list-drawer_1_ (there's some other symbol after the underscore)
await expect(uploadListDrawer).toBeVisible()
await wait(500)
await uploadListDrawer.locator('button').getByText('payload.png').first().click()
await expect(uploadListDrawer).toBeHidden()
const newUploadNode = richTextField.locator('.lexical-upload').first()
await newUploadNode.scrollIntoViewIfNeeded()
await expect(newUploadNode).toBeVisible()
await expect(newUploadNode.locator('.lexical-upload__bottomRow')).toContainText('payload.png')
await page.keyboard.press('ArrowLeft')
// Select "there" by pressing shift + arrow left
for (let i = 0; i < 4; i++) {
await page.keyboard.press('Shift+ArrowLeft')
}
await newUploadNode.locator('.lexical-upload__swap-drawer-toggler').first().click()
const uploadSwapDrawer = page.locator('dialog[id^=list-drawer_1_]').first()
await expect(uploadSwapDrawer).toBeVisible()
await wait(500)
// Click anywhere in the drawer to make sure the cursor position is preserved
await uploadSwapDrawer.locator('.drawer__content').first().click()
// click button with text content "payload.jpg"
await uploadSwapDrawer.locator('button').getByText('payload.jpg').first().click()
await expect(uploadSwapDrawer).toBeHidden()
await wait(500)
// press ctrl+B to bold the text previously selected (assuming it is still selected now, which it should be)
await page.keyboard.press('Meta+B')
// In case this is mac or windows
await page.keyboard.press('Control+B')
await wait(500)
await saveDocAndAssert(page)
// Check if the text is bold. It's a self-relationship, so no need to follow relationship
await expect(async () => {
const lexicalDoc: LexicalField = (
await payload.find({
collection: lexicalFieldsSlug,
depth: 0,
overrideAccess: true,
where: {
title: {
equals: lexicalDocData.title,
},
},
})
).docs[0] as never
const lexicalField: SerializedEditorState = lexicalDoc.lexicalRootEditor
const firstParagraph: SerializedParagraphNode = lexicalField.root
.children[0] as SerializedParagraphNode
const secondParagraph: SerializedParagraphNode = lexicalField.root
.children[1] as SerializedParagraphNode
const uploadNode: SerializedUploadNode = lexicalField.root.children[2] as SerializedUploadNode
expect(firstParagraph.children).toHaveLength(2)
expect((firstParagraph.children[0] as SerializedTextNode).text).toBe('Some ')
expect((firstParagraph.children[0] as SerializedTextNode).format).toBe(0)
expect((firstParagraph.children[1] as SerializedTextNode).text).toBe('Text')
expect((firstParagraph.children[1] as SerializedTextNode).format).toBe(1)
expect(secondParagraph.children).toHaveLength(0)
expect(uploadNode.relationTo).toBe('uploads')
}).toPass({
timeout: POLL_TOPASS_TIMEOUT,
})
})
describe('localization', () => {
test.skip('ensure simple localized lexical field works', async () => {
await navigateToLexicalFields(true, true)