Files
payload/packages/richtext-lexical/src/features/upload/client/component/index.tsx
Germán Jabloñski 8b44676b0d feat(richtext-lexical)!: upgrade lexical from 0.17.0 to 0.18.0, make tables more reliable (#8444)
This PR

- Introduces multiline markdown transformers / mdx support
- Introduce `shouldMergeAdjacentLines` option in
`$convertFromMarkdownString`. If true, merges adjacent lines as per
commonmark spec. This would allow to close:
https://github.com/payloadcms/payload/issues/8049
- Many new features and bug fixes!
- Ports over changes from the lexical playground. Most notably:
  - add support for enabling table row stripping
  - make table resizing & table cell selection more reliable

**BREAKING**: This upgrades lexical from 0.17.0 to 0.18.0. If you have
any lexical packages installed in your project, please update them
accordingly. Additionally, if you depend on the lexical APIs, please
consult their changelog, as lexical may introduce breaking changes:
https://github.com/facebook/lexical/releases/tag/v0.18.0

---------

Co-authored-by: Alessio Gravili <alessio@gravili.de>
2024-09-28 13:10:44 -04:00

305 lines
9.4 KiB
TypeScript

'use client'
import type { ClientCollectionConfig, Data } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection.js'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
DrawerToggler,
File,
formatDrawerSlug,
useConfig,
useDocumentDrawer,
useEditDepth,
useModal,
usePayloadAPI,
useTranslation,
} from '@payloadcms/ui'
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import React, { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'
import type { BaseClientFeatureProps } from '../../../typesClient.js'
import type { UploadData } from '../../server/nodes/UploadNode.js'
import type { UploadFeaturePropsClient } from '../feature.client.js'
import type { UploadNode } from '../nodes/UploadNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { FieldsDrawer } from '../../../../utilities/fieldsDrawer/Drawer.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'
import './index.scss'
const baseClass = 'lexical-upload'
const initialParams = {
depth: 0,
}
export type ElementProps = {
data: UploadData
nodeKey: string
}
const Component: React.FC<ElementProps> = (props) => {
const {
data: { fields, relationTo, value },
nodeKey,
} = props
if (typeof value === 'object') {
throw new Error(
'Upload value should be a string or number. The Lexical Upload component should not receive the populated value object.',
)
}
const {
config: {
collections,
routes: { api },
serverURL,
},
} = useConfig()
const uploadRef = useRef<HTMLDivElement | null>(null)
const { closeModal } = useModal()
const { uuid } = useEditorConfigContext()
const editDepth = useEditDepth()
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const { editorConfig, field } = useEditorConfigContext()
const { i18n, t } = useTranslation()
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [relatedCollection] = useState<ClientCollectionConfig>(
() => collections.find((coll) => coll.slug === relationTo)!,
)
const componentID = useId()
const drawerSlug = 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({
id: value,
collectionSlug: relatedCollection.slug,
})
// Get the referenced document
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value}`,
{ initialParams },
)
const thumbnailSRC = data?.thumbnailURL || data?.url
const removeUpload = useCallback(() => {
editor.update(() => {
$getNodeByKey(nodeKey)?.remove()
})
}, [editor, nodeKey])
const updateUpload = useCallback(
(data: Data) => {
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
dispatchCacheBust()
closeDrawer()
},
[setParams, cacheBust, closeDrawer],
)
const $onDelete = useCallback(
(event: KeyboardEvent) => {
const deleteSelection = $getSelection()
if (isSelected && $isNodeSelection(deleteSelection)) {
event.preventDefault()
editor.update(() => {
deleteSelection.getNodes().forEach((node) => {
if ($isUploadNode(node)) {
node.remove()
}
})
})
}
return false
},
[editor, isSelected],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(
CLICK_COMMAND,
(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) {
setSelected(!isSelected)
} else {
if (!isSelected) {
clearSelection()
setSelected(true)
}
}
return true
}
return false
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected])
const hasExtraFields = (
editorConfig?.resolvedFeatureMap?.get('upload')
?.sanitizedClientFeatureProps as BaseClientFeatureProps<UploadFeaturePropsClient>
).collections?.[relatedCollection.slug]?.hasExtraFields
const onExtraFieldsDrawerSubmit = useCallback(
(_, data) => {
// Update lexical node (with key nodeKey) with new data
editor.update(() => {
const uploadNode: null | UploadNode = $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(' ')}
contentEditable={false}
ref={uploadRef}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
{/* TODO: migrate to use @payloadcms/ui/elements/Thumbnail component */}
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC ? (
<img
alt={data?.filename}
data-lexical-upload-id={value}
data-lexical-upload-relation-to={relationTo}
src={thumbnailSRC}
/>
) : (
<File />
)}
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
{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"
icon="edit"
onClick={(e) => {
e.preventDefault()
}}
round
tooltip={t('fields:editRelationship')}
/>
</DrawerToggler>
) : null}
<Button
buttonStyle="icon-label"
disabled={field?.admin?.readOnly}
el="div"
icon="swap"
onClick={() => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: { nodeKey },
})
}}
round
tooltip={t('fields:swapUpload')}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.admin?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeUpload()
}}
round
tooltip={t('fields:removeUpload')}
/>
</div>
)}
</div>
</div>
<div className={`${baseClass}__bottomRow`}>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<strong>{data?.filename}</strong>
</DocumentDrawerToggler>
</div>
</div>
{value ? <DocumentDrawer onSave={updateUpload} /> : null}
{hasExtraFields ? (
<FieldsDrawer
data={fields}
drawerSlug={drawerSlug}
drawerTitle={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
featureKey="upload"
handleDrawerSubmit={onExtraFieldsDrawerSubmit}
schemaPathSuffix={relatedCollection.slug}
/>
) : null}
</div>
)
}
export const UploadComponent = (props: ElementProps): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<Component {...props} />
</EnabledRelationshipsCondition>
)
}