feat: initial lexical support (#5206)

* chore: explores pattern for rscs in lexical

* WORKING!!!!!!

* fix(richtext-slate): field map path

* Working Link Drawer

* fix issues after merge

* AlignFeature

* Fix AlignFeature

---------

Co-authored-by: James <james@trbl.design>
This commit is contained in:
Alessio Gravili
2024-02-28 16:55:37 -05:00
committed by GitHub
parent 1a1c207a97
commit e90d8dcdb5
82 changed files with 1389 additions and 1044 deletions

20
.vscode/settings.json vendored
View File

@@ -36,26 +36,6 @@
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
// Load .git-blame-ignore-revs file // Load .git-blame-ignore-revs file
"gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"], "gitlens.advanced.blame.customArguments": ["--ignore-revs-file", ".git-blame-ignore-revs"],
"workbench.colorCustomizations": {
"activityBar.activeBackground": "#7fafca",
"activityBar.background": "#7fafca",
"activityBar.foreground": "#15202b",
"activityBar.inactiveForeground": "#15202b99",
"activityBarBadge.background": "#b44b8e",
"activityBarBadge.foreground": "#e7e7e7",
"commandCenter.border": "#15202b99",
"sash.hoverBorder": "#7fafca",
"statusBar.background": "#5b98bb",
"statusBar.foreground": "#15202b",
"statusBarItem.hoverBackground": "#437ea0",
"statusBarItem.remoteBackground": "#5b98bb",
"statusBarItem.remoteForeground": "#15202b",
"titleBar.activeBackground": "#5b98bb",
"titleBar.activeForeground": "#15202b",
"titleBar.inactiveBackground": "#5b98bb99",
"titleBar.inactiveForeground": "#15202b99"
},
"peacock.color": "#5b98bb",
"[javascript][typescript][typescriptreact]": { "[javascript][typescript][typescriptreact]": {
"editor.codeActionsOnSave": { "editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit" "source.fixAll.eslint": "explicit"

View File

@@ -46,12 +46,13 @@
"@types/json-schema": "7.0.15", "@types/json-schema": "7.0.15",
"@types/node": "20.6.2", "@types/node": "20.6.2",
"@types/react": "18.2.15", "@types/react": "18.2.15",
"@types/react-dom": "18.2.7",
"payload": "workspace:*" "payload": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
"payload": "^2.4.0",
"@payloadcms/translations": "workspace:^", "@payloadcms/translations": "workspace:^",
"@payloadcms/ui": "workspace:^" "@payloadcms/ui": "workspace:^",
"payload": "^2.4.0"
}, },
"exports": { "exports": {
".": { ".": {

View File

@@ -6,12 +6,12 @@ import { useTableCell } from '@payloadcms/ui/elements'
import { $getRoot } from 'lexical' import { $getRoot } from 'lexical'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import type { SanitizedEditorConfig } from '../field/lexical/config/types' import type { SanitizedClientEditorConfig } from '../field/lexical/config/types'
import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider' import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider'
import { useClientFunctions } from '../../../ui/src/providers/ClientFunction' import { useClientFunctions } from '../../../ui/src/providers/ClientFunction'
import { defaultEditorLexicalConfig } from '../field/lexical/config/defaultClient' import { defaultEditorLexicalConfig } from '../field/lexical/config/client/default'
import { sanitizeEditorConfig } from '../field/lexical/config/sanitize' import { sanitizeClientEditorConfig } from '../field/lexical/config/client/sanitize'
import { getEnabledNodes } from '../field/lexical/nodes' import { getEnabledNodes } from '../field/lexical/nodes'
export const RichTextCell: React.FC<{ export const RichTextCell: React.FC<{
@@ -30,12 +30,10 @@ export const RichTextCell: React.FC<{
return return
} }
const finalSanitizedEditorConfig: SanitizedEditorConfig = sanitizeEditorConfig({ const finalSanitizedEditorConfig: SanitizedClientEditorConfig = sanitizeClientEditorConfig(
features: [], lexicalEditorConfig ? lexicalEditorConfig : defaultEditorLexicalConfig,
lexical: lexicalEditorConfig null,
? () => Promise.resolve(lexicalEditorConfig) )
: () => Promise.resolve(defaultEditorLexicalConfig),
})
// Transform data through load hooks // Transform data through load hooks
if (finalSanitizedEditorConfig?.features?.hooks?.load?.length) { if (finalSanitizedEditorConfig?.features?.hooks?.load?.length) {
@@ -61,23 +59,21 @@ export const RichTextCell: React.FC<{
return return
} }
void finalSanitizedEditorConfig.lexical().then((lexicalConfig: LexicalEditorConfig) => { // initialize headless editor
// initialize headless editor const headlessEditor = createHeadlessEditor({
const headlessEditor = createHeadlessEditor({ namespace: finalSanitizedEditorConfig.lexical.namespace,
namespace: lexicalConfig.namespace, nodes: getEnabledNodes({ editorConfig: finalSanitizedEditorConfig }),
nodes: getEnabledNodes({ editorConfig: finalSanitizedEditorConfig }), theme: finalSanitizedEditorConfig.lexical.theme,
theme: lexicalConfig.theme,
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse))
const textContent =
headlessEditor.getEditorState().read(() => {
return $getRoot().getTextContent()
}) || ''
// Limiting the number of characters shown is done in a CSS rule
setPreview(textContent)
}) })
headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse))
const textContent =
headlessEditor.getEditorState().read(() => {
return $getRoot().getTextContent()
}) || ''
// Limiting the number of characters shown is done in a CSS rule
setPreview(textContent)
}, [cellData, lexicalEditorConfig]) }, [cellData, lexicalEditorConfig])
return <span>{preview}</span> return <span>{preview}</span>

View File

@@ -1,6 +1,6 @@
export { RichTextCell } from '../cell' export { RichTextCell } from '../cell'
export { RichTextField } from '../field' export { RichTextField } from '../field'
export { defaultEditorLexicalConfig } from '../field/lexical/config/defaultClient' export { defaultEditorLexicalConfig } from '../field/lexical/config/client/defaultClient'
export { ToolbarButton } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarButton' export { ToolbarButton } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarButton'
export { ToolbarDropdown } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarDropdown/index' export { ToolbarDropdown } from '../field/lexical/plugins/FloatingSelectToolbar/ToolbarDropdown/index'

View File

@@ -5,7 +5,7 @@ import { type FormFieldBase, useField } from '@payloadcms/ui'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { ErrorBoundary } from 'react-error-boundary' import { ErrorBoundary } from 'react-error-boundary'
import type { SanitizedEditorConfig } from './lexical/config/types' import type { SanitizedClientEditorConfig } from './lexical/config/types'
import { richTextValidateHOC } from '../validate' import { richTextValidateHOC } from '../validate'
import './index.scss' import './index.scss'
@@ -15,7 +15,7 @@ const baseClass = 'rich-text-lexical'
const RichText: React.FC< const RichText: React.FC<
FormFieldBase & { FormFieldBase & {
editorConfig: SanitizedEditorConfig // With rendered features n stuff editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string name: string
richTextComponentMap: Map<string, React.ReactNode> richTextComponentMap: Map<string, React.ReactNode>
} }

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../ui/src/scss/styles.scss';
.ContentEditable__root > div:has(.lexical-block) { .ContentEditable__root > div:has(.lexical-block) {
// Fixes a bug where, if the block field has a Select field, the Select field's dropdown menu would be hidden behind the lexical editor. // Fixes a bug where, if the block field has a Select field, the Select field's dropdown menu would be hidden behind the lexical editor.

View File

@@ -20,7 +20,7 @@ import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '..' import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { transformInputFormSchema } from '../utils/transformInputFormSchema' import { transformInputFormSchema } from '../utils/transformInputFormSchema'
import { BlockContent } from './BlockContent' import { BlockContent } from './BlockContent'
import './index.scss' import './index.scss'

View File

@@ -13,7 +13,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import type { BlocksFeatureProps } from '..' import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { $createBlockNode } from '../nodes/BlocksNode' import { $createBlockNode } from '../nodes/BlocksNode'
import { INSERT_BLOCK_COMMAND } from '../plugin/commands' import { INSERT_BLOCK_COMMAND } from '../plugin/commands'
const baseClass = 'lexical-blocks-drawer' const baseClass = 'lexical-blocks-drawer'

View File

@@ -1,31 +0,0 @@
import { Drawer, Form, FormSubmit, RenderFields, fieldTypes, useTranslation } from '@payloadcms/ui'
import React from 'react'
import './index.scss'
import { type Props } from './types'
const baseClass = 'lexical-link-edit-drawer'
export const LinkDrawer: React.FC<Props> = ({
drawerSlug,
fieldSchema,
handleModalSubmit,
initialState,
}) => {
const { t } = useTranslation()
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink') ?? ''}>
<Form fields={fieldSchema} initialState={initialState} onSubmit={handleModalSubmit}>
[RenderFields]
{/* <RenderFields
fieldSchema={fieldSchema}
fieldTypes={fieldTypes}
forceRender
readOnly={false}
/> */}
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>
</Drawer>
)
}

View File

@@ -1,203 +0,0 @@
import type { I18n } from '@payloadcms/translations'
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import { $findMatchingParent } from '@lexical/utils'
import { $getSelection, $isRangeSelection } from 'lexical'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import type { SerializedAutoLinkNode } from './nodes/AutoLinkNode'
import type { LinkFields, SerializedLinkNode } from './nodes/LinkNode'
import { getSelectedNode } from '../../lexical/utils/getSelectedNode'
import { FeaturesSectionWithEntries } from '../common/floatingSelectToolbarFeaturesButtonsSection'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
import { AutoLinkNode } from './nodes/AutoLinkNode'
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './plugins/floatingLinkEditor/LinkEditor/commands'
import { linkPopulationPromiseHOC } from './populationPromise'
type ExclusiveLinkCollectionsProps =
| {
/**
* The collections that should be disabled for internal linking. Overrides the `enableRichTextLink` property in the collection config.
* When this property is set, `enabledCollections` will not be available.
**/
disabledCollections?: string[]
// Ensures that enabledCollections is not available when disabledCollections is set
enabledCollections?: never
}
| {
// Ensures that disabledCollections is not available when enabledCollections is set
disabledCollections?: never
/**
* The collections that should be enabled for internal linking. Overrides the `enableRichTextLink` property in the collection config
* When this property is set, `disabledCollections` will not be available.
**/
enabledCollections?: string[]
}
export type LinkFeatureProps = ExclusiveLinkCollectionsProps & {
/**
* A function or array defining additional fields for the link feature. These will be
* displayed in the link editor drawer.
*/
fields?:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[])
| Field[]
}
export const LinkFeature = (props: LinkFeatureProps): FeatureProvider => {
return {
feature: () => {
return {
floatingSelectToolbar: {
sections: [
FeaturesSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/Link').then((module) => module.LinkIcon),
isActive: ({ selection }) => {
if ($isRangeSelection(selection)) {
const selectedNode = getSelectedNode(selection)
const linkParent = $findMatchingParent(selectedNode, $isLinkNode)
return linkParent != null
}
return false
},
key: 'link',
label: `Link`,
onClick: ({ editor, isActive }) => {
if (!isActive) {
let selectedText = null
editor.getEditorState().read(() => {
selectedText = $getSelection().getTextContent()
})
const linkFields: LinkFields = {
doc: null,
linkType: 'custom',
newTab: false,
url: 'https://',
}
editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, {
fields: linkFields,
text: selectedText,
})
} else {
// remove link
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
},
order: 1,
},
]),
],
},
nodes: [
{
type: LinkNode.getType(),
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
const rel: string = node.fields.newTab ? ' rel="noopener noreferrer"' : ''
const href: string =
node.fields.linkType === 'custom'
? node.fields.url
: (node.fields.doc?.value as string)
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [LinkNode.getType()],
} as HTMLConverter<SerializedLinkNode>,
},
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
// TODO: Add validation similar to upload for internal links and fields
},
{
type: AutoLinkNode.getType(),
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
const rel: string = node.fields.newTab ? ' rel="noopener noreferrer"' : ''
let href: string = node.fields.url
if (node.fields.linkType === 'internal') {
href =
typeof node.fields.doc?.value === 'string'
? node.fields.doc?.value
: node.fields.doc?.value?.id
}
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [AutoLinkNode.getType()],
} as HTMLConverter<SerializedAutoLinkNode>,
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
},
],
plugins: [
{
Component: () =>
// @ts-expect-error-next-line
import('./plugins/link').then((module) => module.LinkPlugin),
position: 'normal',
},
{
Component: () =>
// @ts-expect-error-next-line
import('./plugins/autoLink').then((module) => module.AutoLinkPlugin),
position: 'normal',
},
{
Component: () =>
// @ts-expect-error-next-line
import('./plugins/clickableLink').then((module) => module.ClickableLinkPlugin),
position: 'normal',
},
{
Component: () =>
// @ts-expect-error-next-line
import('./plugins/floatingLinkEditor').then((module) => {
const floatingLinkEditorPlugin = module.FloatingLinkEditorPlugin
return import('@payloadcms/ui').then((module) =>
module.withMergedProps({
Component: floatingLinkEditorPlugin,
toMergeIntoProps: props,
}),
)
}),
position: 'floatingAnchorElem',
},
],
props,
}
},
key: 'link',
}
}

View File

@@ -1,18 +0,0 @@
'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import type { LinkFeatureProps } from '../..'
import { LinkEditor } from './LinkEditor'
import './index.scss'
export const FloatingLinkEditorPlugin: React.FC<
{
anchorElem: HTMLElement
} & LinkFeatureProps
> = (props) => {
const { anchorElem = document.body } = props
return createPortal(<LinkEditor {...props} anchorElem={anchorElem} />, anchorElem)
}

View File

