feat(richtext-lexical): new FieldsDrawer utility, improve blocks feature performance (#6967)

This commit is contained in:
Alessio Gravili
2024-06-27 16:36:08 -04:00
committed by GitHub
10 changed files with 166 additions and 296 deletions

View File

@@ -123,3 +123,5 @@ export {
$isBlockNode,
BlockNode,
} from '../../features/blocks/nodes/BlocksNode.js'
export { FieldsDrawer } from '../../utilities/fieldsDrawer/Drawer.js'

View File

@@ -72,7 +72,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
apiRoute: config.routes.api,
body: {
id,
data: JSON.parse(JSON.stringify(formData)),
data: formData,
operation: 'update',
schemaPath: schemaFieldsPath,
},

View File

@@ -1,50 +0,0 @@
@import '../../../scss/styles.scss';
.lexical-link-edit-drawer {
&__template {
position: relative;
z-index: 1;
padding-top: var(--base);
padding-bottom: calc(var(--base) * 2);
}
&__header {
width: 100%;
margin-bottom: var(--base);
display: flex;
justify-content: space-between;
margin-top: calc(var(--base) * 2.5);
margin-bottom: var(--base);
@include mid-break {
margin-top: calc(var(--base) * 1.5);
}
}
&__header-text {
margin: 0;
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: var(--base);
height: var(--base);
svg {
width: calc(var(--base) * 2.75);
height: calc(var(--base) * 2.75);
position: relative;
left: calc(var(--base) * -0.825);
top: calc(var(--base) * -0.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}

View File

@@ -1,9 +0,0 @@
import type { FormState } from 'payload'
import type { LinkFields } from '../nodes/types.js'
export interface Props {
drawerSlug: string
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
stateData: {} | (LinkFields & { text: string })
}

View File

@@ -24,7 +24,7 @@ import type { LinkPayload } from '../types.js'
import { useEditorConfigContext } from '../../../../../lexical/config/client/EditorConfigProvider.js'
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode.js'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor.js'
import { LinkDrawer } from '../../../drawer/index.js'
import { FieldsDrawer } from '../../../../../utilities/fieldsDrawer/Drawer.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'
@@ -44,7 +44,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
const [stateData, setStateData] = useState<{} | (LinkFields & { id?: string; text: string })>({})
const { closeModal, isModalOpen, toggleModal } = useModal()
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const [selectedNodes, setSelectedNodes] = useState<LexicalNode[]>([])
@@ -312,50 +312,52 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R
)}
</div>
</div>
{isModalOpen(drawerSlug) && (
<LinkDrawer
drawerSlug={drawerSlug}
handleModalSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
<FieldsDrawer
className="lexical-link-edit-drawer"
data={stateData}
drawerSlug={drawerSlug}
drawerTitle={t('fields:editLink')}
featureKey="link"
handleDrawerSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug)
const newLinkPayload = data as LinkFields & { text: string }
const newLinkPayload = data as LinkFields & { text: string }
const bareLinkFields: LinkFields = {
...newLinkPayload,
const bareLinkFields: LinkFields = {
...newLinkPayload,
}
delete bareLinkFields.text
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
let linkParent = null
if ($isRangeSelection(selection)) {
linkParent = getSelectedNode(selection).getParent()
} else {
if (selectedNodes.length) {
linkParent = selectedNodes[0].getParent()
}
}
delete bareLinkFields.text
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
let linkParent = null
if ($isRangeSelection(selection)) {
linkParent = getSelectedNode(selection).getParent()
} else {
if (selectedNodes.length) {
linkParent = selectedNodes[0].getParent()
}
}
if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
})
if (linkParent && $isAutoLinkNode(linkParent)) {
const linkNode = $createLinkNode({
fields: bareLinkFields,
})
linkParent.replace(linkNode, true)
}
})
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: bareLinkFields,
selectedNodes,
text: newLinkPayload.text,
})
}}
stateData={stateData}
/>
)}
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: bareLinkFields,
selectedNodes,
text: newLinkPayload.text,
})
}}
schemaPathSuffix="fields"
/>
</React.Fragment>
)
}

