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

@@ -24,6 +24,7 @@ import {
COMMAND_PRIORITY_EDITOR,
type RangeSelection,
} from 'lexical'
import { useLexicalDrawer } from 'packages/richtext-lexical/src/utilities/fieldsDrawer/useLexicalDrawer.js'
import React, { useEffect, useState } from 'react'
import type { PluginComponent } from '../../../typesClient.js'
@@ -44,7 +45,6 @@ export type InsertBlockPayload = BlockFieldsOptionalID
export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
const [editor] = useLexicalComposerContext()
const { closeModal, toggleModal } = useModal()
const [blockFields, setBlockFields] = useState<BlockFields | null>(null)
const [blockType, setBlockType] = useState<string>('' as any)
const [targetNodeKey, setTargetNodeKey] = useState<null | string>(null)
@@ -58,6 +58,8 @@ export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
depth: editDepth,
})
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
const {
field: { richTextComponentMap },
} = useEditorConfigContext()
@@ -135,7 +137,7 @@ export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
setBlockType(fields?.blockType ?? ('' as any))
if (nodeKey) {
toggleModal(drawerSlug)
toggleDrawer()
return true
}
@@ -150,14 +152,14 @@ export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
if (rangeSelection) {
//setLastSelection(rangeSelection)
toggleModal(drawerSlug)
toggleDrawer()
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, targetNodeKey, toggleModal, drawerSlug])
}, [editor, targetNodeKey, toggleDrawer])
if (!blockFields) {
return null
@@ -192,7 +194,6 @@ export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
featureKey="blocks"
fieldMapOverride={clientBlock?.fields}
handleDrawerSubmit={(_fields, data) => {
closeModal(drawerSlug)
if (!data) {
return
}

View File

@@ -16,6 +16,7 @@ import { INSERT_TABLE_COMMAND, TableNode } from '@lexical/table'
import { mergeRegister } from '@lexical/utils'
import { formatDrawerSlug, useEditDepth, useModal } from '@payloadcms/ui'
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import { useLexicalDrawer } from 'packages/richtext-lexical/src/utilities/fieldsDrawer/useLexicalDrawer.js'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import * as React from 'react'
@@ -83,7 +84,6 @@ export function TableContext({ children }: { children: JSX.Element }) {
export const TablePlugin: PluginComponent = () => {
const [editor] = useLexicalComposerContext()
const cellContext = useContext(CellContext)
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const { uuid } = useEditorConfigContext()
@@ -91,6 +91,7 @@ export const TablePlugin: PluginComponent = () => {
slug: 'lexical-table-create-' + uuid,
depth: editDepth,
})
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
useEffect(() => {
if (!editor.hasNodes([TableNode])) {
@@ -111,14 +112,14 @@ export const TablePlugin: PluginComponent = () => {
})
if (rangeSelection) {
toggleModal(drawerSlug)
toggleDrawer()
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [cellContext, drawerSlug, editor, toggleModal])
}, [cellContext, editor, toggleDrawer])
return (
<React.Fragment>
@@ -127,8 +128,6 @@ export const TablePlugin: PluginComponent = () => {
drawerTitle="Create Table"
featureKey="experimental_table"
handleDrawerSubmit={(_fields, data) => {
closeModal(drawerSlug)
if (!data.columns || !data.rows) {
return
}

View File

@@ -33,6 +33,7 @@ import { useEditorConfigContext } from '../../../../../../lexical/config/client/
import { getSelectedNode } from '../../../../../../lexical/utils/getSelectedNode.js'
import { setFloatingElemPositionForLinkEditor } from '../../../../../../lexical/utils/setFloatingElemPositionForLinkEditor.js'
import { FieldsDrawer } from '../../../../../../utilities/fieldsDrawer/Drawer.js'
import { useLexicalDrawer } from '../../../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import { $isAutoLinkNode } from '../../../../nodes/AutoLinkNode.js'
import { $createLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../../nodes/LinkNode.js'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands.js'
@@ -54,7 +55,6 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
({ id?: string; text: string } & LinkFields) | undefined
>()
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const [selectedNodes, setSelectedNodes] = useState<LexicalNode[]>([])
@@ -66,6 +66,8 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
depth: editDepth,
})
const { toggleDrawer } = useLexicalDrawer(drawerSlug)
const setNotLink = useCallback(() => {
setIsLink(false)
if (editorRef && editorRef.current) {
@@ -204,14 +206,14 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
// Now, open the modal
$updateLinkEditor()
toggleModal(drawerSlug)
toggleDrawer()
return true
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor, $updateLinkEditor, toggleModal, drawerSlug])
}, [editor, $updateLinkEditor, toggleDrawer, drawerSlug])
useEffect(() => {
const scrollerElem = anchorElem.parentElement
@@ -292,7 +294,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
aria-label="Edit link"
className="link-edit"
onClick={() => {
toggleModal(drawerSlug)
toggleDrawer()
}}
onMouseDown={(event) => {
event.preventDefault()
@@ -329,8 +331,6 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
drawerTitle={t('fields:editLink')}
featureKey="link"
handleDrawerSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
const newLinkPayload = data as { text: string } & LinkFields
const bareLinkFields: LinkFields = {

View File

@@ -5,7 +5,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import { Button, useConfig, useDocumentDrawer, usePayloadAPI, useTranslation } from '@payloadcms/ui'
import { Button, useConfig, usePayloadAPI, useTranslation } from '@payloadcms/ui'
import {
$getNodeByKey,
$getSelection,
@@ -20,6 +20,7 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'rea
import type { RelationshipData } from '../../server/nodes/RelationshipNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDocumentDrawer.js'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import { $isRelationshipNode } from '../nodes/RelationshipNode.js'
import './index.scss'
@@ -73,7 +74,7 @@ const Component: React.FC<Props> = (props) => {
{ initialParams },
)
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
const { closeDocumentDrawer, DocumentDrawer, DocumentDrawerToggler } = useLexicalDocumentDrawer({
id: value,
collectionSlug: relatedCollection.slug,
})
@@ -91,10 +92,10 @@ const Component: React.FC<Props> = (props) => {
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
closeDrawer()
closeDocumentDrawer()
dispatchCacheBust()
},
[cacheBust, setParams, closeDrawer],
[cacheBust, setParams, closeDocumentDrawer],
)
const $onDelete = useCallback(
@@ -172,7 +173,7 @@ const Component: React.FC<Props> = (props) => {
buttonStyle="icon-label"
className={`${baseClass}__swapButton`}
disabled={field?.admin?.readOnly}
el="div"
el="button"
icon="swap"
onClick={() => {
if (nodeKey) {

View File

@@ -2,10 +2,10 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useListDrawer } from '@payloadcms/ui'
import { $getNodeByKey, COMMAND_PRIORITY_EDITOR } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react'
import { useLexicalListDrawer } from '../../../../utilities/fieldsDrawer/useLexicalListDrawer.js'
import { $createRelationshipNode } from '../nodes/RelationshipNode.js'
import { INSERT_RELATIONSHIP_COMMAND } from '../plugins/index.js'
import { EnabledRelationshipsCondition } from '../utils/EnabledRelationshipsCondition.js'
@@ -48,8 +48,8 @@ const RelationshipDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }
)
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const [ListDrawer, ListDrawerToggler, { closeDrawer, isDrawerOpen, openDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs!,
const { closeListDrawer, isListDrawerOpen, ListDrawer, openListDrawer } = useLexicalListDrawer({
collectionSlugs: enabledCollectionSlugs ? enabledCollectionSlugs : undefined,
selectedCollection: selectedCollectionSlug,
})
@@ -60,12 +60,12 @@ const RelationshipDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }
INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openDrawer()
openListDrawer()
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, openDrawer])
}, [editor, openListDrawer])
const onSelect = useCallback(
({ collectionSlug, docID }) => {
@@ -75,16 +75,16 @@ const RelationshipDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }
replaceNodeKey,
value: docID,
})
closeDrawer()
closeListDrawer()
},
[editor, closeDrawer, replaceNodeKey],
[editor, closeListDrawer, replaceNodeKey],
)
useEffect(() => {
// always reset back to first option
// TODO: this is not working, see the ListDrawer component
setSelectedCollectionSlug(enabledCollectionSlugs?.[0])
}, [isDrawerOpen, enabledCollectionSlugs])
}, [isListDrawerOpen, enabledCollectionSlugs])
return <ListDrawer onSelect={onSelect} />
}

View File

@@ -1,4 +1,5 @@
'use client'
import type { BaseSelection } from 'lexical'
import type { ClientCollectionConfig, Data } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
@@ -7,13 +8,10 @@ import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
DrawerToggler,
File,
formatDrawerSlug,
useConfig,
useDocumentDrawer,
useEditDepth,
useModal,
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
@@ -35,6 +33,8 @@ import type { UploadNode } from '../nodes/UploadNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { FieldsDrawer } from '../../../../utilities/fieldsDrawer/Drawer.js'
import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDocumentDrawer.js'
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import { EnabledRelationshipsCondition } from '../../../relationship/client/utils/EnabledRelationshipsCondition.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import { $isUploadNode } from '../nodes/UploadNode.js'
@@ -71,7 +71,6 @@ const Component: React.FC<ElementProps> = (props) => {
},
} = useConfig()
const uploadRef = useRef<HTMLDivElement | null>(null)
const { closeModal } = useModal()
const { uuid } = useEditorConfigContext()
const editDepth = useEditDepth()
const [editor] = useLexicalComposerContext()
@@ -87,12 +86,15 @@ const Component: React.FC<ElementProps> = (props) => {
const componentID = useId()
const drawerSlug = formatDrawerSlug({
const extraFieldsDrawerSlug = formatDrawerSlug({
slug: `lexical-upload-drawer-` + uuid + componentID, // There can be multiple upload components, each with their own drawer, in one single editor => separate them by componentID
depth: editDepth,
})
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
// Need to use hook to initialize useEffect that restores cursor position
const { toggleDrawer } = useLexicalDrawer(extraFieldsDrawerSlug, true)
const { closeDocumentDrawer, DocumentDrawer, DocumentDrawerToggler } = useLexicalDocumentDrawer({
id: value,
collectionSlug: relatedCollection.slug,
})
@@ -119,9 +121,9 @@ const Component: React.FC<ElementProps> = (props) => {
})
dispatchCacheBust()
closeDrawer()
closeDocumentDrawer()
},
[setParams, cacheBust, closeDrawer],
[setParams, cacheBust, closeDocumentDrawer],
)
const $onDelete = useCallback(
@@ -191,10 +193,8 @@ const Component: React.FC<ElementProps> = (props) => {
uploadNode.setData(newData)
}
})
closeModal(drawerSlug)
},
[closeModal, editor, drawerSlug, nodeKey],
[editor, nodeKey],
)
return (
@@ -225,28 +225,25 @@ const Component: React.FC<ElementProps> = (props) => {
{editor.isEditable() && (
<div className={`${baseClass}__actions`}>
{hasExtraFields ? (
<DrawerToggler
className={`${baseClass}__upload-drawer-toggler`}
disabled={field?.admin?.readOnly}
slug={drawerSlug}
>
<Button
buttonStyle="icon-label"
el="div"
className={`${baseClass}__upload-drawer-toggler`}
disabled={field?.admin?.readOnly}
el="button"
icon="edit"
onClick={(e) => {
e.preventDefault()
onClick={() => {
toggleDrawer()
}}
round
tooltip={t('fields:editRelationship')}
/>
</DrawerToggler>
) : null}
<Button
buttonStyle="icon-label"
className={`${baseClass}__swap-drawer-toggler`}
disabled={field?.admin?.readOnly}
el="div"
el="button"
icon="swap"
onClick={() => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
@@ -282,7 +279,7 @@ const Component: React.FC<ElementProps> = (props) => {
{hasExtraFields ? (
<FieldsDrawer
data={fields}
drawerSlug={drawerSlug}
drawerSlug={extraFieldsDrawerSlug}
drawerTitle={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}

View File

@@ -2,10 +2,10 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useListDrawer } from '@payloadcms/ui'
import { $getNodeByKey, COMMAND_PRIORITY_EDITOR } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react'
import { useLexicalListDrawer } from '../../../../utilities/fieldsDrawer/useLexicalListDrawer.js'
import { EnabledRelationshipsCondition } from '../../../relationship/client/utils/EnabledRelationshipsCondition.js'
import { $createUploadNode } from '../nodes/UploadNode.js'
import { INSERT_UPLOAD_COMMAND } from '../plugin/index.js'
@@ -57,7 +57,7 @@ const UploadDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }) => {
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const [ListDrawer, ListDrawerToggler, { closeDrawer, openDrawer }] = useListDrawer({
const { closeListDrawer, ListDrawer, openListDrawer } = useLexicalListDrawer({
collectionSlugs: enabledCollectionSlugs,
uploads: true,
})
@@ -69,24 +69,24 @@ const UploadDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }) => {
INSERT_UPLOAD_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openDrawer()
openListDrawer()
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, openDrawer])
}, [editor, openListDrawer])
const onSelect = useCallback(
({ collectionSlug, docID }) => {
closeListDrawer()
insertUpload({
editor,
relationTo: collectionSlug,
replaceNodeKey,
value: docID,
})
closeDrawer()
},
[editor, closeDrawer, replaceNodeKey],
[editor, closeListDrawer, replaceNodeKey],
)
return <ListDrawer onSelect={onSelect} />

View File

@@ -1,7 +1,7 @@
'use client'
import type { ClientField, Data, FormState, JsonObject } from 'payload'
import { Drawer } from '@payloadcms/ui'
import { Drawer, useModal } from '@payloadcms/ui'
import React from 'react'
import { DrawerContent } from './DrawerContent.js'
@@ -34,6 +34,7 @@ export const FieldsDrawer: React.FC<FieldsDrawerProps> = ({
schemaFieldsPathOverride,
schemaPathSuffix,
}) => {
const { closeModal } = useModal()
// The Drawer only renders its children (and itself) if it's open. Thus, by extracting the main content
// to DrawerContent, this should be faster
return (
@@ -42,7 +43,19 @@ export const FieldsDrawer: React.FC<FieldsDrawerProps> = ({
data={data}
featureKey={featureKey}
fieldMapOverride={fieldMapOverride}
handleDrawerSubmit={handleDrawerSubmit}
handleDrawerSubmit={(args, args2) => {
// Simply close drawer - no need for useLexicalDrawer here as at this point,
// we don't need to restore the cursor position. This is handled by the useEffect in useLexicalDrawer.
closeModal(drawerSlug)
// Actual drawer submit logic needs to be triggered after the drawer is closed.
// That's because the lexical selection / cursor restore logic that is striggerer by
// `useLexicalDrawer` neeeds to be triggered before any editor.update calls that may happen
// in the `handleDrawerSubmit` function.
setTimeout(() => {
handleDrawerSubmit(args, args2)
}, 1)
}}
schemaFieldsPathOverride={schemaFieldsPathOverride}
schemaPathSuffix={schemaPathSuffix}
/>

View File

@@ -0,0 +1,81 @@
'use client'
import type { UseDocumentDrawer } from '@payloadcms/ui'
import type { BaseSelection } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useDocumentDrawer, useModal } from '@payloadcms/ui'
import { $getPreviousSelection, $getSelection, $setSelection } from 'lexical'
import { useCallback, useEffect, useState } from 'react'
/**
*
* Wrapper around useDocumentDrawer that restores and saves selection state (cursor position) when opening and closing the drawer.
* By default, the lexical cursor position may be lost when opening a drawer and clicking somewhere on that drawer.
*/
export const useLexicalDocumentDrawer = (
args: Parameters<UseDocumentDrawer>[0],
): {
closeDocumentDrawer: () => void
DocumentDrawer: ReturnType<UseDocumentDrawer>[0]
documentDrawerSlug: string
DocumentDrawerToggler: ReturnType<UseDocumentDrawer>[1]
} => {
const [editor] = useLexicalComposerContext()
const [selectionState, setSelectionState] = useState<BaseSelection | null>(null)
const [wasOpen, setWasOpen] = useState<boolean>(false)
const [
DocumentDrawer,
DocumentDrawerToggler,
{ closeDrawer: closeDrawer, drawerSlug: documentDrawerSlug },
] = useDocumentDrawer(args)
const { modalState } = useModal()
const storeSelection = useCallback(() => {
editor.read(() => {
const selection = $getSelection() ?? $getPreviousSelection()
setSelectionState(selection)
})
setWasOpen(true)
}, [editor])
const restoreSelection = useCallback(() => {
if (selectionState) {
editor.update(
() => {
$setSelection(selectionState.clone())
},
{ discrete: true, skipTransforms: true },
)
}
}, [editor, selectionState])
const closeDocumentDrawer = () => {
//restoreSelection() // Should already be stored by the useEffect below
closeDrawer()
}
// We need to handle drawer closing via a useEffect, as toggleDrawer / closeDrawer will not be triggered if the drawer
// is closed by clicking outside of the drawer. This useEffect will handle everything.
useEffect(() => {
if (!wasOpen) {
return
}
const thisModalState = modalState[documentDrawerSlug]
// Exists in modalState (thus has opened at least once before) and is closed
if (thisModalState && !thisModalState?.isOpen) {
setWasOpen(false)
setTimeout(() => {
restoreSelection()
}, 1)
}
}, [modalState, documentDrawerSlug, restoreSelection, wasOpen])
return {
closeDocumentDrawer,
DocumentDrawer,
documentDrawerSlug,
DocumentDrawerToggler: (props) => <DocumentDrawerToggler {...props} onClick={storeSelection} />,
}
}

View File

@@ -0,0 +1,86 @@
'use client'
import type { BaseSelection } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useModal } from '@payloadcms/ui'
import { $getPreviousSelection, $getSelection, $setSelection } from 'lexical'
import { useCallback, useEffect, useState } from 'react'
/**
*
* Wrapper around useModal that restores and saves selection state (cursor position) when opening and closing the drawer.
* By default, the lexical cursor position may be lost when opening a drawer and clicking somewhere on that drawer.
*/
export const useLexicalDrawer = (slug: string, restoreLate?: boolean) => {
const [editor] = useLexicalComposerContext()
const [selectionState, setSelectionState] = useState<BaseSelection | null>(null)
const [wasOpen, setWasOpen] = useState<boolean>(false)
const {
closeModal: closeBaseModal,
isModalOpen: isBaseModalOpen,
modalState,
toggleModal: toggleBaseModal,
} = useModal()
const storeSelection = useCallback(() => {
editor.read(() => {
const selection = $getSelection() ?? $getPreviousSelection()
setSelectionState(selection)
})
}, [editor])
const restoreSelection = useCallback(() => {
if (selectionState) {
editor.update(
() => {
$setSelection(selectionState.clone())
},
{ discrete: true, skipTransforms: true },
)
}
}, [editor, selectionState])
const closeDrawer = () => {
//restoreSelection() // Should already be stored by the useEffect below
closeBaseModal(slug)
}
const toggleDrawer = () => {
if (!isBaseModalOpen(slug)) {
storeSelection()
} else {
restoreSelection()
}
setWasOpen(true)
toggleBaseModal(slug)
}
// We need to handle drawer closing via a useEffect, as toggleDrawer / closeDrawer will not be triggered if the drawer
// is closed by clicking outside of the drawer. This useEffect will handle everything.
useEffect(() => {
if (!wasOpen) {
return
}
const thisModalState = modalState[slug]
// Exists in modalState (thus has opened at least once before) and is closed
if (thisModalState && !thisModalState?.isOpen) {
setWasOpen(false)
if (restoreLate) {
// restoreLate is used for upload extra field drawers. For some reason, the selection is not restored if we call restoreSelection immediately.
setTimeout(() => {
restoreSelection()
}, 1)
} else {
restoreSelection()
}
}
}, [modalState, slug, restoreSelection, wasOpen, restoreLate])
return {
closeDrawer,
toggleDrawer,
}
}

View File

@@ -0,0 +1,100 @@
'use client'
import type { UseListDrawer } from '@payloadcms/ui'
import type { BaseSelection } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useListDrawer, useModal } from '@payloadcms/ui'
import { $getPreviousSelection, $getSelection, $setSelection } from 'lexical'
import { useCallback, useEffect, useState } from 'react'
/**
*
* Wrapper around useListDrawer that restores and saves selection state (cursor position) when opening and closing the drawer.
* By default, the lexical cursor position may be lost when opening a drawer and clicking somewhere on that drawer.
*/
export const useLexicalListDrawer = (
args: Parameters<UseListDrawer>[0],
): {
closeListDrawer: () => void
isListDrawerOpen: boolean
ListDrawer: ReturnType<UseListDrawer>[0]
listDrawerSlug: string
ListDrawerToggler: ReturnType<UseListDrawer>[1]
openListDrawer: (selection?: BaseSelection) => void
} => {
const [editor] = useLexicalComposerContext()
const [selectionState, setSelectionState] = useState<BaseSelection | null>(null)
const [wasOpen, setWasOpen] = useState<boolean>(false)
const [
BaseListDrawer,
BaseListDrawerToggler,
{
closeDrawer: baseCloseDrawer,
drawerSlug: listDrawerSlug,
isDrawerOpen,
openDrawer: baseOpenDrawer,
},
] = useListDrawer(args)
const { modalState } = useModal()
const $storeSelection = useCallback(() => {
// editor.read() causes an error here when creating a new upload node from the slash menu. It seems like we can omit it here though, as all
// invocations of that functions are wrapped in editor.read() or editor.update() somewhere in the call stack.
const selection = $getSelection() ?? $getPreviousSelection()
setSelectionState(selection)
}, [])
const restoreSelection = useCallback(() => {
if (selectionState) {
editor.update(
() => {
$setSelection(selectionState.clone())
},
{ discrete: true, skipTransforms: true },
)
}
}, [editor, selectionState])
const closeListDrawer = () => {
//restoreSelection() // Should already be stored by the useEffect below
baseCloseDrawer()
}
// We need to handle drawer closing via a useEffect, as toggleDrawer / closeDrawer will not be triggered if the drawer
// is closed by clicking outside of the drawer. This useEffect will handle everything.
useEffect(() => {
if (!wasOpen) {
return
}
const thisModalState = modalState[listDrawerSlug]
// Exists in modalState (thus has opened at least once before) and is closed
if (thisModalState && !thisModalState?.isOpen) {
setWasOpen(false)
setTimeout(() => {
restoreSelection()
}, 1)
}
}, [modalState, listDrawerSlug, restoreSelection, wasOpen])
return {
closeListDrawer,
isListDrawerOpen: isDrawerOpen,
ListDrawer: BaseListDrawer,
listDrawerSlug,
ListDrawerToggler: (props) => (
<BaseListDrawerToggler
{...props}
onClick={() => {
$storeSelection()
}}
/>
),
openListDrawer: () => {
$storeSelection()
baseOpenDrawer()
setWasOpen(true)
},
}
}

View File

@@ -13,6 +13,10 @@
}
.btn {
* {
pointer-events: none;
}
// colors
&--style-primary {
--color: var(--theme-elevation-0);

View File

@@ -33,6 +33,7 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
collectionSlug,
disabled,
drawerSlug,
onClick,
...rest
}) => {
const { i18n, t } = useTranslation()
@@ -45,6 +46,7 @@ export const DocumentDrawerToggler: React.FC<DocumentTogglerProps> = ({
})}
className={[className, `${baseClass}__toggler`].filter(Boolean).join(' ')}
disabled={disabled}
onClick={onClick}
slug={drawerSlug}
{...rest}
>

View File

@@ -27,6 +27,7 @@ export type DocumentTogglerProps = {
readonly disabled?: boolean
readonly drawerSlug?: string
readonly id?: string
readonly onClick?: () => void
} & Readonly<HTMLAttributes<HTMLButtonElement>>
export type UseDocumentDrawer = (args: { collectionSlug: string; id?: number | string }) => [

View File

@@ -27,12 +27,14 @@ export const ListDrawerToggler: React.FC<ListTogglerProps> = ({
className,
disabled,
drawerSlug,
onClick,
...rest
}) => {
return (
<DrawerToggler
className={[className, `${baseClass}__toggler`].filter(Boolean).join(' ')}
disabled={disabled}
onClick={onClick}
slug={drawerSlug}
{...rest}
>

View File

@@ -35,7 +35,7 @@ export type UseListDrawer = (args: {
React.FC<
Pick<ListDrawerProps, 'allowCreate' | 'enableRowSelections' | 'onBulkSelect' | 'onSelect'>
>, // drawer
React.FC<Pick<ListTogglerProps, 'children' | 'className' | 'disabled'>>, // toggler
React.FC<Pick<ListTogglerProps, 'children' | 'className' | 'disabled' | 'onClick'>>, // toggler
{
closeDrawer: () => void
collectionSlugs: SanitizedCollectionConfig['slug'][]

View File

@@ -42,6 +42,12 @@ export { DeleteMany } from '../../elements/DeleteMany/index.js'
export { DocumentControls } from '../../elements/DocumentControls/index.js'
export { Dropzone } from '../../elements/Dropzone/index.js'
export { useDocumentDrawer } from '../../elements/DocumentDrawer/index.js'
export type {
DocumentDrawerProps,
DocumentTogglerProps,
UseDocumentDrawer,
} from '../../elements/DocumentDrawer/types.js'
export { DocumentFields } from '../../elements/DocumentFields/index.js'
export { Drawer, DrawerToggler, formatDrawerSlug } from '../../elements/Drawer/index.js'
export { useDrawerSlug } from '../../elements/Drawer/useDrawerSlug.js'
@@ -55,6 +61,11 @@ export { HydrateAuthProvider } from '../../elements/HydrateAuthProvider/index.js
export { Locked } from '../../elements/Locked/index.js'
export { ListControls } from '../../elements/ListControls/index.js'
export { useListDrawer } from '../../elements/ListDrawer/index.js'
export type {
ListDrawerProps,
ListTogglerProps,
UseListDrawer,
} from '../../elements/ListDrawer/types.js'
export { ListSelection } from '../../elements/ListSelection/index.js'
export { ListHeader } from '../../elements/ListHeader/index.js'
export { LoadingOverlayToggle } from '../../elements/Loading/index.js'

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)