@@ -8,7 +8,7 @@ import React, { useCallback, useReducer, useState } from 'react'
import type { RelationshipData } from '../RelationshipNode' import type { RelationshipData } from '../RelationshipNode'
import { useEditorConfigContext } from '../../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../../drawer/commands' import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../../drawer/commands'
import './index.scss' import './index.scss'

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../../ui/src/scss/styles.scss';
.lexical-relationship { .lexical-relationship {
@extend %body; @extend %body;

View File

@@ -26,7 +26,7 @@ import type { ElementProps } from '..'
import type { UploadFeatureProps } from '../..' import type { UploadFeatureProps } from '../..'
import type { UploadData, UploadNode } from '../../nodes/UploadNode' import type { UploadData, UploadNode } from '../../nodes/UploadNode'
import { useEditorConfigContext } from '../../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider'
/** /**
* This handles the extra fields, e.g. captions or alt text, which are * This handles the extra fields, e.g. captions or alt text, which are

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../ui/src/scss/styles.scss';
.lexical-upload { .lexical-upload {
@extend %body; @extend %body;

View File

@@ -21,7 +21,7 @@ import React, { useCallback, useReducer, useState } from 'react'
import type { UploadFeatureProps } from '..' import type { UploadFeatureProps } from '..'
import type { UploadData } from '../nodes/UploadNode' import type { UploadData } from '../nodes/UploadNode'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { EnabledRelationshipsCondition } from '../../Relationship/utils/EnabledRelationshipsCondition' import { EnabledRelationshipsCondition } from '../../Relationship/utils/EnabledRelationshipsCondition'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands' import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands'
import { ExtraFieldsUploadDrawer } from './ExtraFieldsDrawer' import { ExtraFieldsUploadDrawer } from './ExtraFieldsDrawer'

View File

@@ -0,0 +1,75 @@
'use client'
import { FORMAT_ELEMENT_COMMAND } from 'lexical'
import type { FeatureProviderProviderClient } from '../types'
import { AlignCenterIcon } from '../../lexical/ui/icons/AlignCenter'
import { AlignJustifyIcon } from '../../lexical/ui/icons/AlignJustify'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft'
import { AlignRightIcon } from '../../lexical/ui/icons/AlignRight'
import { createClientComponent } from '../createClientComponent'
import { AlignDropdownSectionWithEntries } from './floatingSelectToolbarAlignDropdownSection'
const AlignFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignLeftIcon,
isActive: () => false,
key: 'align-left',
label: `Align Left`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')
},
order: 1,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignCenterIcon,
isActive: () => false,
key: 'align-center',
label: `Align Center`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')
},
order: 2,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignRightIcon,
isActive: () => false,
key: 'align-right',
label: `Align Right`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')
},
order: 3,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignJustifyIcon,
isActive: () => false,
key: 'align-justify',
label: `Align Justify`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
},
order: 4,
},
]),
],
},
}),
}
}
export const AlignFeatureClientComponent = createClientComponent(AlignFeatureClient)

View File

@@ -0,0 +1,17 @@
import type { FeatureProviderProviderServer } from '../types'
import { AlignFeatureClientComponent } from './feature.client'
export const AlignFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
return {
feature: () => {
return {
ClientComponent: AlignFeatureClientComponent,
clientFeatureProps: null,
serverFeatureProps: props,
}
},
key: 'align',
serverFeatureProps: props,
}
}

View File

@@ -3,14 +3,14 @@ import type {
FloatingToolbarSectionEntry, FloatingToolbarSectionEntry,
} from '../../lexical/plugins/FloatingSelectToolbar/types' } from '../../lexical/plugins/FloatingSelectToolbar/types'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft'
export const AlignDropdownSectionWithEntries = ( export const AlignDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[], entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => { ): FloatingToolbarSection => {
return { return {
type: 'dropdown', type: 'dropdown',
ChildComponent: () => ChildComponent: AlignLeftIcon,
// @ts-expect-error-next-line
import('../../lexical/ui/icons/AlignLeft').then((module) => module.AlignLeftIcon),
entries, entries,
key: 'dropdown-align', key: 'dropdown-align',
order: 2, order: 2,

View File

@@ -1,82 +0,0 @@
import { FORMAT_ELEMENT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../types'
import { AlignDropdownSectionWithEntries } from './floatingSelectToolbarAlignDropdownSection'
export const AlignFeature = (): FeatureProvider => {
return {
feature: () => {
return {
floatingSelectToolbar: {
sections: [
AlignDropdownSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/AlignLeft').then((module) => module.AlignLeftIcon),
isActive: () => false,
key: 'align-left',
label: `Align Left`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')
},
order: 1,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/AlignCenter').then(
(module) => module.AlignCenterIcon,
),
isActive: () => false,
key: 'align-center',
label: `Align Center`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')
},
order: 2,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/AlignRight').then(
(module) => module.AlignRightIcon,
),
isActive: () => false,
key: 'align-right',
label: `Align Right`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')
},
order: 3,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error
import('../../lexical/ui/icons/AlignJustify').then(
(module) => module.AlignJustifyIcon,
),
isActive: () => false,
key: 'align-justify',
label: `Align Justify`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
},
order: 4,
},
]),
],
},
props: null,
}
},
key: 'align',
}
}

View File

@@ -0,0 +1,17 @@
'use client'
import type { FeatureProviderProviderClient, ServerFeature } from './types'
import { useLexicalFeature } from '../../useLexicalFeature'
/**
* Utility function to create a client component for the client feature
*/
export const createClientComponent = <ClientFeatureProps,>(
clientFeature: FeatureProviderProviderClient<ClientFeatureProps>,
): ServerFeature<unknown, ClientFeatureProps>['ClientComponent'] => {
return (props) => {
useLexicalFeature(props.featureKey, clientFeature(props))
return null
}
}

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../ui/src/scss/styles.scss';
.lexical-link-edit-drawer { .lexical-link-edit-drawer {
&__template { &__template {

View File

@@ -0,0 +1,72 @@
import {
Drawer,
FieldPathProvider,
Form,
type FormState,
FormSubmit,
RenderFields,
getFormState,
useConfig,
useDocumentInfo,
useTranslation,
} from '@payloadcms/ui'
import { useFieldPath } from '@payloadcms/ui'
import React, { useEffect, useState } from 'react'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import './index.scss'
import { type Props } from './types'
const baseClass = 'lexical-link-edit-drawer'
export const LinkDrawer: React.FC<Props> = ({ drawerSlug, handleModalSubmit, stateData }) => {
const { t } = useTranslation()
const { id, getDocPreferences } = useDocumentInfo()
const { schemaPath } = useFieldPath()
const config = useConfig()
const [initialState, setInitialState] = useState<FormState>({})
const {
field: { richTextComponentMap },
} = useEditorConfigContext()
const componentMapRenderedFieldsPath = `feature.link.fields.fields`
const schemaFieldsPath = `${schemaPath}.feature.link.fields`
const fieldMap = richTextComponentMap.get(componentMapRenderedFieldsPath) // Field Schema
useEffect(() => {
const awaitInitialState = async () => {
const docPreferences = await getDocPreferences()
const state = await getFormState({
apiRoute: config.routes.api,
body: {
id,
data: stateData,
docPreferences,
operation: 'update',
schemaPath: schemaFieldsPath,
},
serverURL: config.serverURL,
}) // Form State
setInitialState(state)
}
if (stateData) {
void awaitInitialState()
}
}, [config.routes.api, config.serverURL, schemaFieldsPath, getDocPreferences, id, stateData])
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink') ?? ''}>
<FieldPathProvider path="" schemaPath="">
<Form fields={fieldMap} initialState={initialState} onSubmit={handleModalSubmit}>
<RenderFields fieldMap={fieldMap} forceRender readOnly={false} />
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>
</FieldPathProvider>
</Drawer>
)
}

View File

@@ -1,10 +1,9 @@
import type { FormState } from '@payloadcms/ui' import type { FormState } from '@payloadcms/ui'
import { type Field } from 'payload/types' import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
export interface Props { export interface Props {
drawerSlug: string drawerSlug: string
fieldSchema: Field[]
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
initialState?: FormState stateData?: LinkPayload
} }

View File

@@ -0,0 +1,93 @@
'use client'
import { $findMatchingParent } from '@lexical/utils'
import { $getSelection, $isRangeSelection } from 'lexical'
import type { FeatureProviderProviderClient } from '../types'
import type { ExclusiveLinkCollectionsProps } from './feature.server'
import type { LinkFields } from './nodes/types'
import { LinkIcon } from '../../lexical/ui/icons/Link'
import { getSelectedNode } from '../../lexical/utils/getSelectedNode'
import { FeaturesSectionWithEntries } from '../common/floatingSelectToolbarFeaturesButtonsSection'
import { createClientComponent } from '../createClientComponent'
import { AutoLinkNode } from './nodes/AutoLinkNode'
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode'
import { AutoLinkPlugin } from './plugins/autoLink'
import { ClickableLinkPlugin } from './plugins/clickableLink'
import { FloatingLinkEditorPlugin } from './plugins/floatingLinkEditor'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './plugins/floatingLinkEditor/LinkEditor/commands'
import { LinkPlugin } from './plugins/link'
export type ClientProps = ExclusiveLinkCollectionsProps
const LinkFeatureClient: FeatureProviderProviderClient<ClientProps> = (props) => {
return {
clientFeatureProps: props,
feature: () => ({
clientFeatureProps: props,
floatingSelectToolbar: {
sections: [
FeaturesSectionWithEntries([
{
ChildComponent: LinkIcon,
isActive: ({ selection }) => {
if ($isRangeSelection(selection)) {
const selectedNode = getSelectedNode(selection)
const linkParent = $findMatchingParent(selectedNode, $isLinkNode)
return linkParent != null
}
return false
},
key: 'link',
label: `Link`,
onClick: ({ editor, isActive }) => {
if (!isActive) {
let selectedText = null
editor.getEditorState().read(() => {
selectedText = $getSelection().getTextContent()
})
const linkFields: LinkFields = {
doc: null,
linkType: 'custom',
newTab: false,
url: 'https://',
}
editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, {
fields: linkFields,
text: selectedText,
})
} else {
// remove link
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
},
order: 1,
},
]),
],
},
nodes: [LinkNode, AutoLinkNode],
plugins: [
{
Component: LinkPlugin,
position: 'normal',
},
{
Component: AutoLinkPlugin,
position: 'normal',
},
{
Component: ClickableLinkPlugin,
position: 'normal',
},
{
Component: FloatingLinkEditorPlugin,
position: 'floatingAnchorElem',
},
],
}),
}
}
export const LinkFeatureClientComponent = createClientComponent(LinkFeatureClient)

View File

@@ -0,0 +1,152 @@
import type { I18n } from '@payloadcms/translations'
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type React from 'react'
import { initI18n } from '@payloadcms/translations'
import { translations } from '@payloadcms/translations/client'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProviderProviderServer } from '../types'
import type { ClientProps } from './feature.client'
import type { SerializedAutoLinkNode, SerializedLinkNode } from './nodes/types'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
import { LinkFeatureClientComponent } from './feature.client'
import { AutoLinkNode } from './nodes/AutoLinkNode'
import { LinkNode } from './nodes/LinkNode'
import { transformExtraFields } from './plugins/floatingLinkEditor/utilities'
import { linkPopulationPromiseHOC } from './populationPromise'
export type ExclusiveLinkCollectionsProps =
| {
/**
* The collections that should be disabled for internal linking. Overrides the `enableRichTextLink` property in the collection config.
* When this property is set, `enabledCollections` will not be available.
**/
disabledCollections?: string[]
// Ensures that enabledCollections is not available when disabledCollections is set
enabledCollections?: never
}
| {
// Ensures that disabledCollections is not available when enabledCollections is set
disabledCollections?: never
/**
* The collections that should be enabled for internal linking. Overrides the `enableRichTextLink` property in the collection config
* When this property is set, `disabledCollections` will not be available.
**/
enabledCollections?: string[]
}
export type LinkFeatureServerProps = ExclusiveLinkCollectionsProps & {
/**
* A function or array defining additional fields for the link feature. These will be
* displayed in the link editor drawer.
*/
fields?:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: I18n }) => Field[])
| Field[]
//someFunction: (something: number) => string
someFunction?: React.FC
}
export const LinkFeature: FeatureProviderProviderServer<LinkFeatureServerProps, ClientProps> = (
props,
) => {
return {
feature: () => {
return {
ClientComponent: LinkFeatureClientComponent,
clientFeatureProps: {
disabledCollections: props.disabledCollections,
enabledCollections: props.enabledCollections,
} as ExclusiveLinkCollectionsProps,
generateComponentMap: () => {
return {
someFunction: props.someFunction,
}
},
generateSchemaMap: ({ config, props, schemaMap, schemaPath }) => {
const i18n = initI18n({ config: config.i18n, context: 'client', translations })
return {
fields: transformExtraFields(
props.fields,
config,
i18n,
props.enabledCollections,
props.disabledCollections,
),
}
},
nodes: [
{
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
const rel: string = node.fields.newTab ? ' rel="noopener noreferrer"' : ''
let href: string = node.fields.url
if (node.fields.linkType === 'internal') {
href =
typeof node.fields.doc?.value === 'string'
? node.fields.doc?.value
: node.fields.doc?.value?.id
}
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [AutoLinkNode.getType()],
} as HTMLConverter<SerializedAutoLinkNode>,
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
},
{
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
const rel: string = node.fields.newTab ? ' rel="noopener noreferrer"' : ''
const href: string =
node.fields.linkType === 'custom'
? node.fields.url
: (node.fields.doc?.value as string)
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [LinkNode.getType()],
} as HTMLConverter<SerializedLinkNode>,
},
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
},
],
serverFeatureProps: props,
}
},
key: 'link',
serverFeatureProps: props,
}
}

View File

@@ -6,9 +6,9 @@ import {
type RangeSelection, type RangeSelection,
} from 'lexical' } from 'lexical'
import { type LinkFields, LinkNode, type SerializedLinkNode } from './LinkNode' import type { LinkFields, SerializedAutoLinkNode } from './types'
export type SerializedAutoLinkNode = SerializedLinkNode import { LinkNode } from './LinkNode'
// Custom node type to override `canInsertTextAfter` that will // Custom node type to override `canInsertTextAfter` that will
// allow typing within the link // allow typing within the link

View File

@@ -15,39 +15,11 @@ import {
type LexicalNode, type LexicalNode,
type NodeKey, type NodeKey,
type RangeSelection, type RangeSelection,
type SerializedElementNode,
type Spread,
createCommand, createCommand,
} from 'lexical' } from 'lexical'
import type { LinkPayload } from '../plugins/floatingLinkEditor/types' import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
import type { LinkFields, SerializedLinkNode } from './types'
import { $isAutoLinkNode } from './AutoLinkNode'
export type LinkFields = {
// unknown, custom fields:
[key: string]: unknown
doc: {
relationTo: string
value:
| {
// Actual doc data, populated in afterRead hook
[key: string]: unknown
id: string
}
| string
} | null
linkType: 'custom' | 'internal'
newTab: boolean
url: string
}
export type SerializedLinkNode = Spread<
{
fields: LinkFields
},
SerializedElementNode
>
const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:']) const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:'])

View File

@@ -0,0 +1,27 @@
import type { SerializedElementNode, Spread } from 'lexical'
export type LinkFields = {
// unknown, custom fields:
[key: string]: unknown
doc: {
relationTo: string
value:
| {
// Actual doc data, populated in afterRead hook
[key: string]: unknown
id: string
}
| string
} | null
linkType: 'custom' | 'internal'
newTab: boolean
url: string
}
export type SerializedLinkNode = Spread<
{
fields: LinkFields
},
SerializedElementNode
>
export type SerializedAutoLinkNode = SerializedLinkNode

View File