View File

@@ -1,149 +0,0 @@
'use client'
import type { FormProps } from '@payloadcms/ui'
import type { ClientCollectionConfig, FormState } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { getTranslation } from '@payloadcms/translations'
import {
Drawer,
Form,
FormSubmit,
RenderFields,
useConfig,
useDocumentInfo,
useFieldProps,
useModal,
useTranslation,
} from '@payloadcms/ui'
import { getFormState } from '@payloadcms/ui/shared'
import { $getNodeByKey } from 'lexical'
import { deepCopyObject } from 'payload/shared'
import React, { useCallback, useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import type { UploadData, UploadNode } from '../../nodes/UploadNode.js'
import type { ElementProps } from '../index.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
/**
* This handles the extra fields, e.g. captions or alt text, which are
* potentially added to the upload feature.
*/
export const ExtraFieldsUploadDrawer: React.FC<
ElementProps & {
drawerSlug: string
relatedCollection: ClientCollectionConfig
}
> = (props) => {
const {
data: { fields, relationTo, value },
drawerSlug,
nodeKey,
relatedCollection,
} = props
const [editor] = useLexicalComposerContext()
const { closeModal } = useModal()
const { i18n, t } = useTranslation()
const { id } = useDocumentInfo()
const { schemaPath } = useFieldProps()
const config = useConfig()
const [initialState, setInitialState] = useState<FormState | false>(false)
const {
field: { richTextComponentMap },
} = useEditorConfigContext()
const componentMapRenderedFieldsPath = `feature.upload.fields.${relatedCollection.slug}`
const schemaFieldsPath = `${schemaPath}.feature.upload.${relatedCollection.slug}`
const fieldMap = richTextComponentMap.get(componentMapRenderedFieldsPath) // Field Schema
useEffect(() => {
const awaitInitialState = async () => {
const state = await getFormState({
apiRoute: config.routes.api,
body: {
id,
data: deepCopyObject(fields || {}),
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
}) // Form State
setInitialState(state)
}
void awaitInitialState()
}, [config.routes.api, config.serverURL, schemaFieldsPath, id, fields])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
return await getFormState({
apiRoute: config.routes.api,
body: {
id,
formState: prevFormState,
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
})
},
[config.routes.api, config.serverURL, schemaFieldsPath, id],
)
const handleUpdateEditData = useCallback(
(_, data) => {
// Update lexical node (with key nodeKey) with new data
editor.update(() => {
const uploadNode: UploadNode | null = $getNodeByKey(nodeKey)
if (uploadNode) {
const newData: UploadData = {
...uploadNode.getData(),
fields: data,
}
uploadNode.setData(newData)
}
})
closeModal(drawerSlug)
},
[closeModal, editor, drawerSlug, nodeKey],
)
return (
<Drawer
slug={drawerSlug}
title={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
{initialState !== false && (
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
// @ts-expect-error TODO: Fix this
fields={fieldMap}
initialState={initialState}
onChange={[onChange]}
onSubmit={handleUpdateEditData}
uuid={uuid()}
>
<RenderFields
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
forceRender
path="" // See Blocks feature path for details as for why this is empty
readOnly={false}
schemaPath={schemaFieldsPath}
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
)}
</Drawer>
)
}

View File

@@ -1,5 +1,5 @@
'use client'
import type { ClientCollectionConfig } from 'payload'
import type { ClientCollectionConfig, Data } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
@@ -12,6 +12,7 @@ import {
useConfig,
useDocumentDrawer,
useDrawerSlug,
useModal,
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
@@ -28,13 +29,13 @@ import React, { useCallback, useEffect, useReducer, useRef, useState } from 'rea
import type { ClientComponentProps } from '../../typesClient.js'
import type { UploadFeaturePropsClient } from '../feature.client.js'
import type { UploadData } from '../nodes/UploadNode.js'
import type { UploadData, UploadNode } from '../nodes/UploadNode.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { FieldsDrawer } from '../../../utilities/fieldsDrawer/Drawer.js'
import { EnabledRelationshipsCondition } from '../../relationship/utils/EnabledRelationshipsCondition.js'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import { $isUploadNode } from '../nodes/UploadNode.js'
import { ExtraFieldsUploadDrawer } from './ExtraFieldsDrawer/index.js'
import './index.scss'
const baseClass = 'lexical-upload'
@@ -60,6 +61,7 @@ const Component: React.FC<ElementProps> = (props) => {
serverURL,
} = useConfig()
const uploadRef = useRef<HTMLDivElement | null>(null)
const { closeModal } = useModal()
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
@@ -68,7 +70,7 @@ const Component: React.FC<ElementProps> = (props) => {
const { i18n, t } = useTranslation()
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [relatedCollection, setRelatedCollection] = useState<ClientCollectionConfig>(() =>
const [relatedCollection] = useState<ClientCollectionConfig>(() =>
collections.find((coll) => coll.slug === relationTo),
)
@@ -94,7 +96,7 @@ const Component: React.FC<ElementProps> = (props) => {
}, [editor, nodeKey])
const updateUpload = useCallback(
(json) => {
(data: Data) => {
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
@@ -107,9 +109,8 @@ const Component: React.FC<ElementProps> = (props) => {
)
const $onDelete = useCallback(
(payload: KeyboardEvent) => {
(event: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
const event: KeyboardEvent = payload
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isUploadNode(node)) {
@@ -122,8 +123,7 @@ const Component: React.FC<ElementProps> = (props) => {
[isSelected, nodeKey],
)
const onClick = useCallback(
(payload: MouseEvent) => {
const event = payload
(event: MouseEvent) => {
// Check if uploadRef.target or anything WITHIN uploadRef.target was clicked
if (event.target === uploadRef.current || uploadRef.current?.contains(event.target as Node)) {
if (event.shiftKey) {
@@ -156,6 +156,25 @@ const Component: React.FC<ElementProps> = (props) => {
?.sanitizedClientFeatureProps as ClientComponentProps<UploadFeaturePropsClient>
).collections?.[relatedCollection.slug]?.hasExtraFields
const onExtraFieldsDrawerSubmit = useCallback(
(_, data) => {
// Update lexical node (with key nodeKey) with new data
editor.update(() => {
const uploadNode: UploadNode | null = $getNodeByKey(nodeKey)
if (uploadNode) {
const newData: UploadData = {
...uploadNode.getData(),
fields: data,
}
uploadNode.setData(newData)
}
})
closeModal(drawerSlug)
},
[closeModal, editor, drawerSlug, nodeKey],
)
return (
<div
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
@@ -239,10 +258,15 @@ const Component: React.FC<ElementProps> = (props) => {
</div>
{value && <DocumentDrawer onSave={updateUpload} />}
{hasExtraFields ? (
<ExtraFieldsUploadDrawer
<FieldsDrawer
data={fields}
drawerSlug={drawerSlug}
relatedCollection={relatedCollection}
{...props}
drawerTitle={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
featureKey="upload"
handleDrawerSubmit={onExtraFieldsDrawerSubmit}
schemaPathSuffix={relatedCollection.slug}
/>
) : null}
</div>

View File

@@ -981,3 +981,5 @@ export { migrateSlateToLexical } from './utilities/migrateSlateToLexical/index.j
export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'
export * from './nodeTypes.js'
export type { FieldsDrawerProps } from './utilities/fieldsDrawer/Drawer.js'

View File

@@ -0,0 +1,45 @@
'use client'
import type { FormState } from 'payload'
import { Drawer } from '@payloadcms/ui'
import React from 'react'
import { DrawerContent } from './DrawerContent.js'
export type FieldsDrawerProps = {
className?: string
data: any
drawerSlug: string
drawerTitle?: string
featureKey: string
handleDrawerSubmit: (fields: FormState, data: Record<string, unknown>) => void
schemaPathSuffix?: string
}
/**
* This FieldsDrawer component can be used to easily create a Drawer that contains a form with fields within your feature.
* The fields are taken directly from the schema map based on your `featureKey` and `schemaPathSuffix`. Thus, this can only
* be used if you provide your field schema inside the `generateSchemaMap` prop of your feature.server.ts.
*/
export const FieldsDrawer: React.FC<FieldsDrawerProps> = ({
className,
data,
drawerSlug,
drawerTitle,
featureKey,
handleDrawerSubmit,
schemaPathSuffix,
}) => {
// 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 (
<Drawer className={className} slug={drawerSlug} title={drawerTitle ?? ''}>
<DrawerContent
data={data}
featureKey={featureKey}
handleDrawerSubmit={handleDrawerSubmit}
schemaPathSuffix={schemaPathSuffix}
/>
</Drawer>
)
}

View File

@@ -1,8 +1,8 @@
'use client'
import type { FormProps } from '@payloadcms/ui'
import type { FormState } from 'payload'
import {
Drawer,
Form,
FormSubmit,
RenderFields,
@@ -15,13 +15,16 @@ import { getFormState } from '@payloadcms/ui/shared'
import React, { useCallback, useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import './index.scss'
import { type Props } from './types.js'
import type { FieldsDrawerProps } from './Drawer.js'
const baseClass = 'lexical-link-edit-drawer'
import { useEditorConfigContext } from '../../lexical/config/client/EditorConfigProvider.js'
export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, stateData }) => {
export const DrawerContent: React.FC<Omit<FieldsDrawerProps, 'drawerSlug' | 'drawerTitle'>> = ({
data,
featureKey,
handleDrawerSubmit,
schemaPathSuffix,
}) => {
const { t } = useTranslation()
const { id } = useDocumentInfo()
const { schemaPath } = useFieldProps()
@@ -31,8 +34,8 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
field: { richTextComponentMap },
} = useEditorConfigContext()
const componentMapRenderedFieldsPath = `feature.link.fields.fields`
const schemaFieldsPath = `${schemaPath}.feature.link.fields`
const componentMapRenderedFieldsPath = `feature.${featureKey}.fields${schemaPathSuffix ? `.${schemaPathSuffix}` : ''}`
const schemaFieldsPath = `${schemaPath}.feature.${featureKey}${schemaPathSuffix ? `.${schemaPathSuffix}` : ''}`
const fieldMap = richTextComponentMap.get(componentMapRenderedFieldsPath) // Field Schema
@@ -42,7 +45,7 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
apiRoute: config.routes.api,
body: {
id,
data: stateData,
data,
operation: 'update',
schemaPath: schemaFieldsPath,
},
@@ -52,10 +55,10 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
setInitialState(state)
}
if (stateData) {
if (data) {
void awaitInitialState()
}
}, [config.routes.api, config.serverURL, schemaFieldsPath, id, stateData])
}, [config.routes.api, config.serverURL, schemaFieldsPath, id, data])
const onChange: FormProps['onChange'][0] = useCallback(
async ({ formState: prevFormState }) => {
@@ -74,29 +77,29 @@ export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, sta
[config.routes.api, config.serverURL, schemaFieldsPath, id],
)
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink') ?? ''}>
{initialState !== false && (
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
fields={Array.isArray(fieldMap) ? fieldMap : []}
initialState={initialState}
onChange={[onChange]}
onSubmit={handleModalSubmit}
uuid={uuid()}
>
<RenderFields
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
forceRender
path="" // See Blocks feature path for details as for why this is empty
readOnly={false}
schemaPath={schemaFieldsPath}
/>
if (initialState === false) {
return null
}
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>
)}
</Drawer>
return (
<Form
beforeSubmit={[onChange]}
disableValidationOnSubmit
fields={Array.isArray(fieldMap) ? fieldMap : []}
initialState={initialState}
onChange={[onChange]}
onSubmit={handleDrawerSubmit}
uuid={uuid()}
>
<RenderFields
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
forceRender
path="" // See Blocks feature path for details as for why this is empty
readOnly={false}
schemaPath={schemaFieldsPath}
/>
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
)
}