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",
// Load .git-blame-ignore-revs file
"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]": {
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
export { RichTextCell } from '../cell'
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 { 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 { ErrorBoundary } from 'react-error-boundary'
import type { SanitizedEditorConfig } from './lexical/config/types'
import type { SanitizedClientEditorConfig } from './lexical/config/types'
import { richTextValidateHOC } from '../validate'
import './index.scss'
@@ -15,7 +15,7 @@ const baseClass = 'rich-text-lexical'
const RichText: React.FC<
FormFieldBase & {
editorConfig: SanitizedEditorConfig // With rendered features n stuff
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
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) {
// 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 { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { transformInputFormSchema } from '../utils/transformInputFormSchema'
import { BlockContent } from './BlockContent'
import './index.scss'

View File

@@ -13,7 +13,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider'
import { $createBlockNode } from '../nodes/BlocksNode'
import { INSERT_BLOCK_COMMAND } from '../plugin/commands'
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 { useEditorConfigContext } from '../../../../lexical/config/EditorConfigProvider'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../../drawer/commands'
import './index.scss'

View File

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

View File

@@ -26,7 +26,7 @@ import type { ElementProps } from '..'
import type { UploadFeatureProps } from '../..'
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

View File

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

View File

@@ -21,7 +21,7 @@ import React, { useCallback, useReducer, useState } from 'react'
import type { UploadFeatureProps } from '..'
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 { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands'
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,
} from '../../lexical/plugins/FloatingSelectToolbar/types'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft'
export const AlignDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
type: 'dropdown',
ChildComponent: () =>
// @ts-expect-error-next-line
import('../../lexical/ui/icons/AlignLeft').then((module) => module.AlignLeftIcon),
ChildComponent: AlignLeftIcon,
entries,
key: 'dropdown-align',
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 {
&__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 Field } from 'payload/types'
import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
export interface Props {
drawerSlug: string
fieldSchema: Field[]
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,
} 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
// allow typing within the link

View File

@@ -15,39 +15,11 @@ import {
type LexicalNode,
type NodeKey,
type RangeSelection,
type SerializedElementNode,
type Spread,
createCommand,
} from 'lexical'
import type { LinkPayload } from '../plugins/floatingLinkEditor/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
>
import type { LinkFields, SerializedLinkNode } from './types'
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'
import { useEffect } from 'react'
import type { LinkFields } from '../../nodes/types'
import { invariant } from '../../../../lexical/utils/invariant'
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

View File

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

View File

@@ -1,4 +1,4 @@
@import '@payloadcms/ui/scss';
@import '../../../../../../../ui/src/scss/styles.scss';
html[data-theme='light'] {
.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

View File

@@ -10,10 +10,11 @@ import {
} from 'lexical'
import { useEffect } from 'react'
import type { LinkFields } from '../../nodes/types'
import type { LinkPayload } from '../floatingLinkEditor/types'
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 {
const [editor] = useLexicalComposerContext()

View File

@@ -1,12 +1,12 @@
import type { LinkFeatureProps } from '.'
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 { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const linkPopulationPromiseHOC = (
props: LinkFeatureProps,
props: LinkFeatureServerProps,
): PopulationPromise<SerializedLinkNode> => {
const linkPopulationPromise: PopulationPromise<SerializedLinkNode> = ({
context,

View File

@@ -1,5 +1,5 @@
/* 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 { 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 { convertSlateNodesToLexical } from '..'

View File

@@ -5,11 +5,11 @@ import type { SerializedLexicalNode } from 'lexical'
import type { LexicalNodeReplacement } from 'lexical'
import type { RequestContext } from 'payload'
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 { 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 { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/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
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?: {
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?: {
modifyOutputSchema: ({
currentSchema,
@@ -95,16 +221,6 @@ export type Feature = {
incomingEditorState: SerializedEditorState
siblingDoc: Record<string, unknown>
}) => Promise<void> | null
load?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
save?: ({
incomingEditorState,
}: {
incomingEditorState: SerializedEditorState
}) => SerializedEditorState
}
markdownTransformers?: Transformer[]
nodes?: Array<{
@@ -113,107 +229,66 @@ export type Feature = {
}
node: Klass<LexicalNode> | LexicalNodeReplacement
populationPromises?: Array<PopulationPromise>
type: string
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: unknown
slashMenu?: {
dynamicOptions?: ({
editor,
queryString,
}: {
editor: LexicalEditor
queryString: string
}) => SlashMenuGroup[]
options?: SlashMenuGroup[]
}
serverFeatureProps: ServerProps
}
export type FeatureProvider = {
Component: React.FC
/** 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. 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 &
export type ResolvedServerFeature<ServerProps, ClientFeatureProps> = ServerFeature<
ServerProps,
ClientFeatureProps
> &
Required<
Pick<
FeatureProvider,
'Component' | 'dependencies' | 'dependenciesPriority' | 'dependenciesSoft' | 'key'
>
FeatureProviderServer<ServerProps, ClientFeatureProps>,
'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 =
| {
// 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 }>>
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>
Component: React.FC
key: string
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>
Component: React.FC
key: string
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>
Component: React.FC
key: string
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<
Pick<ResolvedFeature, 'markdownTransformers' | 'nodes'>
export type SanitizedServerFeatures = Required<
Pick<ResolvedServerFeature<unknown, unknown>, 'markdownTransformers' | 'nodes'>
> & {
/** The node types mapped to their converters */
converters: {
@@ -221,9 +296,6 @@ export type SanitizedFeatures = Required<
}
/** The keys of all enabled features */
enabledFeatures: string[]
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
generatedTypes: {
modifyOutputSchemas: Array<
({
@@ -257,6 +329,22 @@ export type SanitizedFeatures = Required<
siblingDoc: Record<string, unknown>
}) => 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<
({
incomingEditorState,
@@ -273,14 +361,10 @@ export type SanitizedFeatures = Required<
>
}
plugins?: Array<SanitizedPlugin>
/** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
slashMenu: {
dynamicOptions: Array<
({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => 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 {
display: flex;

View File

@@ -1,16 +1,18 @@
'use client'
import type { FeatureProvider } from '@payloadcms/richtext-lexical'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { type FormFieldBase, ShimmerEffect } from '@payloadcms/ui'
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 { useClientFunctions } from '../../../ui/src/providers/ClientFunction'
import { defaultEditorLexicalConfig } from './lexical/config/defaultClient'
import { sanitizeEditorConfig } from './lexical/config/sanitize'
import { defaultEditorLexicalConfig } from './lexical/config/client/default'
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
const RichTextEditor = lazy(() => import('./Field'))
@@ -27,20 +29,19 @@ export const RichTextField: React.FC<
const clientFunctions = useClientFunctions()
const [hasLoadedFeatures, setHasLoadedFeatures] = useState(false)
const [featureProviders, setFeatureProviders] = useState<FeatureProvider[]>([])
const [featureProviders, setFeatureProviders] = useState<FeatureProviderClient<unknown>[]>([])
const finalSanitizedEditorConfig: SanitizedEditorConfig = sanitizeEditorConfig({
features: [],
lexical: lexicalEditorConfig
? () => Promise.resolve(lexicalEditorConfig)
: () => Promise.resolve(defaultEditorLexicalConfig),
})
const [finalSanitizedEditorConfig, setFinalSanitizedEditorConfig] =
useState<SanitizedClientEditorConfig>(null)
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(() => {
if (!hasLoadedFeatures) {
const featureProvidersLocal: FeatureProvider[] = []
const featureProvidersLocal: FeatureProviderClient<unknown>[] = []
Object.entries(clientFunctions).forEach(([key, plugin]) => {
if (key.startsWith(`lexicalFeature.${schemaPath}.`)) {
@@ -51,30 +52,56 @@ export const RichTextField: React.FC<
if (featureProvidersLocal.length === featureProviderComponents.length) {
setFeatureProviders(featureProvidersLocal)
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) {
return (
<React.Fragment>
{Array.isArray(featureProviderComponents) &&
featureProviderComponents.map((FeatureProvider, i) => {
return <React.Fragment key={i}>{FeatureProvider}</React.Fragment>
featureProviderComponents.map((FeatureProvider) => {
return (
<React.Fragment key={FeatureProvider.key}>
{FeatureProvider.ClientComponent}
</React.Fragment>
)
})}
</React.Fragment>
)
}
const features = clientFunctions
console.log('clientFunctions', features['lexicalFeature.posts.richText.paragraph'])
//features['lexicalFeature.posts.richText.paragraph']()
return (
<Suspense fallback={<ShimmerEffect height="35vh" />}>
{finalSanitizedEditorConfig && (
<RichTextEditor {...props} editorConfig={finalSanitizedEditorConfig} />
)}
</Suspense>
)
}

View File

@@ -1,5 +1,4 @@
import * as React from 'react'
import { useMemo } from 'react'
import type { SanitizedPlugin } from '../features/types'
@@ -7,31 +6,9 @@ export const EditorPlugin: React.FC<{
anchorElem?: HTMLDivElement
plugin: SanitizedPlugin
}> = ({ 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') {
return (
Component && (
<React.Suspense>
<Component anchorElem={anchorElem} />
</React.Suspense>
)
)
return plugin.Component && <plugin.Component anchorElem={anchorElem} />
}
return (
Component && (
<React.Suspense>
<Component />
</React.Suspense>
)
)
return plugin.Component && <plugin.Component />
}

View File

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

View File

@@ -1,21 +1,25 @@
'use client'
import type { InitialConfigType } from '@lexical/react/LexicalComposer'
import type { FormFieldBase } from '@payloadcms/ui'
import type { EditorState, SerializedEditorState } from 'lexical'
import type { LexicalEditor } from 'lexical'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import * as React from 'react'
import type { FieldProps } from '../../types'
import type { SanitizedEditorConfig } from './config/types'
import type { SanitizedClientEditorConfig } from './config/types'
import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor'
import { EditorConfigProvider } from './config/EditorConfigProvider'
import { EditorConfigProvider } from './config/client/EditorConfigProvider'
import { getEnabledNodes } from './nodes'
export type LexicalProviderProps = {
editorConfig: SanitizedEditorConfig
fieldProps: FieldProps
editorConfig: SanitizedClientEditorConfig
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
path: string
readOnly: boolean
@@ -27,21 +31,19 @@ export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
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(() => {
void editorConfig.lexical().then((lexicalConfig: LexicalEditorConfig) => {
const newInitialConfig: InitialConfigType = {
editable: readOnly !== true,
editorState: value != null ? JSON.stringify(value) : undefined,
namespace: lexicalConfig.namespace,
namespace: editorConfig.lexical.namespace,
nodes: [...getEnabledNodes({ editorConfig })],
onError: (error: Error) => {
throw error
},
theme: lexicalConfig.theme,
theme: editorConfig.lexical.theme,
}
setInitialConfig(newInitialConfig)
})
}, [editorConfig, readOnly, value])
if (editorConfig?.features?.hooks?.load?.length) {

View File

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

View File

@@ -1,6 +1,7 @@
'use client'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import { LexicalEditorTheme } from '../theme/EditorTheme'
import { LexicalEditorTheme } from '../../theme/EditorTheme'
export const defaultEditorLexicalConfig: LexicalEditorConfig = {
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'
import type { EditorConfig, SanitizedEditorConfig } from './types'
'use client'
import { loadFeatures } from './loader'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeatures => {
const sanitized: SanitizedFeatures = {
converters: {
html: [],
},
import type { ResolvedClientFeatureMap, SanitizedClientFeatures } from '../../../features/types'
import type { SanitizedClientEditorConfig } from '../types'
export const sanitizeClientFeatures = (
features: ResolvedClientFeatureMap,
): SanitizedClientFeatures => {
const sanitized: SanitizedClientFeatures = {
enabledFeatures: [],
floatingSelectToolbar: {
sections: [],
},
generatedTypes: {
modifyOutputSchemas: [],
},
hooks: {
afterReadPromises: [],
load: [],
save: [],
},
markdownTransformers: [],
nodes: [],
plugins: [],
populationPromises: new Map(),
slashMenu: {
dynamicOptions: [],
groupsWithOptions: [],
},
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.hooks?.load?.length) {
sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load)
}
@@ -51,17 +42,6 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
if (feature.nodes?.length) {
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) {
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) {
for (const section of feature.floatingSelectToolbar.sections) {
@@ -169,14 +144,13 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
return sanitized
}
export function sanitizeEditorConfig(editorConfig: EditorConfig): SanitizedEditorConfig {
const resolvedFeatureMap = loadFeatures({
unSanitizedEditorConfig: editorConfig,
})
export function sanitizeClientEditorConfig(
lexical: LexicalEditorConfig,
resolvedClientFeatureMap: ResolvedClientFeatureMap,
): SanitizedClientEditorConfig {
return {
features: sanitizeFeatures(resolvedFeatureMap),
lexical: editorConfig.lexical,
resolvedFeatureMap: resolvedFeatureMap,
features: sanitizeClientFeatures(resolvedClientFeatureMap),
lexical,
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 { EditorConfig } from './types'
import type {
FeatureProviderServer,
ResolvedServerFeatureMap,
ServerFeatureProviderMap,
} from '../../../features/types'
import type { ServerEditorConfig } from '../types'
type DependencyGraph = {
[key: string]: {
dependencies: string[]
dependenciesPriority: string[]
dependenciesSoft: string[]
featureProvider: FeatureProvider
featureProvider: FeatureProviderServer<unknown, unknown>
}
}
function createDependencyGraph(featureProviders: FeatureProvider[]): DependencyGraph {
function createDependencyGraph(
featureProviders: FeatureProviderServer<unknown, unknown>[],
): DependencyGraph {
const graph: DependencyGraph = {}
for (const fp of featureProviders) {
graph[fp.key] = {
@@ -23,10 +29,12 @@ function createDependencyGraph(featureProviders: FeatureProvider[]): DependencyG
return graph
}
function topologicallySortFeatures(featureProviders: FeatureProvider[]): FeatureProvider[] {
function topologicallySortFeatures(
featureProviders: FeatureProviderServer<unknown, unknown>[],
): FeatureProviderServer<unknown, unknown>[] {
const graph = createDependencyGraph(featureProviders)
const visited: { [key: string]: boolean } = {}
const stack: FeatureProvider[] = []
const stack: FeatureProviderServer<unknown, unknown>[] = []
for (const key in graph) {
if (!visited[key]) {
@@ -41,7 +49,7 @@ function visit(
graph: DependencyGraph,
key: string,
visited: { [key: string]: boolean },
stack: FeatureProvider[],
stack: FeatureProviderServer<unknown, unknown>[],
currentPath: string[] = [],
) {
if (!graph[key]) {
@@ -90,16 +98,16 @@ function visit(
}
export function sortFeaturesForOptimalLoading(
featureProviders: FeatureProvider[],
): FeatureProvider[] {
featureProviders: FeatureProviderServer<unknown, unknown>[],
): FeatureProviderServer<unknown, unknown>[] {
return topologicallySortFeatures(featureProviders)
}
export function loadFeatures({
unSanitizedEditorConfig,
}: {
unSanitizedEditorConfig: EditorConfig
}): ResolvedFeatureMap {
unSanitizedEditorConfig: ServerEditorConfig
}): ResolvedServerFeatureMap {
// First remove all duplicate features. The LAST feature with a given key wins.
unSanitizedEditorConfig.features = unSanitizedEditorConfig.features
.reverse()
@@ -109,15 +117,18 @@ export function loadFeatures({
})
.reverse()
const featureProviderMap: FeatureProviderMap = new Map(
unSanitizedEditorConfig.features.map((f) => [f.key, f] as [string, FeatureProvider]),
)
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
let loaded = 0
for (const featureProvider of unSanitizedEditorConfig.features) {
if (!featureProvider.key) {
throw new Error(
@@ -163,12 +174,14 @@ export function loadFeatures({
})
resolvedFeatures.set(featureProvider.key, {
...feature,
Component: featureProvider.Component,
dependencies: featureProvider.dependencies,
dependenciesPriority: featureProvider.dependenciesPriority,
dependenciesSoft: featureProvider.dependenciesSoft,
key: featureProvider.key,
order: loaded,
})
loaded++
}
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 { FeatureProvider, ResolvedFeatureMap, SanitizedFeatures } from '../../features/types'
import type {
FeatureProviderClient,
FeatureProviderServer,
ResolvedClientFeatureMap,
ResolvedServerFeatureMap,
SanitizedClientFeatures,
SanitizedServerFeatures,
} from '../../features/types'
export type EditorConfig = {
features: FeatureProvider[]
lexical?: () => Promise<LexicalEditorConfig>
export type ServerEditorConfig = {
features: FeatureProviderServer<unknown, unknown>[]
lexical?: LexicalEditorConfig
}
export type SanitizedEditorConfig = {
features: SanitizedFeatures
lexical: () => Promise<LexicalEditorConfig>
resolvedFeatureMap: ResolvedFeatureMap
export type SanitizedServerEditorConfig = {
features: SanitizedServerFeatures
lexical: LexicalEditorConfig
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 { LexicalNodeReplacement } from 'lexical'
import type { SanitizedEditorConfig } from '../config/types'
import type { SanitizedClientEditorConfig, SanitizedServerEditorConfig } from '../config/types'
export function getEnabledNodes({
editorConfig,
}: {
editorConfig: SanitizedEditorConfig
editorConfig: SanitizedClientEditorConfig | SanitizedServerEditorConfig
}): 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 { $getSelection } from 'lexical'
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 type { FloatingToolbarSectionEntry } from '../types'
@@ -24,7 +24,7 @@ export function DropDownItem({
children: React.ReactNode
entry: FloatingToolbarSectionEntry
title?: string
}): JSX.Element {
}): React.ReactNode {
const [editor] = useLexicalComposerContext()
const [enabled, setEnabled] = useState<boolean>(true)
const [active, setActive] = useState<boolean>(false)
@@ -209,7 +209,7 @@ export function DropDown({
children: ReactNode
disabled?: boolean
stopCloseOnClickSelf?: boolean
}): JSX.Element {
}): React.ReactNode {
const dropDownRef = useRef<HTMLDivElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null)
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 {
display: flex;

View File

@@ -1,5 +1,5 @@
'use client'
import React, { useMemo } from 'react'
import React from 'react'
const baseClass = 'floating-select-toolbar-popup__dropdown'
@@ -19,43 +19,17 @@ export const ToolbarEntry = ({
editor: LexicalEditor
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) {
return (
Component && (
<React.Suspense>
<Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
</React.Suspense>
entry?.Component && (
<entry.Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />
)
)
}
return (
<DropDownItem entry={entry} key={entry.key}>
{ChildComponent && (
<React.Suspense>
<ChildComponent />
</React.Suspense>
)}
{entry?.ChildComponent && <entry.ChildComponent />}
<span className="text">{entry.label}</span>
</DropDownItem>
)

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ import type React from 'react'
export type FloatingToolbarSection =
| {
ChildComponent?: () => Promise<React.FC>
ChildComponent?: React.FC
entries: Array<FloatingToolbarSectionEntry>
key: string
order?: number
@@ -17,15 +17,13 @@ export type FloatingToolbarSection =
}
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 */
Component?: () => Promise<
React.FC<{
Component?: React.FC<{
anchorElem: HTMLElement
editor: LexicalEditor
entry: FloatingToolbarSectionEntry
}>
>
isActive?: ({ editor, selection }: { editor: LexicalEditor; selection: BaseSelection }) => boolean
isEnabled?: ({
editor,

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,23 +1,104 @@
import type { RichTextAdapter } from 'payload/types'
import { mapFields } from '@payloadcms/ui/utilities'
import { sanitizeFields } from 'payload/config'
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 =
(args: { resolvedFeatureMap: ResolvedFeatureMap }): RichTextAdapter['generateComponentMap'] =>
({ config }) => {
(args: {
resolvedFeatureMap: ResolvedServerFeatureMap
}): RichTextAdapter['generateComponentMap'] =>
({ config, schemaPath }) => {
const validRelationships = config.collections.map((c) => c.slug) || []
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()) {
console.log('key', key)
const resolvedFeature = args.resolvedFeatureMap.get(key)
const Component = resolvedFeature.Component
componentMap.set(`feature.${key}`, <Component />)
componentMap.set(
`features`,
resolvedFeatureMapArray.map(([featureKey, resolvedFeature]) => {
const ClientComponent = resolvedFeature.ClientComponent
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 />)
}
}
}
console.log('componentMaaap', componentMap)
/**
* 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
}),
)
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 type { FeatureProvider, ResolvedFeatureMap } from './field/features/types'
import type { EditorConfig, SanitizedEditorConfig } from './field/lexical/config/types'
import type { FeatureProviderServer, ResolvedServerFeatureMap } from './field/features/types'
import type { SanitizedServerEditorConfig } from './field/lexical/config/types'
import type { AdapterProps } from './types'
import {
defaultEditorConfig,
defaultEditorFeatures,
defaultSanitizedEditorConfig,
} from './field/lexical/config/default'
import { loadFeatures } from './field/lexical/config/loader'
import { sanitizeFeatures } from './field/lexical/config/sanitize'
defaultSanitizedServerEditorConfig,
} from './field/lexical/config/server/default'
import { loadFeatures } from './field/lexical/config/server/loader'
import { sanitizeServerFeatures } from './field/lexical/config/server/sanitize'
import { cloneDeep } from './field/lexical/utils/cloneDeep'
import { getGenerateComponentMap } from './generateComponentMap'
import { getGenerateSchemaMap } from './generateSchemaMap'
import { richTextRelationshipPromise } from './populate/richTextRelationshipPromise'
import { richTextValidateHOC } from './validate'
export type LexicalEditorProps = {
features?:
| (({ defaultFeatures }: { defaultFeatures: FeatureProvider[] }) => FeatureProvider[])
| FeatureProvider[]
| (({
defaultFeatures,
}: {
defaultFeatures: FeatureProviderServer<unknown, unknown>[]
}) => FeatureProviderServer<unknown, unknown>[])
| FeatureProviderServer<unknown, unknown>[]
lexical?: LexicalEditorConfig
}
// eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents
export type LexicalRichTextAdapter = RichTextAdapter<SerializedEditorState, AdapterProps, any> & {
editorConfig: SanitizedEditorConfig
editorConfig: SanitizedServerEditorConfig
}
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)) {
finalSanitizedEditorConfig = cloneDeep(defaultSanitizedEditorConfig)
finalSanitizedEditorConfig = cloneDeep(defaultSanitizedServerEditorConfig)
resolvedFeatureMap = finalSanitizedEditorConfig.resolvedFeatureMap
} else {
let features: FeatureProvider[] =
let features: FeatureProviderServer<unknown, unknown>[] =
props.features && typeof props.features === 'function'
? props.features({ defaultFeatures: cloneDeep(defaultEditorFeatures) })
: (props.features as FeatureProvider[])
: (props.features as FeatureProviderServer<unknown, unknown>[])
if (!features) {
features = cloneDeep(defaultEditorFeatures)
}
@@ -53,14 +59,14 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
resolvedFeatureMap = loadFeatures({
unSanitizedEditorConfig: {
features,
lexical: lexical ? () => Promise.resolve(lexical) : defaultEditorConfig.lexical,
lexical: lexical ? lexical : defaultEditorConfig.lexical,
},
})
finalSanitizedEditorConfig = {
features: sanitizeFeatures(resolvedFeatureMap),
lexical: lexical ? () => Promise.resolve(lexical) : defaultEditorConfig.lexical,
resolvedFeatureMap: resolvedFeatureMap,
features: sanitizeServerFeatures(resolvedFeatureMap),
lexical: lexical ? lexical : defaultEditorConfig.lexical,
resolvedFeatureMap,
}
}
@@ -113,7 +119,10 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
},
editorConfig: finalSanitizedEditorConfig,
generateComponentMap: getGenerateComponentMap({
resolvedFeatureMap: resolvedFeatureMap,
resolvedFeatureMap,
}),
generateSchemaMap: getGenerateSchemaMap({
resolvedFeatureMap,
}),
outputSchema: ({ field, interfaceNameDefinitions, isRequired }) => {
let outputSchema: JSONSchema4 = {
@@ -234,23 +243,6 @@ export {
} from './field/features/Blocks/nodes/BlocksNode'
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 { RelationshipFeature } from './field/features/Relationship'
export {
@@ -260,6 +252,7 @@ export {
RelationshipNode,
type SerializedRelationshipNode,
} from './field/features/Relationship/nodes/RelationshipNode'
export { UploadFeature } from './field/features/Upload'
export type { UploadFeatureProps } from './field/features/Upload'
export type { RawUploadPayload } from './field/features/Upload/nodes/UploadNode'
@@ -270,7 +263,7 @@ export {
type UploadData,
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 {
HTMLConverterFeature,
@@ -287,19 +280,36 @@ export { defaultHTMLConverters } from './field/features/converters/html/converte
export type { HTMLConverter } from './field/features/converters/html/converter/types'
export { consolidateHTMLConverters } from './field/features/converters/html/field'
export { lexicalHTML } from './field/features/converters/html/field'
export { TestRecorderFeature } from './field/features/debug/TestRecorder'
export { TreeViewFeature } from './field/features/debug/TreeView'
export { BoldTextFeature } from './field/features/format/Bold'
export { InlineCodeTextFeature } from './field/features/format/InlineCode'
export { ItalicTextFeature } from './field/features/format/Italic'
export { SectionWithEntries as FormatSectionWithEntries } from './field/features/format/common/floatingSelectToolbarSection'
export { StrikethroughTextFeature } from './field/features/format/strikethrough'
export { SubscriptTextFeature } from './field/features/format/subscript'
export { SuperscriptTextFeature } from './field/features/format/superscript'
export { UnderlineTextFeature } from './field/features/format/underline'
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 { OrderedListFeature } from './field/features/lists/OrderedList'
export { UnorderedListFeature } from './field/features/lists/UnorderedList'
@@ -330,26 +340,50 @@ export type {
} from './field/features/migrations/SlateToLexical/converter/types'
export type {
Feature,
FeatureProvider,
FeatureProviderMap,
ClientFeature,
ClientFeatureProviderMap,
FeatureProviderClient,
FeatureProviderProviderClient,
FeatureProviderProviderServer,
FeatureProviderServer,
NodeValidation,
PopulationPromise,
ResolvedFeature,
ResolvedFeatureMap,
SanitizedFeatures,
ResolvedClientFeature,
ResolvedClientFeatureMap,
ResolvedServerFeature,
ResolvedServerFeatureMap,
SanitizedClientFeatures,
SanitizedPlugin,
SanitizedServerFeatures,
ServerFeature,
ServerFeatureProviderMap,
} from './field/features/types'
export {
EditorConfigProvider,
useEditorConfigContext,
} from './field/lexical/config/EditorConfigProvider'
} from './field/lexical/config/client/EditorConfigProvider'
export {
sanitizeClientEditorConfig,
sanitizeClientFeatures,
} from './field/lexical/config/client/sanitize'
export {
defaultEditorConfig,
defaultEditorFeatures,
defaultSanitizedEditorConfig,
} from './field/lexical/config/default'
export { loadFeatures, sortFeaturesForOptimalLoading } from './field/lexical/config/loader'
export { sanitizeEditorConfig, sanitizeFeatures } from './field/lexical/config/sanitize'
defaultEditorLexicalConfig,
defaultSanitizedServerEditorConfig,
} from './field/lexical/config/server/default'
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 {
@@ -357,8 +391,6 @@ export {
type FloatingToolbarSectionEntry,
} from './field/lexical/plugins/FloatingSelectToolbar/types'
export { ENABLE_SLASH_MENU_COMMAND } from './field/lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/index'
// export SanitizedEditorConfig
export type { EditorConfig, SanitizedEditorConfig }
export type { AdapterProps }
export {

View File

@@ -2,8 +2,9 @@ import type { SerializedEditorState } from 'lexical'
import type { FieldPermissions } from 'payload/auth'
import type { FieldTypes } from 'payload/config'
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> & {
fieldTypes: FieldTypes
@@ -13,5 +14,11 @@ export type FieldProps = RichTextFieldProps<SerializedEditorState, 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'
type FeatureProviderGetter = () => FeatureProvider
export const useLexicalFeature = (key: string, feature: FeatureProviderGetter) => {
export const useLexicalFeature = (key: string, feature: FeatureProvider) => {
const { schemaPath } = useFieldPath()
useAddClientFunction(`lexicalFeature.${schemaPath}.${key}`, feature)

View File

@@ -21,5 +21,5 @@
"src/**/*.spec.tsx"
],
"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 { richTextComponentMap } = fieldProps
const fieldMap = richTextComponentMap.get(fieldMapPath)
const fieldMap = richTextComponentMap.get(linkFieldsSchemaPath)
const editor = useSlate()
const config = useConfig()
@@ -113,7 +113,7 @@ export const LinkElement = () => {
setInitialState(state)
}
awaitInitialState()
void awaitInitialState()
}, [renderModal, element, user, locale, t, getDocPreferences, config, id, fieldMapPath])
return (

View File

@@ -5,8 +5,7 @@ import type { Editor } from 'slate'
import { useSlatePlugin } from '../../../utilities/useSlatePlugin'
export const WithLinks: React.FC = () => {
useSlatePlugin('withLinks', (incomingEditor: Editor): Editor => {
const plugin = (incomingEditor: Editor): Editor => {
const editor = incomingEditor
const { isInline } = editor
@@ -19,6 +18,9 @@ export const WithLinks: React.FC = () => {
}
return editor
})
}
export const WithLinks: React.FC = () => {
useSlatePlugin('withLinks', plugin)
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 FieldDescription } from '../forms/FieldDescription'
export { FieldPathProvider } from '../forms/FieldPathProvider'
export { useFieldPath } from '../forms/FieldPathProvider'
export { default as Form } from '../forms/Form'
export { default as buildInitialState } from '../forms/Form'
export {

View File

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

3
pnpm-lock.yaml generated
View File

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

View File

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