@@ -15,9 +15,11 @@ import {
} from 'lexical' } from 'lexical'
import { useEffect } from 'react' import { useEffect } from 'react'
import type { LinkFields } from '../../nodes/types'
import { invariant } from '../../../../lexical/utils/invariant' import { invariant } from '../../../../lexical/utils/invariant'
import { $createAutoLinkNode, $isAutoLinkNode, AutoLinkNode } from '../../nodes/AutoLinkNode' import { $createAutoLinkNode, $isAutoLinkNode, AutoLinkNode } from '../../nodes/AutoLinkNode'
import { $isLinkNode, type LinkFields } from '../../nodes/LinkNode' import { $isLinkNode } from '../../nodes/LinkNode'
type ChangeHandler = (url: null | string, prevUrl: null | string) => void type ChangeHandler = (url: null | string, prevUrl: null | string) => void

View File

@@ -6,16 +6,7 @@ import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils' import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { import { formatDrawerSlug, useConfig, useEditDepth, useTranslation } from '@payloadcms/ui'
buildStateFromSchema,
formatDrawerSlug,
useAuth,
useConfig,
useDocumentInfo,
useEditDepth,
useLocale,
useTranslation,
} from '@payloadcms/ui'
import { import {
$getSelection, $getSelection,
$isRangeSelection, $isRangeSelection,
@@ -24,29 +15,21 @@ import {
KEY_ESCAPE_COMMAND, KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
} from 'lexical' } from 'lexical'
import { sanitizeFields } from 'payload/config'
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import type { LinkFeatureProps } from '../../..'
import type { LinkNode } from '../../../nodes/LinkNode' import type { LinkNode } from '../../../nodes/LinkNode'
import type { LinkPayload } from '../types' import type { LinkPayload } from '../types'
import { useEditorConfigContext } from '../../../../../lexical/config/EditorConfigProvider' import { useEditorConfigContext } from '../../../../../lexical/config/client/EditorConfigProvider'
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode' import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor' import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor'
import { LinkDrawer } from '../../../drawer' import { LinkDrawer } from '../../../drawer'
import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode' import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode'
import { $createLinkNode } from '../../../nodes/LinkNode' import { $createLinkNode } from '../../../nodes/LinkNode'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode' import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode'
import { transformExtraFields } from '../utilities'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands' import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands'
export function LinkEditor({ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.ReactNode {
anchorElem,
disabledCollections,
enabledCollections,
fields: customFieldSchema,
}: { anchorElem: HTMLElement } & LinkFeatureProps): JSX.Element {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const editorRef = useRef<HTMLDivElement | null>(null) const editorRef = useRef<HTMLDivElement | null>(null)
@@ -57,36 +40,9 @@ export function LinkEditor({
const config = useConfig() const config = useConfig()
const { user } = useAuth()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation()
const { getDocPreferences } = useDocumentInfo() const [stateData, setStateData] = useState<LinkPayload>(null)
const [initialState, setInitialState] = useState<FormState>({})
const [fieldSchema] = useState(() => {
const fieldsUnsanitized = transformExtraFields(
customFieldSchema,
// TODO: fix this
// @ts-expect-error-next-line
config,
i18n,
enabledCollections,
disabledCollections,
)
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fields = sanitizeFields({
// TODO: fix this
// @ts-expect-error-next-line
config,
fields: fieldsUnsanitized,
validRelationships,
})
return fields
})
const { closeModal, toggleModal } = useModal() const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth() const editDepth = useEditDepth()
@@ -98,7 +54,7 @@ export function LinkEditor({
depth: editDepth, depth: editDepth,
}) })
const updateLinkEditor = useCallback(async () => { const updateLinkEditor = useCallback(() => {
const selection = $getSelection() const selection = $getSelection()
let selectedNodeDomRect: DOMRect | undefined = null let selectedNodeDomRect: DOMRect | undefined = null
@@ -147,22 +103,7 @@ export function LinkEditor({
setLinkLabel(label) setLinkLabel(label)
} }
// Set initial state of the drawer. This will basically pre-fill the drawer fields with the setStateData(data)
// values saved in the link node you clicked on.
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
// TODO: fix this
// @ts-expect-error-next-line
config,
data,
fieldSchema,
locale,
operation: 'create',
preferences,
t,
user: user ?? undefined,
})
setInitialState(state)
setIsLink(true) setIsLink(true)
if ($isAutoLinkNode(linkParent)) { if ($isAutoLinkNode(linkParent)) {
setIsAutoLink(true) setIsAutoLink(true)
@@ -206,7 +147,7 @@ export function LinkEditor({
} }
return true return true
}, [anchorElem, editor, fieldSchema, config, getDocPreferences, locale, t, user, i18n]) }, [anchorElem, editor, config, t, i18n])
useEffect(() => { useEffect(() => {
return mergeRegister( return mergeRegister(
@@ -217,12 +158,8 @@ export function LinkEditor({
// Now, open the modal // Now, open the modal
updateLinkEditor() updateLinkEditor()
.then(() => { toggleModal(drawerSlug)
toggleModal(drawerSlug)
})
.catch((error) => {
throw error
})
return true return true
}, },
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
@@ -339,7 +276,6 @@ export function LinkEditor({
</div> </div>
<LinkDrawer <LinkDrawer
drawerSlug={drawerSlug} drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
handleModalSubmit={(fields: FormState, data: Data) => { handleModalSubmit={(fields: FormState, data: Data) => {
closeModal(drawerSlug) closeModal(drawerSlug)
@@ -363,7 +299,7 @@ export function LinkEditor({
// it being applied to the auto link node instead of the link node. // it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload) editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
}} }}
initialState={initialState} stateData={stateData}
/> />
</React.Fragment> </React.Fragment>
) )

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../../ui/src/scss/styles.scss';
html[data-theme='light'] { html[data-theme='light'] {
.link-editor { .link-editor {

View File

@@ -0,0 +1,14 @@
'use client'
import * as React from 'react'
import { createPortal } from 'react-dom'
import { LinkEditor } from './LinkEditor'
import './index.scss'
export const FloatingLinkEditorPlugin: React.FC<{
anchorElem: HTMLElement
}> = (props) => {
const { anchorElem = document.body } = props
return createPortal(<LinkEditor anchorElem={anchorElem} />, anchorElem)
}

View File

@@ -1,4 +1,4 @@
import type { LinkFields } from '../../nodes/LinkNode' import type { LinkFields } from '../../nodes/types'
/** /**
* The payload of a link node * The payload of a link node

View File

@@ -10,10 +10,11 @@ import {
} from 'lexical' } from 'lexical'
import { useEffect } from 'react' import { useEffect } from 'react'
import type { LinkFields } from '../../nodes/types'
import type { LinkPayload } from '../floatingLinkEditor/types' import type { LinkPayload } from '../floatingLinkEditor/types'
import { validateUrl } from '../../../../lexical/utils/url' import { validateUrl } from '../../../../lexical/utils/url'
import { type LinkFields, LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode' import { LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode'
export function LinkPlugin(): null { export function LinkPlugin(): null {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()

View File

@@ -1,12 +1,12 @@
import type { LinkFeatureProps } from '.'
import type { PopulationPromise } from '../types' import type { PopulationPromise } from '../types'
import type { SerializedLinkNode } from './nodes/LinkNode' import type { LinkFeatureServerProps } from './feature.server'
import type { SerializedLinkNode } from './nodes/types'
import { populate } from '../../../populate/populate' import { populate } from '../../../populate/populate'
import { recurseNestedFields } from '../../../populate/recurseNestedFields' import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const linkPopulationPromiseHOC = ( export const linkPopulationPromiseHOC = (
props: LinkFeatureProps, props: LinkFeatureServerProps,
): PopulationPromise<SerializedLinkNode> => { ): PopulationPromise<SerializedLinkNode> => {
const linkPopulationPromise: PopulationPromise<SerializedLinkNode> = ({ const linkPopulationPromise: PopulationPromise<SerializedLinkNode> = ({
context, context,

View File

@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import type { SerializedLinkNode } from '../../../../Link/nodes/LinkNode' import type { SerializedLinkNode } from '../../../../link/nodes/LinkNode'
import type { LexicalPluginNodeConverter } from '../types' import type { LexicalPluginNodeConverter } from '../types'
import { convertLexicalPluginNodesToLexical } from '..' import { convertLexicalPluginNodesToLexical } from '..'

View File

@@ -1,4 +1,4 @@
import type { SerializedLinkNode } from '../../../../Link/nodes/LinkNode' import type { SerializedLinkNode } from '../../../../link/nodes/LinkNode'
import type { SlateNodeConverter } from '../types' import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..' import { convertSlateNodesToLexical } from '..'

View File

@@ -5,11 +5,11 @@ import type { SerializedLexicalNode } from 'lexical'
import type { LexicalNodeReplacement } from 'lexical' import type { LexicalNodeReplacement } from 'lexical'
import type { RequestContext } from 'payload' import type { RequestContext } from 'payload'
import type { SanitizedConfig } from 'payload/config' import type { SanitizedConfig } from 'payload/config'
import type { PayloadRequest, RichTextField, ValidateOptions } from 'payload/types' import type { Field, PayloadRequest, RichTextField, ValidateOptions } from 'payload/types'
import type React from 'react' import type React from 'react'
import type { AdapterProps } from '../../types' import type { AdapterProps } from '../../types'
import type { EditorConfig } from '../lexical/config/types' import type { ClientEditorConfig, ServerEditorConfig } from '../lexical/config/types'
import type { FloatingToolbarSection } from '../lexical/plugins/FloatingSelectToolbar/types' import type { FloatingToolbarSection } from '../lexical/plugins/FloatingSelectToolbar/types'
import type { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types' import type { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types'
import type { HTMLConverter } from './converters/html/converter/types' import type { HTMLConverter } from './converters/html/converter/types'
@@ -62,10 +62,136 @@ export type NodeValidation<T extends SerializedLexicalNode = SerializedLexicalNo
} }
}) => Promise<string | true> | string | true }) => Promise<string | true> | string | true
export type Feature = { export type FeatureProviderProviderServer<ServerFeatureProps, ClientFeatureProps> = (
props?: ServerFeatureProps,
) => FeatureProviderServer<ServerFeatureProps, ClientFeatureProps>
export type FeatureProviderServer<ServerFeatureProps, ClientFeatureProps> = {
/** Keys of dependencies needed for this feature. These dependencies do not have to be loaded first */
dependencies?: string[]
/** Keys of priority dependencies needed for this feature. These dependencies have to be loaded first and are available in the `feature` property*/
dependenciesPriority?: string[]
/** Keys of soft-dependencies needed for this feature. The FeatureProviders dependencies are optional, but are considered as last-priority in the loading process */
dependenciesSoft?: string[]
feature: (props: {
/** unSanitizedEditorConfig.features, but mapped */
featureProviderMap: ServerFeatureProviderMap
// other resolved features, which have been loaded before this one. All features declared in 'dependencies' should be available here
resolvedFeatures: ResolvedServerFeatureMap
// unSanitized EditorConfig,
unSanitizedEditorConfig: ServerEditorConfig
}) => ServerFeature<ServerFeatureProps, ClientFeatureProps>
key: string
/** Props which were passed into your feature will have to be passed here. This will allow them to be used / read in other places of the code, e.g. wherever you can use useEditorConfigContext */
serverFeatureProps: ServerFeatureProps
}
export type FeatureProviderProviderClient<ClientFeatureProps> = (
props?: ClientComponentProps<ClientFeatureProps>,
) => FeatureProviderClient<ClientFeatureProps>
/**
* No dependencies => Features need to be sorted on the server first, then sent to client in right order
*/
export type FeatureProviderClient<ClientFeatureProps> = {
/**
* Return props, to make it easy to retrieve passed in props to this Feature for the client if anyone wants to
*/
clientFeatureProps: ClientComponentProps<ClientFeatureProps>
feature: (props: {
/** unSanitizedEditorConfig.features, but mapped */
featureProviderMap: ClientFeatureProviderMap
// other resolved features, which have been loaded before this one. All features declared in 'dependencies' should be available here
resolvedFeatures: ResolvedClientFeatureMap
// unSanitized EditorConfig,
unSanitizedEditorConfig: ClientEditorConfig
}) => ClientFeature<ClientFeatureProps>
}
export type ClientFeature<ClientFeatureProps> = {
/**
* Return props, to make it easy to retrieve passed in props to this Feature for the client if anyone wants to
*/
clientFeatureProps: ClientComponentProps<ClientFeatureProps>
floatingSelectToolbar?: { floatingSelectToolbar?: {
sections: FloatingToolbarSection[] sections: FloatingToolbarSection[]
} }
hooks?: {
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
}
markdownTransformers?: Transformer[]
nodes?: Array<Klass<LexicalNode> | LexicalNodeReplacement>
plugins?: Array<
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: React.FC
position: 'bottom' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: React.FC
position: 'normal' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: React.FC
position: 'top' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: React.FC<{ anchorElem: HTMLElement }>
position: 'floatingAnchorElem' // Determines at which position the Component will be added.
}
>
slashMenu?: {
dynamicOptions?: ({
editor,
queryString,
}: {
editor: LexicalEditor
queryString: string
}) => SlashMenuGroup[]
options?: SlashMenuGroup[]
}
}
export type ClientComponentProps<ClientFeatureProps> = ClientFeatureProps & {
featureKey: string
order: number
}
export type ServerFeature<ServerProps, ClientFeatureProps> = {
ClientComponent: React.FC<ClientComponentProps<ClientFeatureProps>>
/**
* This determines what props will be available on the Client.
*/
clientFeatureProps: ClientFeatureProps
generateComponentMap?: (args: {
config: SanitizedConfig
props: ServerProps
schemaPath: string
}) => {
[key: string]: React.FC
}
generateSchemaMap?: (args: {
config: SanitizedConfig
props: ServerProps
schemaMap: Map<string, Field[]>
schemaPath: string
}) => {
[key: string]: Field[]
}
generatedTypes?: { generatedTypes?: {
modifyOutputSchema: ({ modifyOutputSchema: ({
currentSchema, currentSchema,
@@ -95,16 +221,6 @@ export type Feature = {
incomingEditorState: SerializedEditorState incomingEditorState: SerializedEditorState
siblingDoc: Record<string, unknown> siblingDoc: Record<string, unknown>
}) => Promise<void> | null }) => Promise<void> | null
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
} }
markdownTransformers?: Transformer[] markdownTransformers?: Transformer[]
nodes?: Array<{ nodes?: Array<{
@@ -113,107 +229,66 @@ export type Feature = {
} }
node: Klass<LexicalNode> | LexicalNodeReplacement node: Klass<LexicalNode> | LexicalNodeReplacement
populationPromises?: Array<PopulationPromise> populationPromises?: Array<PopulationPromise>
type: string
validations?: Array<NodeValidation> validations?: Array<NodeValidation>
}> }>
plugins?: Array<
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC<{ anchorElem: HTMLElement }>>
position: 'floatingAnchorElem' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC>
position: 'bottom' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC>
position: 'normal' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC>
position: 'top' // Determines at which position the Component will be added.
}
>
/** Props which were passed into your feature will have to be passed here. This will allow them to be used / read in other places of the code, e.g. wherever you can use useEditorConfigContext */ /** Props which were passed into your feature will have to be passed here. This will allow them to be used / read in other places of the code, e.g. wherever you can use useEditorConfigContext */
props: unknown serverFeatureProps: ServerProps
slashMenu?: {
dynamicOptions?: ({
editor,
queryString,
}: {
editor: LexicalEditor
queryString: string
}) => SlashMenuGroup[]
options?: SlashMenuGroup[]
}
} }
export type FeatureProvider = { export type ResolvedServerFeature<ServerProps, ClientFeatureProps> = ServerFeature<
Component: React.FC ServerProps,
/** Keys of dependencies needed for this feature. These dependencies do not have to be loaded first */ ClientFeatureProps
dependencies?: string[] > &
/** Keys of priority dependencies needed for this feature. These dependencies have to be loaded first and are available in the `feature` property*/
dependenciesPriority?: string[]
/** Keys of soft-dependencies needed for this feature. These dependencies are optional, but are considered as last-priority in the loading process */
dependenciesSoft?: string[]
feature: (props: {
/** unSanitizedEditorConfig.features, but mapped */
featureProviderMap: FeatureProviderMap
// other resolved features, which have been loaded before this one. All features declared in 'dependencies' should be available here
resolvedFeatures: ResolvedFeatureMap
// unSanitized EditorConfig,
unSanitizedEditorConfig: EditorConfig
}) => Feature
key: string
}
export type ResolvedFeature = Feature &
Required< Required<
Pick< Pick<
FeatureProvider, FeatureProviderServer<ServerProps, ClientFeatureProps>,
'Component' | 'dependencies' | 'dependenciesPriority' | 'dependenciesSoft' | 'key' 'dependencies' | 'dependenciesPriority' | 'dependenciesSoft' | 'key'
> >
> > & {
order: number
}
export type ResolvedFeatureMap = Map<string, ResolvedFeature> export type ResolvedClientFeature<ClientFeatureProps> = ClientFeature<ClientFeatureProps> & {
key: string
order: number
}
export type FeatureProviderMap = Map<string, FeatureProvider> export type ResolvedServerFeatureMap = Map<string, ResolvedServerFeature<unknown, unknown>>
export type ResolvedClientFeatureMap = Map<string, ResolvedClientFeature<unknown>>
export type ServerFeatureProviderMap = Map<string, FeatureProviderServer<unknown, unknown>>
export type ClientFeatureProviderMap = Map<string, FeatureProviderClient<unknown>>
export type SanitizedPlugin = export type SanitizedPlugin =
| { | {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality // plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC<{ anchorElem: HTMLElement }>> Component: React.FC
desktopOnly?: boolean
key: string
position: 'floatingAnchorElem' // Determines at which position the Component will be added.
}
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC>
key: string key: string
position: 'bottom' // Determines at which position the Component will be added. position: 'bottom' // Determines at which position the Component will be added.
} }
| { | {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality // plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC> Component: React.FC
key: string key: string
position: 'normal' // Determines at which position the Component will be added. position: 'normal' // Determines at which position the Component will be added.
} }
| { | {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality // plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: () => Promise<React.FC> Component: React.FC
key: string key: string
position: 'top' // Determines at which position the Component will be added. position: 'top' // Determines at which position the Component will be added.
} }
| {
// plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality
Component: React.FC<{ anchorElem: HTMLElement }>
desktopOnly?: boolean
key: string
position: 'floatingAnchorElem' // Determines at which position the Component will be added.
}
export type SanitizedFeatures = Required< export type SanitizedServerFeatures = Required<
Pick<ResolvedFeature, 'markdownTransformers' | 'nodes'> Pick<ResolvedServerFeature<unknown, unknown>, 'markdownTransformers' | 'nodes'>
> & { > & {
/** The node types mapped to their converters */ /** The node types mapped to their converters */
converters: { converters: {
@@ -221,9 +296,6 @@ export type SanitizedFeatures = Required<
} }
/** The keys of all enabled features */ /** The keys of all enabled features */
enabledFeatures: string[] enabledFeatures: string[]
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
generatedTypes: { generatedTypes: {
modifyOutputSchemas: Array< modifyOutputSchemas: Array<
({ ({
@@ -257,6 +329,22 @@ export type SanitizedFeatures = Required<
siblingDoc: Record<string, unknown> siblingDoc: Record<string, unknown>
}) => Promise<void> | null }) => Promise<void> | null
> >
}
/** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
/** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>>
}
export type SanitizedClientFeatures = Required<
Pick<ResolvedClientFeature<unknown>, 'markdownTransformers' | 'nodes'>
> & {
/** The keys of all enabled features */
enabledFeatures: string[]
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
hooks: {
load: Array< load: Array<
({ ({
incomingEditorState, incomingEditorState,
@@ -273,14 +361,10 @@ export type SanitizedFeatures = Required<
> >
} }
plugins?: Array<SanitizedPlugin> plugins?: Array<SanitizedPlugin>
/** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
slashMenu: { slashMenu: {
dynamicOptions: Array< dynamicOptions: Array<
({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => SlashMenuGroup[] ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => SlashMenuGroup[]
> >
groupsWithOptions: SlashMenuGroup[] groupsWithOptions: SlashMenuGroup[]
} }
/** The node types mapped to their validations */
validations: Map<string, Array<NodeValidation>>
} }

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../ui/src/scss/styles.scss';
.rich-text-lexical { .rich-text-lexical {
display: flex; display: flex;

View File

@@ -1,16 +1,18 @@
'use client' 'use client'
import type { FeatureProvider } from '@payloadcms/richtext-lexical'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor' import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { type FormFieldBase, ShimmerEffect } from '@payloadcms/ui' import { type FormFieldBase, ShimmerEffect } from '@payloadcms/ui'
import React, { Suspense, lazy, useEffect, useState } from 'react' import React, { Suspense, lazy, useEffect, useState } from 'react'
import type { SanitizedEditorConfig } from './lexical/config/types' import type { GeneratedFeatureProviderComponent } from '../types'
import type { FeatureProviderClient } from './features/types'
import type { SanitizedClientEditorConfig } from './lexical/config/types'
import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider' import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider'
import { useClientFunctions } from '../../../ui/src/providers/ClientFunction' import { useClientFunctions } from '../../../ui/src/providers/ClientFunction'
import { defaultEditorLexicalConfig } from './lexical/config/defaultClient' import { defaultEditorLexicalConfig } from './lexical/config/client/default'
import { sanitizeEditorConfig } from './lexical/config/sanitize' import { loadClientFeatures } from './lexical/config/client/loader'
import { sanitizeClientEditorConfig } from './lexical/config/client/sanitize'
// @ts-expect-error-next-line Just TypeScript being broken // TODO: Open TypeScript issue // @ts-expect-error-next-line Just TypeScript being broken // TODO: Open TypeScript issue
const RichTextEditor = lazy(() => import('./Field')) const RichTextEditor = lazy(() => import('./Field'))
@@ -27,20 +29,19 @@ export const RichTextField: React.FC<
const clientFunctions = useClientFunctions() const clientFunctions = useClientFunctions()
const [hasLoadedFeatures, setHasLoadedFeatures] = useState(false) const [hasLoadedFeatures, setHasLoadedFeatures] = useState(false)
const [featureProviders, setFeatureProviders] = useState<FeatureProvider[]>([]) const [featureProviders, setFeatureProviders] = useState<FeatureProviderClient<unknown>[]>([])
const finalSanitizedEditorConfig: SanitizedEditorConfig = sanitizeEditorConfig({ const [finalSanitizedEditorConfig, setFinalSanitizedEditorConfig] =
features: [], useState<SanitizedClientEditorConfig>(null)
lexical: lexicalEditorConfig
? () => Promise.resolve(lexicalEditorConfig)
: () => Promise.resolve(defaultEditorLexicalConfig),
})
const featureProviderComponents = Array.from(richTextComponentMap.values()) let featureProviderComponents: GeneratedFeatureProviderComponent[] =
richTextComponentMap.get('features')
// order by order
featureProviderComponents = featureProviderComponents.sort((a, b) => a.order - b.order)
useEffect(() => { useEffect(() => {
if (!hasLoadedFeatures) { if (!hasLoadedFeatures) {
const featureProvidersLocal: FeatureProvider[] = [] const featureProvidersLocal: FeatureProviderClient<unknown>[] = []
Object.entries(clientFunctions).forEach(([key, plugin]) => { Object.entries(clientFunctions).forEach(([key, plugin]) => {
if (key.startsWith(`lexicalFeature.${schemaPath}.`)) { if (key.startsWith(`lexicalFeature.${schemaPath}.`)) {
@@ -51,30 +52,56 @@ export const RichTextField: React.FC<
if (featureProvidersLocal.length === featureProviderComponents.length) { if (featureProvidersLocal.length === featureProviderComponents.length) {
setFeatureProviders(featureProvidersLocal) setFeatureProviders(featureProvidersLocal)
setHasLoadedFeatures(true) setHasLoadedFeatures(true)
/**
* Loaded feature provided => create the final sanitized editor config
*/
const resolvedClientFeatures = loadClientFeatures({
unSanitizedEditorConfig: {
features: featureProvidersLocal,
lexical: lexicalEditorConfig,
},
})
setFinalSanitizedEditorConfig(
sanitizeClientEditorConfig(
lexicalEditorConfig ? lexicalEditorConfig : defaultEditorLexicalConfig,
resolvedClientFeatures,
),
)
} }
} }
}, [hasLoadedFeatures, clientFunctions, schemaPath, featureProviderComponents.length]) }, [
hasLoadedFeatures,
clientFunctions,
schemaPath,
featureProviderComponents.length,
featureProviders,
finalSanitizedEditorConfig,
lexicalEditorConfig,
])
if (!hasLoadedFeatures) { if (!hasLoadedFeatures) {
return ( return (
<React.Fragment> <React.Fragment>
{Array.isArray(featureProviderComponents) && {Array.isArray(featureProviderComponents) &&
featureProviderComponents.map((FeatureProvider, i) => { featureProviderComponents.map((FeatureProvider) => {
return <React.Fragment key={i}>{FeatureProvider}</React.Fragment> return (
<React.Fragment key={FeatureProvider.key}>
{FeatureProvider.ClientComponent}
</React.Fragment>
)
})} })}
</React.Fragment> </React.Fragment>
) )
} }
const features = clientFunctions
console.log('clientFunctions', features['lexicalFeature.posts.richText.paragraph'])
//features['lexicalFeature.posts.richText.paragraph']()
return ( return (
<Suspense fallback={<ShimmerEffect height="35vh" />}> <Suspense fallback={<ShimmerEffect height="35vh" />}>
<RichTextEditor {...props} editorConfig={finalSanitizedEditorConfig} /> {finalSanitizedEditorConfig && (
<RichTextEditor {...props} editorConfig={finalSanitizedEditorConfig} />
)}
</Suspense> </Suspense>
) )
} }

View File

@@ -1,5 +1,4 @@
import * as React from 'react' import * as React from 'react'
import { useMemo } from 'react'
import type { SanitizedPlugin } from '../features/types' import type { SanitizedPlugin } from '../features/types'
@@ -7,31 +6,9 @@ export const EditorPlugin: React.FC<{
anchorElem?: HTMLDivElement anchorElem?: HTMLDivElement
plugin: SanitizedPlugin plugin: SanitizedPlugin
}> = ({ anchorElem, plugin }) => { }> = ({ anchorElem, plugin }) => {
const Component: React.FC<any> = useMemo(() => {
return plugin?.Component
? React.lazy(() =>
plugin.Component().then((resolvedComponent) => ({
default: resolvedComponent,
})),
)
: null
}, [plugin]) // Dependency array ensures this is only recomputed if entry changes
if (plugin.position === 'floatingAnchorElem') { if (plugin.position === 'floatingAnchorElem') {
return ( return plugin.Component && <plugin.Component anchorElem={anchorElem} />
Component && (
<React.Suspense>
<Component anchorElem={anchorElem} />
</React.Suspense>
)
)
} }
return ( return plugin.Component && <plugin.Component />
Component && (
<React.Suspense>
<Component />
</React.Suspense>
)
)
} }

View File

@@ -111,7 +111,7 @@ export const LexicalEditor: React.FC<Pick<LexicalProviderProps, 'editorConfig' |
{editor.isEditable() && ( {editor.isEditable() && (
<React.Fragment> <React.Fragment>
<HistoryPlugin /> <HistoryPlugin />
<MarkdownShortcutPlugin /> {editorConfig?.features?.markdownTransformers?.length > 0 && <MarkdownShortcutPlugin />}
</React.Fragment> </React.Fragment>
)} )}

View File

@@ -1,21 +1,25 @@
'use client' 'use client'
import type { InitialConfigType } from '@lexical/react/LexicalComposer' import type { InitialConfigType } from '@lexical/react/LexicalComposer'
import type { FormFieldBase } from '@payloadcms/ui'
import type { EditorState, SerializedEditorState } from 'lexical' import type { EditorState, SerializedEditorState } from 'lexical'
import type { LexicalEditor } from 'lexical' import type { LexicalEditor } from 'lexical'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { LexicalComposer } from '@lexical/react/LexicalComposer' import { LexicalComposer } from '@lexical/react/LexicalComposer'
import * as React from 'react' import * as React from 'react'
import type { FieldProps } from '../../types' import type { SanitizedClientEditorConfig } from './config/types'
import type { SanitizedEditorConfig } from './config/types'
import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor' import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor'
import { EditorConfigProvider } from './config/EditorConfigProvider' import { EditorConfigProvider } from './config/client/EditorConfigProvider'
import { getEnabledNodes } from './nodes' import { getEnabledNodes } from './nodes'
export type LexicalProviderProps = { export type LexicalProviderProps = {
editorConfig: SanitizedEditorConfig editorConfig: SanitizedClientEditorConfig
fieldProps: FieldProps fieldProps: FormFieldBase & {
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void onChange: (editorState: EditorState, editor: LexicalEditor, tags: Set<string>) => void
path: string path: string
readOnly: boolean readOnly: boolean
@@ -27,21 +31,19 @@ export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
const [initialConfig, setInitialConfig] = React.useState<InitialConfigType | null>(null) const [initialConfig, setInitialConfig] = React.useState<InitialConfigType | null>(null)
// set lexical config in useffect async: // set lexical config in useEffect: // TODO: Is this the most performant way to do this? Prob not
React.useEffect(() => { React.useEffect(() => {
void editorConfig.lexical().then((lexicalConfig: LexicalEditorConfig) => { const newInitialConfig: InitialConfigType = {
const newInitialConfig: InitialConfigType = { editable: readOnly !== true,
editable: readOnly !== true, editorState: value != null ? JSON.stringify(value) : undefined,
editorState: value != null ? JSON.stringify(value) : undefined, namespace: editorConfig.lexical.namespace,
namespace: lexicalConfig.namespace, nodes: [...getEnabledNodes({ editorConfig })],
nodes: [...getEnabledNodes({ editorConfig })], onError: (error: Error) => {
onError: (error: Error) => { throw error
throw error },
}, theme: editorConfig.lexical.theme,
theme: lexicalConfig.theme, }
} setInitialConfig(newInitialConfig)
setInitialConfig(newInitialConfig)
})
}, [editorConfig, readOnly, value]) }, [editorConfig, readOnly, value])
if (editorConfig?.features?.hooks?.load?.length) { if (editorConfig?.features?.hooks?.load?.length) {

View File

@@ -1,17 +1,23 @@
'use client' 'use client'
import type { FormFieldBase } from '@payloadcms/ui'
import * as React from 'react' import * as React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react' import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import type { FieldProps } from '../../../types' import type { FieldProps } from '../../../../types'
import type { SanitizedEditorConfig } from './types' import type { SanitizedClientEditorConfig } from '../types'
// Should always produce a 20 character pseudo-random string // Should always produce a 20 character pseudo-random string
function generateQuickGuid(): string { function generateQuickGuid(): string {
return Math.random().toString(36).substring(2, 12) + Math.random().toString(36).substring(2, 12) return Math.random().toString(36).substring(2, 12) + Math.random().toString(36).substring(2, 12)
} }
interface ContextType { interface ContextType {
editorConfig: SanitizedEditorConfig editorConfig: SanitizedClientEditorConfig
field: FieldProps field: FormFieldBase & {
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
uuid: string uuid: string
} }
@@ -27,9 +33,13 @@ export const EditorConfigProvider = ({
fieldProps, fieldProps,
}: { }: {
children: React.ReactNode children: React.ReactNode
editorConfig: SanitizedEditorConfig editorConfig: SanitizedClientEditorConfig
fieldProps: FieldProps fieldProps: FormFieldBase & {
}): JSX.Element => { editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
}): React.ReactNode => {
// State to store the UUID // State to store the UUID
const [uuid, setUuid] = useState(generateQuickGuid()) const [uuid, setUuid] = useState(generateQuickGuid())

View File

@@ -1,6 +1,7 @@
'use client'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor' import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { LexicalEditorTheme } from '../theme/EditorTheme' import { LexicalEditorTheme } from '../../theme/EditorTheme'
export const defaultEditorLexicalConfig: LexicalEditorConfig = { export const defaultEditorLexicalConfig: LexicalEditorConfig = {
namespace: 'lexical', namespace: 'lexical',

View File

@@ -0,0 +1,62 @@
'use client'
import type {
ClientEditorConfig,
ClientFeatureProviderMap,
FeatureProviderClient,
ResolvedClientFeatureMap,
} from '@payloadcms/richtext-lexical'
/**
* This function expects client functions to ALREADY be ordered & dependencies checked on the server
* @param unSanitizedEditorConfig
*/
export function loadClientFeatures({
unSanitizedEditorConfig,
}: {
unSanitizedEditorConfig: ClientEditorConfig
}): ResolvedClientFeatureMap {
for (const featureProvider of unSanitizedEditorConfig.features) {
if (
!featureProvider?.clientFeatureProps?.featureKey ||
featureProvider?.clientFeatureProps?.order === undefined ||
featureProvider?.clientFeatureProps?.order === null
) {
throw new Error(
'A Feature you have installed does not return the client props as clientFeatureProps. Please make sure to always return those props, even if they are null, as other important props like order and featureKey are later on injected.',
)
}
}
// sort unSanitizedEditorConfig.features by order
unSanitizedEditorConfig.features = unSanitizedEditorConfig.features.sort(
(a, b) => a.clientFeatureProps.order - b.clientFeatureProps.order,
)
const featureProviderMap: ClientFeatureProviderMap = new Map(
unSanitizedEditorConfig.features.map(
(f) => [f.clientFeatureProps.featureKey, f] as [string, FeatureProviderClient<unknown>],
),
)
const resolvedFeatures: ResolvedClientFeatureMap = new Map()
// Make sure all dependencies declared in the respective features exist
let loaded = 0
for (const featureProvider of unSanitizedEditorConfig.features) {
const feature = featureProvider.feature({
featureProviderMap,
resolvedFeatures,
unSanitizedEditorConfig,
})
resolvedFeatures.set(featureProvider.clientFeatureProps.featureKey, {
...feature,
key: featureProvider.clientFeatureProps.featureKey,
order: loaded,
})
loaded++
}
return resolvedFeatures
}

View File

@@ -1,46 +1,37 @@
import type { ResolvedFeatureMap, SanitizedFeatures } from '../../features/types' 'use client'
import type { EditorConfig, SanitizedEditorConfig } from './types'
import { loadFeatures } from './loader' import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeatures => { import type { ResolvedClientFeatureMap, SanitizedClientFeatures } from '../../../features/types'
const sanitized: SanitizedFeatures = { import type { SanitizedClientEditorConfig } from '../types'
converters: {
html: [], export const sanitizeClientFeatures = (
}, features: ResolvedClientFeatureMap,
): SanitizedClientFeatures => {
const sanitized: SanitizedClientFeatures = {
enabledFeatures: [], enabledFeatures: [],
floatingSelectToolbar: { floatingSelectToolbar: {
sections: [], sections: [],
}, },
generatedTypes: {
modifyOutputSchemas: [],
},
hooks: { hooks: {
afterReadPromises: [],
load: [], load: [],
save: [], save: [],
}, },
markdownTransformers: [],
nodes: [], nodes: [],
plugins: [], plugins: [],
populationPromises: new Map(),
slashMenu: { slashMenu: {
dynamicOptions: [], dynamicOptions: [],
groupsWithOptions: [], groupsWithOptions: [],
}, },
validations: new Map(), }
if (!features?.size) {
return sanitized
} }
features.forEach((feature) => { features.forEach((feature) => {
if (feature?.generatedTypes?.modifyOutputSchema) {
sanitized.generatedTypes.modifyOutputSchemas.push(feature.generatedTypes.modifyOutputSchema)
}
if (feature.hooks) { if (feature.hooks) {
if (feature.hooks.afterReadPromise) {
sanitized.hooks.afterReadPromises = sanitized.hooks.afterReadPromises.concat(
feature.hooks.afterReadPromise,
)
}
if (feature.hooks?.load?.length) { if (feature.hooks?.load?.length) {
sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load) sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load)
} }
@@ -51,17 +42,6 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
if (feature.nodes?.length) { if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes) sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {
if (node?.populationPromises?.length) {
sanitized.populationPromises.set(node.type, node.populationPromises)
}
if (node?.validations?.length) {
sanitized.validations.set(node.type, node.validations)
}
if (node?.converters?.html) {
sanitized.converters.html.push(node.converters.html)
}
})
} }
if (feature.plugins?.length) { if (feature.plugins?.length) {
feature.plugins.forEach((plugin, i) => { feature.plugins.forEach((plugin, i) => {
@@ -72,11 +52,6 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
}) })
}) })
} }
if (feature.markdownTransformers?.length) {
sanitized.markdownTransformers = sanitized.markdownTransformers.concat(
feature.markdownTransformers,
)
}
if (feature.floatingSelectToolbar?.sections?.length) { if (feature.floatingSelectToolbar?.sections?.length) {
for (const section of feature.floatingSelectToolbar.sections) { for (const section of feature.floatingSelectToolbar.sections) {
@@ -169,14 +144,13 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
return sanitized return sanitized
} }
export function sanitizeEditorConfig(editorConfig: EditorConfig): SanitizedEditorConfig { export function sanitizeClientEditorConfig(
const resolvedFeatureMap = loadFeatures({ lexical: LexicalEditorConfig,
unSanitizedEditorConfig: editorConfig, resolvedClientFeatureMap: ResolvedClientFeatureMap,
}) ): SanitizedClientEditorConfig {
return { return {
features: sanitizeFeatures(resolvedFeatureMap), features: sanitizeClientFeatures(resolvedClientFeatureMap),
lexical: editorConfig.lexical, lexical,
resolvedFeatureMap: resolvedFeatureMap, resolvedFeatureMap: resolvedClientFeatureMap,
} }
} }

View File

@@ -1,57 +0,0 @@
import type { FeatureProvider } from '../../features/types'
import type { EditorConfig, SanitizedEditorConfig } from './types'
import { BlockQuoteFeature } from '../../features/BlockQuote'
import { HeadingFeature } from '../../features/Heading'
import { LinkFeature } from '../../features/Link'
import { ParagraphFeature } from '../../features/Paragraph'
import { RelationshipFeature } from '../../features/Relationship'
import { UploadFeature } from '../../features/Upload'
import { AlignFeature } from '../../features/align'
import { BoldTextFeature } from '../../features/format/Bold'
import { InlineCodeTextFeature } from '../../features/format/InlineCode'
import { ItalicTextFeature } from '../../features/format/Italic'
import { StrikethroughTextFeature } from '../../features/format/strikethrough'
import { SubscriptTextFeature } from '../../features/format/subscript'
import { SuperscriptTextFeature } from '../../features/format/superscript'
import { UnderlineTextFeature } from '../../features/format/underline'
import { IndentFeature } from '../../features/indent'
import { CheckListFeature } from '../../features/lists/CheckList'
import { OrderedListFeature } from '../../features/lists/OrderedList'
import { UnorderedListFeature } from '../../features/lists/UnorderedList'
import { sanitizeEditorConfig } from './sanitize'
export const defaultEditorFeatures: FeatureProvider[] = [
BoldTextFeature(),
ItalicTextFeature(),
UnderlineTextFeature(),
StrikethroughTextFeature(),
SubscriptTextFeature(),
SuperscriptTextFeature(),
InlineCodeTextFeature(),
ParagraphFeature(),
HeadingFeature({}),
AlignFeature(),
IndentFeature(),
UnorderedListFeature(),
OrderedListFeature(),
CheckListFeature(),
LinkFeature({}),
RelationshipFeature(),
BlockQuoteFeature(),
UploadFeature(),
//BlocksFeature(), // Adding this by default makes no sense if no blocks are defined
]
export const defaultEditorConfig: EditorConfig = {
features: defaultEditorFeatures,
lexical: () =>
// @ts-expect-error-next-line
import('./defaultClient').then((module) => {
const defaultEditorLexicalConfig = module.defaultEditorLexicalConfig
return defaultEditorLexicalConfig
}),
}
export const defaultSanitizedEditorConfig: SanitizedEditorConfig =
sanitizeEditorConfig(defaultEditorConfig)

View File

@@ -0,0 +1,59 @@
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import type { FeatureProviderServer } from '../../../features/types'
import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types'
import { BlockQuoteFeature } from '../../../features/BlockQuote'
import { HeadingFeature } from '../../../features/Heading'
import { ParagraphFeature } from '../../../features/Paragraph'
import { RelationshipFeature } from '../../../features/Relationship'
import { UploadFeature } from '../../../features/Upload'
import { AlignFeature } from '../../../features/align/feature.server'
import { BoldTextFeature } from '../../../features/format/Bold'
import { InlineCodeTextFeature } from '../../../features/format/InlineCode'
import { ItalicTextFeature } from '../../../features/format/Italic'
import { StrikethroughTextFeature } from '../../../features/format/strikethrough'
import { SubscriptTextFeature } from '../../../features/format/subscript'
import { SuperscriptTextFeature } from '../../../features/format/superscript'
import { UnderlineTextFeature } from '../../../features/format/underline'
import { IndentFeature } from '../../../features/indent'
import { LinkFeature } from '../../../features/link/feature.server'
import { CheckListFeature } from '../../../features/lists/CheckList'
import { OrderedListFeature } from '../../../features/lists/OrderedList'
import { UnorderedListFeature } from '../../../features/lists/UnorderedList'
import { LexicalEditorTheme } from '../../theme/EditorTheme'
import { sanitizeServerEditorConfig } from './sanitize'
export const defaultEditorLexicalConfig: LexicalEditorConfig = {
namespace: 'lexical',
theme: LexicalEditorTheme,
}
export const defaultEditorFeatures: FeatureProviderServer<unknown, unknown>[] = [
BoldTextFeature(),
ItalicTextFeature(),
UnderlineTextFeature(),
StrikethroughTextFeature(),
SubscriptTextFeature(),
SuperscriptTextFeature(),
InlineCodeTextFeature(),
ParagraphFeature(),
HeadingFeature({}),
AlignFeature(),
IndentFeature(),
UnorderedListFeature(),
OrderedListFeature(),
CheckListFeature(),
LinkFeature({}),
RelationshipFeature(),
BlockQuoteFeature(),
UploadFeature(),
]
export const defaultEditorConfig: ServerEditorConfig = {
features: defaultEditorFeatures,
lexical: defaultEditorLexicalConfig,
}
export const defaultSanitizedServerEditorConfig: SanitizedServerEditorConfig =
sanitizeServerEditorConfig(defaultEditorConfig)

View File

@@ -1,16 +1,22 @@
import type { FeatureProvider, FeatureProviderMap, ResolvedFeatureMap } from '../../features/types' import type {
import type { EditorConfig } from './types' FeatureProviderServer,
ResolvedServerFeatureMap,
ServerFeatureProviderMap,
} from '../../../features/types'
import type { ServerEditorConfig } from '../types'
type DependencyGraph = { type DependencyGraph = {
[key: string]: { [key: string]: {
dependencies: string[] dependencies: string[]
dependenciesPriority: string[] dependenciesPriority: string[]
dependenciesSoft: string[] dependenciesSoft: string[]
featureProvider: FeatureProvider featureProvider: FeatureProviderServer<unknown, unknown>
} }
} }
function createDependencyGraph(featureProviders: FeatureProvider[]): DependencyGraph { function createDependencyGraph(
featureProviders: FeatureProviderServer<unknown, unknown>[],
): DependencyGraph {
const graph: DependencyGraph = {} const graph: DependencyGraph = {}
for (const fp of featureProviders) { for (const fp of featureProviders) {
graph[fp.key] = { graph[fp.key] = {
@@ -23,10 +29,12 @@ function createDependencyGraph(featureProviders: FeatureProvider[]): DependencyG
return graph return graph
} }
function topologicallySortFeatures(featureProviders: FeatureProvider[]): FeatureProvider[] { function topologicallySortFeatures(
featureProviders: FeatureProviderServer<unknown, unknown>[],
): FeatureProviderServer<unknown, unknown>[] {
const graph = createDependencyGraph(featureProviders) const graph = createDependencyGraph(featureProviders)
const visited: { [key: string]: boolean } = {} const visited: { [key: string]: boolean } = {}
const stack: FeatureProvider[] = [] const stack: FeatureProviderServer<unknown, unknown>[] = []
for (const key in graph) { for (const key in graph) {
if (!visited[key]) { if (!visited[key]) {
@@ -41,7 +49,7 @@ function visit(
graph: DependencyGraph, graph: DependencyGraph,
key: string, key: string,
visited: { [key: string]: boolean }, visited: { [key: string]: boolean },
stack: FeatureProvider[], stack: FeatureProviderServer<unknown, unknown>[],
currentPath: string[] = [], currentPath: string[] = [],
) { ) {
if (!graph[key]) { if (!graph[key]) {
@@ -90,16 +98,16 @@ function visit(
} }
export function sortFeaturesForOptimalLoading( export function sortFeaturesForOptimalLoading(
featureProviders: FeatureProvider[], featureProviders: FeatureProviderServer<unknown, unknown>[],
): FeatureProvider[] { ): FeatureProviderServer<unknown, unknown>[] {
return topologicallySortFeatures(featureProviders) return topologicallySortFeatures(featureProviders)
} }
export function loadFeatures({ export function loadFeatures({
unSanitizedEditorConfig, unSanitizedEditorConfig,
}: { }: {
unSanitizedEditorConfig: EditorConfig unSanitizedEditorConfig: ServerEditorConfig
}): ResolvedFeatureMap { }): ResolvedServerFeatureMap {
// First remove all duplicate features. The LAST feature with a given key wins. // First remove all duplicate features. The LAST feature with a given key wins.
unSanitizedEditorConfig.features = unSanitizedEditorConfig.features unSanitizedEditorConfig.features = unSanitizedEditorConfig.features
.reverse() .reverse()
@@ -109,15 +117,18 @@ export function loadFeatures({
}) })
.reverse() .reverse()
const featureProviderMap: FeatureProviderMap = new Map(
unSanitizedEditorConfig.features.map((f) => [f.key, f] as [string, FeatureProvider]),
)
unSanitizedEditorConfig.features = sortFeaturesForOptimalLoading(unSanitizedEditorConfig.features) unSanitizedEditorConfig.features = sortFeaturesForOptimalLoading(unSanitizedEditorConfig.features)
const resolvedFeatures: ResolvedFeatureMap = new Map() const featureProviderMap: ServerFeatureProviderMap = new Map(
unSanitizedEditorConfig.features.map(
(f) => [f.key, f] as [string, FeatureProviderServer<unknown, unknown>],
),
)
const resolvedFeatures: ResolvedServerFeatureMap = new Map()
// Make sure all dependencies declared in the respective features exist // Make sure all dependencies declared in the respective features exist
let loaded = 0
for (const featureProvider of unSanitizedEditorConfig.features) { for (const featureProvider of unSanitizedEditorConfig.features) {
if (!featureProvider.key) { if (!featureProvider.key) {
throw new Error( throw new Error(
@@ -163,12 +174,14 @@ export function loadFeatures({
}) })
resolvedFeatures.set(featureProvider.key, { resolvedFeatures.set(featureProvider.key, {
...feature, ...feature,
Component: featureProvider.Component,
dependencies: featureProvider.dependencies, dependencies: featureProvider.dependencies,
dependenciesPriority: featureProvider.dependenciesPriority, dependenciesPriority: featureProvider.dependenciesPriority,
dependenciesSoft: featureProvider.dependenciesSoft, dependenciesSoft: featureProvider.dependenciesSoft,
key: featureProvider.key, key: featureProvider.key,
order: loaded,
}) })
loaded++
} }
return resolvedFeatures return resolvedFeatures

View File

@@ -0,0 +1,84 @@
import type { ResolvedServerFeatureMap, SanitizedServerFeatures } from '../../../features/types'
import type { SanitizedServerEditorConfig, ServerEditorConfig } from '../types'
import { loadFeatures } from './loader'
export const sanitizeServerFeatures = (
features: ResolvedServerFeatureMap,
): SanitizedServerFeatures => {
const sanitized: SanitizedServerFeatures = {
converters: {
html: [],
},
enabledFeatures: [],
generatedTypes: {
modifyOutputSchemas: [],
},
hooks: {
afterReadPromises: [],
},
markdownTransformers: [],
nodes: [],
populationPromises: new Map(),
validations: new Map(),
}
if (!features?.size) {
return sanitized
}
features.forEach((feature) => {
if (feature?.generatedTypes?.modifyOutputSchema) {
sanitized.generatedTypes.modifyOutputSchemas.push(feature.generatedTypes.modifyOutputSchema)
}
if (feature.hooks) {
if (feature.hooks.afterReadPromise) {
sanitized.hooks.afterReadPromises = sanitized.hooks.afterReadPromises.concat(
feature.hooks.afterReadPromise,
)
}
}
if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {
const nodeType = 'with' in node.node ? node.node.replace.getType() : node.node.getType() // TODO: Idk if this works for node replacements
if (node?.populationPromises?.length) {
sanitized.populationPromises.set(nodeType, node.populationPromises)
}
if (node?.validations?.length) {
sanitized.validations.set(nodeType, node.validations)
}
if (node?.converters?.html) {
sanitized.converters.html.push(node.converters.html)
}
})
}
if (feature.markdownTransformers?.length) {
sanitized.markdownTransformers = sanitized.markdownTransformers.concat(
feature.markdownTransformers,
)
}
sanitized.enabledFeatures.push(feature.key)
})
return sanitized
}
export function sanitizeServerEditorConfig(
editorConfig: ServerEditorConfig,
): SanitizedServerEditorConfig {
const resolvedFeatureMap = loadFeatures({
unSanitizedEditorConfig: editorConfig,
})
return {
features: sanitizeServerFeatures(resolvedFeatureMap),
lexical: editorConfig.lexical,
resolvedFeatureMap: resolvedFeatureMap,
}
}

View File

@@ -1,14 +1,32 @@
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor' import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import type { FeatureProvider, ResolvedFeatureMap, SanitizedFeatures } from '../../features/types' import type {
FeatureProviderClient,
FeatureProviderServer,
ResolvedClientFeatureMap,
ResolvedServerFeatureMap,
SanitizedClientFeatures,
SanitizedServerFeatures,
} from '../../features/types'
export type EditorConfig = { export type ServerEditorConfig = {
features: FeatureProvider[] features: FeatureProviderServer<unknown, unknown>[]
lexical?: () => Promise<LexicalEditorConfig> lexical?: LexicalEditorConfig
} }
export type SanitizedEditorConfig = { export type SanitizedServerEditorConfig = {
features: SanitizedFeatures features: SanitizedServerFeatures
lexical: () => Promise<LexicalEditorConfig> lexical: LexicalEditorConfig
resolvedFeatureMap: ResolvedFeatureMap resolvedFeatureMap: ResolvedServerFeatureMap
}
export type ClientEditorConfig = {
features: FeatureProviderClient<unknown>[]
lexical?: LexicalEditorConfig
}
export type SanitizedClientEditorConfig = {
features: SanitizedClientFeatures
lexical: LexicalEditorConfig
resolvedFeatureMap: ResolvedClientFeatureMap
} }

View File

@@ -1,12 +1,17 @@
import type { Klass, LexicalNode } from 'lexical' import type { Klass, LexicalNode } from 'lexical'
import type { LexicalNodeReplacement } from 'lexical' import type { LexicalNodeReplacement } from 'lexical'
import type { SanitizedEditorConfig } from '../config/types' import type { SanitizedClientEditorConfig, SanitizedServerEditorConfig } from '../config/types'
export function getEnabledNodes({ export function getEnabledNodes({
editorConfig, editorConfig,
}: { }: {
editorConfig: SanitizedEditorConfig editorConfig: SanitizedClientEditorConfig | SanitizedServerEditorConfig
}): Array<Klass<LexicalNode> | LexicalNodeReplacement> { }): Array<Klass<LexicalNode> | LexicalNodeReplacement> {
return editorConfig.features.nodes.map((node) => node.node) return editorConfig.features.nodes.map((node) => {
if ('node' in node) {
return node.node
}
return node
})
} }

View File

@@ -3,7 +3,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { mergeRegister } from '@lexical/utils' import { mergeRegister } from '@lexical/utils'
import { $getSelection } from 'lexical' import { $getSelection } from 'lexical'
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as React from 'react' import React from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { FloatingToolbarSectionEntry } from '../types' import type { FloatingToolbarSectionEntry } from '../types'
@@ -24,7 +24,7 @@ export function DropDownItem({
children: React.ReactNode children: React.ReactNode
entry: FloatingToolbarSectionEntry entry: FloatingToolbarSectionEntry
title?: string title?: string
}): JSX.Element { }): React.ReactNode {
const [editor] = useLexicalComposerContext() const [editor] = useLexicalComposerContext()
const [enabled, setEnabled] = useState<boolean>(true) const [enabled, setEnabled] = useState<boolean>(true)
const [active, setActive] = useState<boolean>(false) const [active, setActive] = useState<boolean>(false)
@@ -209,7 +209,7 @@ export function DropDown({
children: ReactNode children: ReactNode
disabled?: boolean disabled?: boolean
stopCloseOnClickSelf?: boolean stopCloseOnClickSelf?: boolean
}): JSX.Element { }): React.ReactNode {
const dropDownRef = useRef<HTMLDivElement>(null) const dropDownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null)
const [showDropDown, setShowDropDown] = useState(false) const [showDropDown, setShowDropDown] = useState(false)

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../../ui/src/scss/styles.scss';
.floating-select-toolbar-popup__dropdown { .floating-select-toolbar-popup__dropdown {
display: flex; display: flex;

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import React, { useMemo } from 'react' import React from 'react'
const baseClass = 'floating-select-toolbar-popup__dropdown' const baseClass = 'floating-select-toolbar-popup__dropdown'
@@ -19,43 +19,17 @@ export const ToolbarEntry = ({
editor: LexicalEditor editor: LexicalEditor
entry: FloatingToolbarSectionEntry entry: FloatingToolbarSectionEntry
}) => { }) => {
const Component = useMemo(() => {
return entry?.Component
? React.lazy(() =>
entry.Component().then((resolvedComponent) => ({
default: resolvedComponent,
})),
)
: null
}, [entry])
const ChildComponent = useMemo(() => {
return entry?.ChildComponent
? React.lazy(() =>
entry.ChildComponent().then((resolvedChildComponent) => ({
default: resolvedChildComponent,
})),
)
: null
}, [entry])
if (entry.Component) { if (entry.Component) {
return ( return (
Component && ( entry?.Component && (
<React.Suspense> <entry.Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
<Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
</React.Suspense>
) )
) )
} }
return ( return (
<DropDownItem entry={entry} key={entry.key}> <DropDownItem entry={entry} key={entry.key}>
{ChildComponent && ( {entry?.ChildComponent && <entry.ChildComponent />}
<React.Suspense>
<ChildComponent />
</React.Suspense>
)}
<span className="text">{entry.label}</span> <span className="text">{entry.label}</span>
</DropDownItem> </DropDownItem>
) )

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../ui/src/scss/styles.scss';
html[data-theme='light'] { html[data-theme='light'] {
.floating-select-toolbar-popup { .floating-select-toolbar-popup {

View File

@@ -10,13 +10,13 @@ import {
COMMAND_PRIORITY_LOW, COMMAND_PRIORITY_LOW,
SELECTION_CHANGE_COMMAND, SELECTION_CHANGE_COMMAND,
} from 'lexical' } from 'lexical'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import * as React from 'react' import * as React from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import type { FloatingToolbarSection, FloatingToolbarSectionEntry } from './types' import type { FloatingToolbarSection, FloatingToolbarSectionEntry } from './types'
import { useEditorConfigContext } from '../../config/EditorConfigProvider' import { useEditorConfigContext } from '../../config/client/EditorConfigProvider'
import { getDOMRangeRect } from '../../utils/getDOMRangeRect' import { getDOMRangeRect } from '../../utils/getDOMRangeRect'
import { setFloatingElemPosition } from '../../utils/setFloatingElemPosition' import { setFloatingElemPosition } from '../../utils/setFloatingElemPosition'
import { ToolbarButton } from './ToolbarButton' import { ToolbarButton } from './ToolbarButton'
@@ -31,44 +31,18 @@ function ButtonSectionEntry({
anchorElem: HTMLElement anchorElem: HTMLElement
editor: LexicalEditor editor: LexicalEditor
entry: FloatingToolbarSectionEntry entry: FloatingToolbarSectionEntry
}): JSX.Element { }): React.ReactNode {
const Component = useMemo(() => {
return entry?.Component
? React.lazy(() =>
entry.Component().then((resolvedComponent) => ({
default: resolvedComponent,
})),
)
: null
}, [entry])
const ChildComponent = useMemo(() => {
return entry?.ChildComponent
? React.lazy(() =>
entry.ChildComponent().then((resolvedChildComponent) => ({
default: resolvedChildComponent,
})),
)
: null
}, [entry])
if (entry.Component) { if (entry.Component) {
return ( return (
Component && ( entry?.Component && (
<React.Suspense> <entry.Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
<Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />{' '}
</React.Suspense>
) )
) )
} }
return ( return (
<ToolbarButton entry={entry} key={entry.key}> <ToolbarButton entry={entry} key={entry.key}>
{ChildComponent && ( {entry?.ChildComponent && <entry.ChildComponent />}
<React.Suspense>
<ChildComponent />
</React.Suspense>
)}
</ToolbarButton> </ToolbarButton>
) )
} }
@@ -83,18 +57,13 @@ function ToolbarSection({
editor: LexicalEditor editor: LexicalEditor
index: number index: number
section: FloatingToolbarSection section: FloatingToolbarSection
}): JSX.Element { }): React.ReactNode {
const { editorConfig } = useEditorConfigContext() const { editorConfig } = useEditorConfigContext()
const Icon = useMemo(() => { const Icon =
return section?.type === 'dropdown' && section.entries.length && section.ChildComponent section?.type === 'dropdown' && section.entries.length && section.ChildComponent
? React.lazy(() => ? section.ChildComponent
section.ChildComponent().then((resolvedComponent) => ({
default: resolvedComponent,
})),
)
: null : null
}, [section])
return ( return (
<div <div
@@ -104,15 +73,13 @@ function ToolbarSection({
{section.type === 'dropdown' && {section.type === 'dropdown' &&
section.entries.length && section.entries.length &&
(Icon ? ( (Icon ? (
<React.Suspense> <ToolbarDropdown
<ToolbarDropdown Icon={Icon}
Icon={Icon} anchorElem={anchorElem}
anchorElem={anchorElem} editor={editor}
editor={editor} entries={section.entries}
entries={section.entries} sectionKey={section.key}
sectionKey={section.key} />
/>
</React.Suspense>
) : ( ) : (
<ToolbarDropdown <ToolbarDropdown
anchorElem={anchorElem} anchorElem={anchorElem}
@@ -146,7 +113,7 @@ function FloatingSelectToolbar({
}: { }: {
anchorElem: HTMLElement anchorElem: HTMLElement
editor: LexicalEditor editor: LexicalEditor
}): JSX.Element { }): React.ReactNode {
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null) const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
const caretRef = useRef<HTMLDivElement | null>(null) const caretRef = useRef<HTMLDivElement | null>(null)

View File

@@ -3,7 +3,7 @@ import type React from 'react'
export type FloatingToolbarSection = export type FloatingToolbarSection =
| { | {
ChildComponent?: () => Promise<React.FC> ChildComponent?: React.FC
entries: Array<FloatingToolbarSectionEntry> entries: Array<FloatingToolbarSectionEntry>
key: string key: string
order?: number order?: number
@@ -17,15 +17,13 @@ export type FloatingToolbarSection =
} }
export type FloatingToolbarSectionEntry = { export type FloatingToolbarSectionEntry = {
ChildComponent?: () => Promise<React.FC> ChildComponent?: React.FC
/** Use component to ignore the children and onClick properties. It does not use the default, pre-defined format Button component */ /** Use component to ignore the children and onClick properties. It does not use the default, pre-defined format Button component */
Component?: () => Promise< Component?: React.FC<{
React.FC<{ anchorElem: HTMLElement
anchorElem: HTMLElement editor: LexicalEditor
editor: LexicalEditor entry: FloatingToolbarSectionEntry
entry: FloatingToolbarSectionEntry }>
}>
>
isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean
isEnabled?: ({ isEnabled?: ({
editor, editor,

View File

@@ -2,10 +2,11 @@
import { MarkdownShortcutPlugin as LexicalMarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin' import { MarkdownShortcutPlugin as LexicalMarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'
import * as React from 'react' import * as React from 'react'
import { useEditorConfigContext } from '../../config/EditorConfigProvider' import { useEditorConfigContext } from '../../config/client/EditorConfigProvider'
export const MarkdownShortcutPlugin: React.FC = () => { export const MarkdownShortcutPlugin: React.FC = () => {
const { editorConfig } = useEditorConfigContext() const { editorConfig } = useEditorConfigContext()
console.log('traaaaa', editorConfig.features.markdownTransformers)
return <LexicalMarkdownShortcutPlugin transformers={editorConfig.features.markdownTransformers} /> return <LexicalMarkdownShortcutPlugin transformers={editorConfig.features.markdownTransformers} />
} }

View File

@@ -5,7 +5,7 @@ import type React from 'react'
export class SlashMenuOption { export class SlashMenuOption {
// Icon for display // Icon for display
Icon: () => Promise<React.FC> Icon: React.FC
displayName?: (({ i18n }: { i18n: I18n }) => string) | string displayName?: (({ i18n }: { i18n: I18n }) => string) | string
// Used for class names and, if displayName is not provided, for display. // Used for class names and, if displayName is not provided, for display.
@@ -22,7 +22,7 @@ export class SlashMenuOption {
constructor( constructor(
key: string, key: string,
options: { options: {
Icon: () => Promise<React.FC> Icon: React.FC
displayName?: (({ i18n }: { i18n: I18n }) => string) | string displayName?: (({ i18n }: { i18n: I18n }) => string) | string
keyboardShortcut?: string keyboardShortcut?: string
keywords?: Array<string> keywords?: Array<string>

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss'; @import '../../../../../../ui/src/scss/styles.scss';
html[data-theme='light'] { html[data-theme='light'] {
.slash-menu-popup { .slash-menu-popup {

View File

@@ -9,7 +9,7 @@ import * as ReactDOM from 'react-dom'
import type { SlashMenuGroup, SlashMenuOption } from './LexicalTypeaheadMenuPlugin/types' import type { SlashMenuGroup, SlashMenuOption } from './LexicalTypeaheadMenuPlugin/types'
import { useEditorConfigContext } from '../../config/EditorConfigProvider' import { useEditorConfigContext } from '../../config/client/EditorConfigProvider'
import { LexicalTypeaheadMenuPlugin } from './LexicalTypeaheadMenuPlugin' import { LexicalTypeaheadMenuPlugin } from './LexicalTypeaheadMenuPlugin'
import './index.scss' import './index.scss'
import { useMenuTriggerMatch } from './useMenuTriggerMatch' import { useMenuTriggerMatch } from './useMenuTriggerMatch'
@@ -45,16 +45,6 @@ function SlashMenuItem({
title = title.substring(0, 25) + '...' title = title.substring(0, 25) + '...'
} }
const LazyIcon = useMemo(() => {
return option?.Icon
? React.lazy(() =>
option.Icon().then((resolvedIcon) => ({
default: resolvedIcon,
})),
)
: null
}, [option])
return ( return (
<button <button
aria-selected={isSelected} aria-selected={isSelected}
@@ -68,11 +58,7 @@ function SlashMenuItem({
tabIndex={-1} tabIndex={-1}
type="button" type="button"
> >
{LazyIcon && ( {option?.Icon && <option.Icon />}
<React.Suspense>
<LazyIcon />
</React.Suspense>
)}
<span className={`${baseClass}__item-text`}>{title}</span> <span className={`${baseClass}__item-text`}>{title}</span>
</button> </button>

View File

@@ -1,23 +1,104 @@
import type { RichTextAdapter } from 'payload/types' import type { RichTextAdapter } from 'payload/types'
import { mapFields } from '@payloadcms/ui/utilities'
import { sanitizeFields } from 'payload/config'
import React from 'react' import React from 'react'
import type { ResolvedFeatureMap } from './field/features/types' import type { ResolvedServerFeatureMap } from './field/features/types'
import type { GeneratedFeatureProviderComponent } from './types'
export const getGenerateComponentMap = export const getGenerateComponentMap =
(args: { resolvedFeatureMap: ResolvedFeatureMap }): RichTextAdapter['generateComponentMap'] => (args: {
({ config }) => { resolvedFeatureMap: ResolvedServerFeatureMap
}): RichTextAdapter['generateComponentMap'] =>
({ config, schemaPath }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
const componentMap = new Map() const componentMap = new Map()
console.log('args.resolvedFeatureMap', args.resolvedFeatureMap) // turn args.resolvedFeatureMap into an array of [key, value] pairs, ordered by value.order, lowest order first:
const resolvedFeatureMapArray = Array.from(args.resolvedFeatureMap.entries()).sort(
(a, b) => a[1].order - b[1].order,
)
for (const key of args.resolvedFeatureMap.keys()) { componentMap.set(
console.log('key', key) `features`,
const resolvedFeature = args.resolvedFeatureMap.get(key) resolvedFeatureMapArray.map(([featureKey, resolvedFeature]) => {
const Component = resolvedFeature.Component const ClientComponent = resolvedFeature.ClientComponent
componentMap.set(`feature.${key}`, <Component />) const clientComponentProps = resolvedFeature.clientFeatureProps
}
/**
* Handle Feature Component Maps
*/
if (
'generateComponentMap' in resolvedFeature &&
typeof resolvedFeature.generateComponentMap === 'function'
) {
const components = resolvedFeature.generateComponentMap({
config,
props: resolvedFeature.serverFeatureProps,
schemaPath,
})
for (const componentKey in components) {
const Component = components[componentKey]
if (Component) {
componentMap.set(`feature.${featureKey}.components.${componentKey}`, <Component />)
}
}
}
/**
* Handle Feature Schema Maps (rendered fields)
*/
if (
'generateSchemaMap' in resolvedFeature &&
typeof resolvedFeature.generateSchemaMap === 'function'
) {
const schemas = resolvedFeature.generateSchemaMap({
config,
props: resolvedFeature.serverFeatureProps,
schemaMap: new Map(),
schemaPath,
})
for (const schemaKey in schemas) {
const fields = schemas[schemaKey]
const sanitizedFields = sanitizeFields({
config,
fields,
validRelationships,
})
const mappedFields = mapFields({
config,
fieldSchema: sanitizedFields,
operation: 'update',
permissions: {},
readOnly: false,
})
componentMap.set(`feature.${featureKey}.fields.${schemaKey}`, mappedFields)
}
}
return {
ClientComponent:
clientComponentProps && typeof clientComponentProps === 'object' ? (
<ClientComponent
{...clientComponentProps}
featureKey={resolvedFeature.key}
order={resolvedFeature.order}
/>
) : (
<ClientComponent featureKey={resolvedFeature.key} order={resolvedFeature.order} />
),
key: resolvedFeature.key,
order: resolvedFeature.order,
} as GeneratedFeatureProviderComponent
}),
)
console.log('componentMaaap', componentMap)
return componentMap return componentMap
} }

View File

@@ -0,0 +1,40 @@
import type { RichTextAdapter } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { ResolvedServerFeatureMap } from './field/features/types'
export const getGenerateSchemaMap =
(args: { resolvedFeatureMap: ResolvedServerFeatureMap }): RichTextAdapter['generateSchemaMap'] =>
({ config, schemaMap, schemaPath }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
for (const [featureKey, resolvedFeature] of args.resolvedFeatureMap.entries()) {
if (
!('generateSchemaMap' in resolvedFeature) ||
typeof resolvedFeature.generateSchemaMap !== 'function'
) {
continue
}
const schemas = resolvedFeature.generateSchemaMap({
config,
props: resolvedFeature.serverFeatureProps,
schemaMap,
schemaPath,
})
for (const schemaKey in schemas) {
const fields = schemas[schemaKey]
const sanitizedFields = sanitizeFields({
config,
fields,
validRelationships,
})
schemaMap.set(`${schemaPath}.feature.${featureKey}.${schemaKey}`, sanitizedFields)
}
}
return schemaMap
}

View File

@@ -5,45 +5,51 @@ import type { RichTextAdapter } from 'payload/types'
import { withNullableJSONSchemaType } from 'payload/utilities' import { withNullableJSONSchemaType } from 'payload/utilities'
import type { FeatureProvider, ResolvedFeatureMap } from './field/features/types' import type { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types'
import type { EditorConfig, SanitizedEditorConfig } from './field/lexical/config/types' import type { SanitizedServerEditorConfig } from './field/lexical/config/types'
import type { AdapterProps } from './types' import type { AdapterProps } from './types'
import { import {
defaultEditorConfig, defaultEditorConfig,
defaultEditorFeatures, defaultEditorFeatures,
defaultSanitizedEditorConfig, defaultSanitizedServerEditorConfig,
} from './field/lexical/config/default' } from './field/lexical/config/server/default'
import { loadFeatures } from './field/lexical/config/loader' import { loadFeatures } from './field/lexical/config/server/loader'
import { sanitizeFeatures } from './field/lexical/config/sanitize' import { sanitizeServerFeatures } from './field/lexical/config/server/sanitize'
import { cloneDeep } from './field/lexical/utils/cloneDeep' import { cloneDeep } from './field/lexical/utils/cloneDeep'
import { getGenerateComponentMap } from './generateComponentMap' import { getGenerateComponentMap } from './generateComponentMap'
import { getGenerateSchemaMap } from './generateSchemaMap'
import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise' import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise'
import { richTextValidateHOC } from './validate' import { richTextValidateHOC } from './validate'
export type LexicalEditorProps = { export type LexicalEditorProps = {
features?: features?:
| (({ defaultFeatures }: { defaultFeatures: FeatureProvider[] }) => FeatureProvider[]) | (({
| FeatureProvider[] defaultFeatures,
}: {
defaultFeatures: FeatureProviderServer<unknown, unknown>[]
}) => FeatureProviderServer<unknown, unknown>[])
| FeatureProviderServer<unknown, unknown>[]
lexical?: LexicalEditorConfig lexical?: LexicalEditorConfig
} }
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
export type LexicalRichTextAdapter = RichTextAdapter<SerializedEditorState, AdapterProps, any> & { export type LexicalRichTextAdapter = RichTextAdapter<SerializedEditorState, AdapterProps, any> & {
editorConfig: SanitizedEditorConfig editorConfig: SanitizedServerEditorConfig
} }
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapter { export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapter {
let resolvedFeatureMap: ResolvedFeatureMap = null // For client and sending to client. Better than serializing completely on client. That way the feature loading can be done on the server. let resolvedFeatureMap: ResolvedServerFeatureMap = null
let finalSanitizedEditorConfig: SanitizedEditorConfig // For server only let finalSanitizedEditorConfig: SanitizedServerEditorConfig // For server only
if (!props || (!props.features && !props.lexical)) { if (!props || (!props.features && !props.lexical)) {
finalSanitizedEditorConfig = cloneDeep(defaultSanitizedEditorConfig) finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig)
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else { } else {
let features: FeatureProvider[] = let features: FeatureProviderServer<unknown, unknown>[] =
props.features && typeof props.features === 'function' props.features && typeof props.features === 'function'
? props.features({ defaultFeatures: cloneDeep(defaultEditorFeatures) }) ? props.features({ defaultFeatures: cloneDeep(defaultEditorFeatures) })
: (props.features as FeatureProvider[]) : (props.features as FeatureProviderServer<unknown, unknown>[])
if (!features) { if (!features) {
features = cloneDeep(defaultEditorFeatures) features = cloneDeep(defaultEditorFeatures)
} }
@@ -53,14 +59,14 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
resolvedFeatureMap = loadFeatures({ resolvedFeatureMap = loadFeatures({
unSanitizedEditorConfig: { unSanitizedEditorConfig: {
features, features,
lexical: lexical ? () => Promise.resolve(lexical) : defaultEditorConfig.lexical, lexical: lexical ? lexical : defaultEditorConfig.lexical,
}, },
}) })
finalSanitizedEditorConfig = { finalSanitizedEditorConfig = {
features: sanitizeFeatures(resolvedFeatureMap), features: sanitizeServerFeatures(resolvedFeatureMap),
lexical: lexical ? () => Promise.resolve(lexical) : defaultEditorConfig.lexical, lexical: lexical ? lexical : defaultEditorConfig.lexical,
resolvedFeatureMap: resolvedFeatureMap, resolvedFeatureMap,
} }
} }
@@ -113,7 +119,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
}, },
editorConfig: finalSanitizedEditorConfig, editorConfig: finalSanitizedEditorConfig,
generateComponentMap: getGenerateComponentMap({ generateComponentMap: getGenerateComponentMap({
resolvedFeatureMap: resolvedFeatureMap, resolvedFeatureMap,
}),
generateSchemaMap: getGenerateSchemaMap({
resolvedFeatureMap,
}), }),
outputSchema: ({ field, interfaceNameDefinitions, isRequired }) => { outputSchema: ({ field, interfaceNameDefinitions, isRequired }) => {
let outputSchema: JSONSchema4 = { let outputSchema: JSONSchema4 = {
@@ -234,23 +243,6 @@ export {
} from './field/features/Blocks/nodes/BlocksNode' } from './field/features/Blocks/nodes/BlocksNode'
export { HeadingFeature } from './field/features/Heading' export { HeadingFeature } from './field/features/Heading'
export { LinkFeature } from './field/features/Link'
export type { LinkFeatureProps } from './field/features/Link'
export {
$createAutoLinkNode,
$isAutoLinkNode,
AutoLinkNode,
type SerializedAutoLinkNode,
} from './field/features/Link/nodes/AutoLinkNode'
export {
$createLinkNode,
$isLinkNode,
type LinkFields,
LinkNode,
type SerializedLinkNode,
TOGGLE_LINK_COMMAND,
} from './field/features/Link/nodes/LinkNode'
export { ParagraphFeature } from './field/features/Paragraph' export { ParagraphFeature } from './field/features/Paragraph'
export { RelationshipFeature } from './field/features/Relationship' export { RelationshipFeature } from './field/features/Relationship'
export { export {
@@ -260,6 +252,7 @@ export {
RelationshipNode, RelationshipNode,
type SerializedRelationshipNode, type SerializedRelationshipNode,
} from './field/features/Relationship/nodes/RelationshipNode' } from './field/features/Relationship/nodes/RelationshipNode'
export { UploadFeature } from './field/features/Upload' export { UploadFeature } from './field/features/Upload'
export type { UploadFeatureProps } from './field/features/Upload' export type { UploadFeatureProps } from './field/features/Upload'
export type { RawUploadPayload } from './field/features/Upload/nodes/UploadNode' export type { RawUploadPayload } from './field/features/Upload/nodes/UploadNode'
@@ -270,7 +263,7 @@ export {
type UploadData, type UploadData,
UploadNode, UploadNode,
} from './field/features/Upload/nodes/UploadNode' } from './field/features/Upload/nodes/UploadNode'
export { AlignFeature } from './field/features/align' export { AlignFeature } from './field/features/align/feature.server'
export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection' export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection'
export { export {
HTMLConverterFeature, HTMLConverterFeature,
@@ -287,19 +280,36 @@ export { defaultHTMLConverters } from './field/features/converters/html/converte
export type { HTMLConverter } from './field/features/converters/html/converter/types' export type { HTMLConverter } from './field/features/converters/html/converter/types'
export { consolidateHTMLConverters } from './field/features/converters/html/field' export { consolidateHTMLConverters } from './field/features/converters/html/field'
export { lexicalHTML } from './field/features/converters/html/field' export { lexicalHTML } from './field/features/converters/html/field'
export { TestRecorderFeature } from './field/features/debug/TestRecorder' export { TestRecorderFeature } from './field/features/debug/TestRecorder'
export { TreeViewFeature } from './field/features/debug/TreeView' export { TreeViewFeature } from './field/features/debug/TreeView'
export { BoldTextFeature } from './field/features/format/Bold' export { BoldTextFeature } from './field/features/format/Bold'
export { InlineCodeTextFeature } from './field/features/format/InlineCode' export { InlineCodeTextFeature } from './field/features/format/InlineCode'
export { ItalicTextFeature } from './field/features/format/Italic' export { ItalicTextFeature } from './field/features/format/Italic'
export { SectionWithEntries as FormatSectionWithEntries } from './field/features/format/common/floatingSelectToolbarSection' export { SectionWithEntries as FormatSectionWithEntries } from './field/features/format/common/floatingSelectToolbarSection'
export { StrikethroughTextFeature } from './field/features/format/strikethrough' export { StrikethroughTextFeature } from './field/features/format/strikethrough'
export { SubscriptTextFeature } from './field/features/format/subscript' export { SubscriptTextFeature } from './field/features/format/subscript'
export { SuperscriptTextFeature } from './field/features/format/superscript' export { SuperscriptTextFeature } from './field/features/format/superscript'
export { UnderlineTextFeature } from './field/features/format/underline' export { UnderlineTextFeature } from './field/features/format/underline'
export { IndentFeature } from './field/features/indent' export { IndentFeature } from './field/features/indent'
export { LinkFeature, type LinkFeatureServerProps } from './field/features/link/feature.server'
export {
$createAutoLinkNode,
$isAutoLinkNode,
AutoLinkNode,
} from './field/features/link/nodes/AutoLinkNode'
export {
$createLinkNode,
$isLinkNode,
LinkNode,
TOGGLE_LINK_COMMAND,
} from './field/features/link/nodes/LinkNode'
export type {
LinkFields,
SerializedAutoLinkNode,
SerializedLinkNode,
} from './field/features/link/nodes/types'
export { CheckListFeature } from './field/features/lists/CheckList' export { CheckListFeature } from './field/features/lists/CheckList'
export { OrderedListFeature } from './field/features/lists/OrderedList' export { OrderedListFeature } from './field/features/lists/OrderedList'
export { UnorderedListFeature } from './field/features/lists/UnorderedList' export { UnorderedListFeature } from './field/features/lists/UnorderedList'
@@ -330,26 +340,50 @@ export type {
} from './field/features/migrations/SlateToLexical/converter/types' } from './field/features/migrations/SlateToLexical/converter/types'
export type { export type {
Feature, ClientFeature,
FeatureProvider, ClientFeatureProviderMap,
FeatureProviderMap, FeatureProviderClient,
FeatureProviderProviderClient,
FeatureProviderProviderServer,
FeatureProviderServer,
NodeValidation, NodeValidation,
PopulationPromise, PopulationPromise,
ResolvedFeature, ResolvedClientFeature,
ResolvedFeatureMap, ResolvedClientFeatureMap,
SanitizedFeatures, ResolvedServerFeature,
ResolvedServerFeatureMap,
SanitizedClientFeatures,
SanitizedPlugin,
SanitizedServerFeatures,
ServerFeature,
ServerFeatureProviderMap,
} from './field/features/types' } from './field/features/types'
export { export {
EditorConfigProvider, EditorConfigProvider,
useEditorConfigContext, useEditorConfigContext,
} from './field/lexical/config/EditorConfigProvider' } from './field/lexical/config/client/EditorConfigProvider'
export {
sanitizeClientEditorConfig,
sanitizeClientFeatures,
} from './field/lexical/config/client/sanitize'
export { export {
defaultEditorConfig, defaultEditorConfig,
defaultEditorFeatures, defaultEditorFeatures,
defaultSanitizedEditorConfig, defaultEditorLexicalConfig,
} from './field/lexical/config/default' defaultSanitizedServerEditorConfig,
export { loadFeatures, sortFeaturesForOptimalLoading } from './field/lexical/config/loader' } from './field/lexical/config/server/default'
export { sanitizeEditorConfig, sanitizeFeatures } from './field/lexical/config/sanitize' export { loadFeatures, sortFeaturesForOptimalLoading } from './field/lexical/config/server/loader'
export {
sanitizeServerEditorConfig,
sanitizeServerFeatures,
} from './field/lexical/config/server/sanitize'
export type {
ClientEditorConfig,
SanitizedClientEditorConfig,
SanitizedServerEditorConfig,
ServerEditorConfig,
} from './field/lexical/config/types'
export { getEnabledNodes } from './field/lexical/nodes' export { getEnabledNodes } from './field/lexical/nodes'
export { export {
@@ -357,8 +391,6 @@ export {
type FloatingToolbarSectionEntry, type FloatingToolbarSectionEntry,
} from './field/lexical/plugins/FloatingSelectToolbar/types' } from './field/lexical/plugins/FloatingSelectToolbar/types'
export { ENABLE_SLASH_MENU_COMMAND } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index' export { ENABLE_SLASH_MENU_COMMAND } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index'
// export SanitizedEditorConfig
export type { EditorConfig, SanitizedEditorConfig }
export type { AdapterProps } export type { AdapterProps }
export { export {

View File

@@ -2,8 +2,9 @@ import type { SerializedEditorState } from 'lexical'
import type { FieldPermissions } from 'payload/auth' import type { FieldPermissions } from 'payload/auth'
import type { FieldTypes } from 'payload/config' import type { FieldTypes } from 'payload/config'
import type { RichTextFieldProps } from 'payload/types' import type { RichTextFieldProps } from 'payload/types'
import type React from 'react'
import type { SanitizedEditorConfig } from './field/lexical/config/types' import type { SanitizedServerEditorConfig } from './field/lexical/config/types'
export type FieldProps = RichTextFieldProps<SerializedEditorState, AdapterProps, AdapterProps> & { export type FieldProps = RichTextFieldProps<SerializedEditorState, AdapterProps, AdapterProps> & {
fieldTypes: FieldTypes fieldTypes: FieldTypes
@@ -13,5 +14,11 @@ export type FieldProps = RichTextFieldProps<SerializedEditorState, AdapterProps,
} }
export type AdapterProps = { export type AdapterProps = {
editorConfig: SanitizedEditorConfig editorConfig: SanitizedServerEditorConfig
}
export type GeneratedFeatureProviderComponent = {
ClientComponent: React.ReactNode
key: string
order: number
} }

View File

@@ -5,9 +5,7 @@ import type { FeatureProvider } from './field/features/types'
import { useFieldPath } from '../../ui/src/forms/FieldPathProvider' import { useFieldPath } from '../../ui/src/forms/FieldPathProvider'
type FeatureProviderGetter = () => FeatureProvider export const useLexicalFeature = (key: string, feature: FeatureProvider) => {
export const useLexicalFeature = (key: string, feature: FeatureProviderGetter) => {
const { schemaPath } = useFieldPath() const { schemaPath } = useFieldPath()
useAddClientFunction(`lexicalFeature.${schemaPath}.${key}`, feature) useAddClientFunction(`lexicalFeature.${schemaPath}.${key}`, feature)

View File

@@ -21,5 +21,5 @@
"src/**/*.spec.tsx" "src/**/*.spec.tsx"
], ],
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"], "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts", "src/**/*.json"],
"references": [{ "path": "../payload" }] "references": [{ "path": "../payload" }, { "path": "../translations" }, { "path": "../ui" }]
} }

View File

@@ -64,7 +64,7 @@ export const LinkElement = () => {
const fieldMapPath = `${schemaPath}.${linkFieldsSchemaPath}` const fieldMapPath = `${schemaPath}.${linkFieldsSchemaPath}`
const { richTextComponentMap } = fieldProps const { richTextComponentMap } = fieldProps
const fieldMap = richTextComponentMap.get(fieldMapPath) const fieldMap = richTextComponentMap.get(linkFieldsSchemaPath)
const editor = useSlate() const editor = useSlate()
const config = useConfig() const config = useConfig()
@@ -113,7 +113,7 @@ export const LinkElement = () => {
setInitialState(state) setInitialState(state)
} }
awaitInitialState() void awaitInitialState()
}, [renderModal, element, user, locale, t, getDocPreferences, config, id, fieldMapPath]) }, [renderModal, element, user, locale, t, getDocPreferences, config, id, fieldMapPath])
return ( return (

View File

@@ -5,20 +5,22 @@ import type { Editor } from 'slate'
import { useSlatePlugin } from '../../../utilities/useSlatePlugin' import { useSlatePlugin } from '../../../utilities/useSlatePlugin'
export const WithLinks: React.FC = () => { const plugin = (incomingEditor: Editor): Editor => {
useSlatePlugin('withLinks', (incomingEditor: Editor): Editor => { const editor = incomingEditor
const editor = incomingEditor const { isInline } = editor
const { isInline } = editor
editor.isInline = (element) => { editor.isInline = (element) => {
if (element.type === 'link') { if (element.type === 'link') {
return true return true
}
return isInline(element)
} }
return editor return isInline(element)
}) }
return editor
}
export const WithLinks: React.FC = () => {
useSlatePlugin('withLinks', plugin)
return null return null
} }

View File

@@ -1 +0,0 @@
export declare function transpileAndCopy(sourcePath: any, targetPath: any): Promise<void>;

View File

@@ -1,27 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.transpileAndCopy = void 0;
const fs_1 = __importDefault(require("fs"));
const swc = require('@swc/core');
async function transpileAndCopy(sourcePath, targetPath) {
try {
const inputCode = fs_1.default.readFileSync(sourcePath, 'utf-8');
const { code } = await swc.transform(inputCode, {
filename: sourcePath,
jsc: {
parser: {
syntax: 'typescript',
},
},
});
fs_1.default.writeFileSync(targetPath.replace(/\.tsx?$/, '.js'), code, 'utf-8');
console.log(`Transpiled and copied ${sourcePath} to ${targetPath.replace(/\.tsx?$/, '.js')}`);
}
catch (error) {
console.error(`Error transpiling ${sourcePath}: ${error.message}`);
}
}
exports.transpileAndCopy = transpileAndCopy;

View File

@@ -1,6 +1,7 @@
export { default as Error } from '../forms/Error' export { default as Error } from '../forms/Error'
export { default as FieldDescription } from '../forms/FieldDescription' export { default as FieldDescription } from '../forms/FieldDescription'
export { FieldPathProvider } from '../forms/FieldPathProvider' export { FieldPathProvider } from '../forms/FieldPathProvider'
export { useFieldPath } from '../forms/FieldPathProvider'
export { default as Form } from '../forms/Form' export { default as Form } from '../forms/Form'
export { default as buildInitialState } from '../forms/Form' export { default as buildInitialState } from '../forms/Form'
export { export {

View File

@@ -37,7 +37,7 @@ export const useAddClientFunction = (key: string, func: any) => {
func, func,
key, key,
}) })
}, [func, key]) }, [func, key, addClientFunction])
} }
export const useClientFunctions = () => { export const useClientFunctions = () => {

3
pnpm-lock.yaml generated
View File

@@ -1205,6 +1205,9 @@ importers:
'@types/react': '@types/react':
specifier: 18.2.15 specifier: 18.2.15
version: 18.2.15 version: 18.2.15
'@types/react-dom':
specifier: 18.2.7
version: 18.2.7
payload: payload:
specifier: workspace:* specifier: workspace:*
version: link:../payload version: link:../payload

View File

@@ -1,3 +1,4 @@
import { AlignFeature, LinkFeature, lexicalEditor } from '@payloadcms/richtext-lexical'
import path from 'path' import path from 'path'
import type { Config, SanitizedConfig } from '../packages/payload/src/config/types' import type { Config, SanitizedConfig } from '../packages/payload/src/config/types'
@@ -5,7 +6,7 @@ import type { Config, SanitizedConfig } from '../packages/payload/src/config/typ
import { mongooseAdapter } from '../packages/db-mongodb/src' import { mongooseAdapter } from '../packages/db-mongodb/src'
import { postgresAdapter } from '../packages/db-postgres/src' import { postgresAdapter } from '../packages/db-postgres/src'
import { buildConfig as buildPayloadConfig } from '../packages/payload/src/config/build' import { buildConfig as buildPayloadConfig } from '../packages/payload/src/config/build'
import { slateEditor } from '../packages/richtext-slate/src' //import { slateEditor } from '../packages/richtext-slate/src'
// process.env.PAYLOAD_DATABASE = 'postgres' // process.env.PAYLOAD_DATABASE = 'postgres'
@@ -55,7 +56,7 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
const config: Config = { const config: Config = {
db: databaseAdapters[process.env.PAYLOAD_DATABASE || 'mongoose'], db: databaseAdapters[process.env.PAYLOAD_DATABASE || 'mongoose'],
secret: 'TEST_SECRET', secret: 'TEST_SECRET',
editor: slateEditor({ /*editor: slateEditor({
admin: { admin: {
upload: { upload: {
collections: { collections: {
@@ -70,6 +71,9 @@ export function buildConfigWithDefaults(testConfig?: Partial<Config>): Promise<S
}, },
}, },
}, },
}),*/
editor: lexicalEditor({
features: [LinkFeature({}), AlignFeature()],
}), }),
rateLimit: { rateLimit: {
max: 9999999999, max: 9999999999,