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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user