feat(richtext-lexical): HTML Serializer (#3685)

* chore(richtext-lexical): add jsdocs for afterReadPromise in GraphQL

* feat(richtext-lexical): HTML Serializer

* chore(richtext-lexical): adjust comment

* chore(richtext-lexical): change the way the html serializer works

* chore: working html converter field, improve various exports

* feat: link and heading html serializers

* fix: populationPromises not being added properly

* feat: allow html serializers to be async

* feat: upload html serializer

* feat: text format => html

* feat: lists => html

* feat: Quote => html

* chore: improve Checklist => html conversion, by passing in the full parent to converters
This commit is contained in:
Alessio Gravili
2023-10-21 14:37:59 +02:00
committed by GitHub
parent f6adbae0c7
commit 0af36af16c
47 changed files with 769 additions and 183 deletions

View File

@@ -12,7 +12,17 @@ export type RichTextFieldProps<Value extends object, AdapterProps> = Omit<
export type RichTextAdapter<Value extends object = object, AdapterProps = any> = {
CellComponent: React.FC<CellComponentProps<RichTextField<Value, AdapterProps>>>
FieldComponent: React.FC<RichTextFieldProps<Value, AdapterProps>>
afterReadPromise?: (data: {
afterReadPromise?: ({
field,
incomingEditorState,
siblingDoc,
}: {
field: RichTextField<Value, AdapterProps>
incomingEditorState: Value
siblingDoc: Record<string, unknown>
}) => Promise<void> | null
populationPromise?: (data: {
currentDepth?: number
depth: number
field: RichTextField<Value, AdapterProps>

View File

@@ -90,12 +90,17 @@ export default joi.object({
debug: joi.boolean(),
defaultDepth: joi.number().min(0).max(30),
defaultMaxTextLength: joi.number(),
editor: joi.object().required().keys({
CellComponent: component.required(),
FieldComponent: component.required(),
afterReadPromise: joi.func().required(),
validate: joi.func().required(),
}),
editor: joi
.object()
.required()
.keys({
CellComponent: component.required(),
FieldComponent: component.required(),
afterReadPromise: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),
email: joi.object(),
endpoints: endpointsSchema,
express: joi.object().keys({

View File

View File

@@ -354,12 +354,16 @@ export const richText = baseField.keys({
name: joi.string().required(),
admin: baseAdminFields.default(),
defaultValue: joi.alternatives().try(joi.array().items(joi.object()), joi.func()),
editor: joi.object().keys({
CellComponent: componentSchema.required(),
FieldComponent: componentSchema.required(),
afterReadPromise: joi.func().required(),
validate: joi.func().required(),
}),
editor: joi
.object()
.keys({
CellComponent: componentSchema.required(),
FieldComponent: componentSchema.required(),
afterReadPromise: joi.func().optional(),
populationPromise: joi.func().optional(),
validate: joi.func().required(),
})
.unknown(),
type: joi.string().valid('richText').required(),
})

View File

@@ -135,8 +135,9 @@ export const promise = async ({
case 'richText': {
const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) {
const afterReadPromise = editor.afterReadPromise({
// This is run here AND in the GraphQL Resolver
if (editor?.populationPromise) {
const populationPromise = editor.populationPromise({
currentDepth,
depth,
field,
@@ -146,6 +147,19 @@ export const promise = async ({
siblingDoc,
})
if (populationPromise) {
populationPromises.push(populationPromise)
}
}
// This is only run here, independent of depth
if (editor?.afterReadPromise) {
const afterReadPromise = editor?.afterReadPromise({
field,
incomingEditorState: siblingDoc[field.name] as object,
siblingDoc,
})
if (afterReadPromise) {
populationPromises.push(afterReadPromise)
}

View File

@@ -429,8 +429,13 @@ function buildObjectType({
if (typeof args.depth !== 'undefined') depth = args.depth
const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) {
await editor?.afterReadPromise({
// RichText fields have their own depth argument in GraphQL.
// This is why the populationPromise (which populates richtext fields like uploads and relationships)
// is run here again, with the provided depth.
// In the graphql find.ts resolver, the depth is then hard-coded to 0.
// Effectively, this means that the populationPromise for GraphQL is only run here, and not in the find.ts resolver / normal population promise.
if (editor?.populationPromise) {
await editor?.populationPromise({
depth,
field,
req: context.req,

View File

@@ -1,17 +1,21 @@
import { $createQuoteNode, QuoteNode } from '@lexical/rich-text'
import type { SerializedHeadingNode, SerializedQuoteNode } from '@lexical/rich-text'
import { $createQuoteNode, HeadingNode, QuoteNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection } from 'lexical'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { BlockquoteIcon } from '../../lexical/ui/icons/Blockquote'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
import { MarkdownTransformer } from './markdownTransformer'
export const BlockQuoteFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
floatingSelectToolbar: {
sections: [
@@ -38,6 +42,23 @@ export const BlockQuoteFeature = (): FeatureProvider => {
markdownTransformers: [MarkdownTransformer],
nodes: [
{
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
return `<blockquote>${childrenText}</blockquote>`
},
nodeTypes: [QuoteNode.getType()],
} as HTMLConverter<SerializedQuoteNode>,
},
node: QuoteNode,
type: QuoteNode.getType(),
},

View File

@@ -7,10 +7,10 @@ import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { BlockIcon } from '../../lexical/ui/icons/Block'
import { blockAfterReadPromiseHOC } from './afterReadPromise'
import './index.scss'
import { BlockNode } from './nodes/BlocksNode'
import { BlocksPlugin, INSERT_BLOCK_COMMAND } from './plugin'
import { blockPopulationPromiseHOC } from './populationPromise'
import { blockValidationHOC } from './validate'
export type BlocksFeatureProps = {
@@ -38,12 +38,12 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
})
}
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
nodes: [
{
afterReadPromises: [blockAfterReadPromiseHOC(props)],
node: BlockNode,
populationPromises: [blockPopulationPromiseHOC(props)],
type: BlockNode.getType(),
validations: [blockValidationHOC(props)],
},

View File

@@ -3,24 +3,22 @@ import type { Block } from 'payload/types'
import { sanitizeFields } from 'payload/config'
import type { BlocksFeatureProps } from '.'
import type { AfterReadPromise } from '../types'
import type { PopulationPromise } from '../types'
import type { SerializedBlockNode } from './nodes/BlocksNode'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const blockAfterReadPromiseHOC = (
export const blockPopulationPromiseHOC = (
props: BlocksFeatureProps,
): AfterReadPromise<SerializedBlockNode> => {
const blockAfterReadPromise: AfterReadPromise<SerializedBlockNode> = ({
afterReadPromises,
): PopulationPromise<SerializedBlockNode> => {
const blockPopulationPromise: PopulationPromise<SerializedBlockNode> = ({
currentDepth,
depth,
field,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) => {
const blocks: Block[] = props.blocks
const blockFieldData = node.fields.data
@@ -45,12 +43,12 @@ export const blockAfterReadPromiseHOC = (
}
recurseNestedFields({
afterReadPromises,
currentDepth,
data: blockFieldData,
depth,
fields: block.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -61,5 +59,5 @@ export const blockAfterReadPromiseHOC = (
return promises
}
return blockAfterReadPromise
return blockPopulationPromise
}

View File

@@ -1,10 +1,11 @@
import type { HeadingTagType } from '@lexical/rich-text'
import type { LexicalEditor } from 'lexical'
import type { HeadingTagType, SerializedHeadingNode } from '@lexical/rich-text'
import type React from 'react'
import { $createHeadingNode, HeadingNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection, DEPRECATED_$isGridSelection } from 'lexical'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
@@ -15,9 +16,10 @@ import { H4Icon } from '../../lexical/ui/icons/H4'
import { H5Icon } from '../../lexical/ui/icons/H5'
import { H6Icon } from '../../lexical/ui/icons/H6'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
import { MarkdownTransformer } from './markdownTransformer'
const setHeading = (editor: LexicalEditor, headingSize: HeadingTagType) => {
const setHeading = (headingSize: HeadingTagType) => {
const selection = $getSelection()
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize))
@@ -41,7 +43,7 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
floatingSelectToolbar: {
sections: [
@@ -49,12 +51,12 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
TextDropdownSectionWithEntries([
{
ChildComponent: HeadingToIconMap[headingSize],
isActive: ({ editor, selection }) => false,
isActive: () => false,
key: headingSize,
label: `Heading ${headingSize.charAt(1)}`,
onClick: ({ editor }) => {
editor.update(() => {
setHeading(editor, headingSize)
setHeading(headingSize)
})
},
order: i + 2,
@@ -64,7 +66,29 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
],
},
markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)],
nodes: [{ node: HeadingNode, type: HeadingNode.getType() }],
nodes: [
{
converters: {
html: {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
return '<' + node?.tag + '>' + childrenText + '</' + node?.tag + '>'
},
nodeTypes: [HeadingNode.getType()],
} as HTMLConverter<SerializedHeadingNode>,
},
node: HeadingNode,
type: HeadingNode.getType(),
},
],
props,
slashMenu: {
options: [
@@ -74,8 +98,8 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
new SlashMenuOption(`Heading ${headingSize.charAt(1)}`, {
Icon: HeadingToIconMap[headingSize],
keywords: ['heading', headingSize],
onSelect: ({ editor }) => {
setHeading(editor, headingSize)
onSelect: () => {
setHeading(headingSize)
},
}),
],

View File

@@ -7,13 +7,15 @@ import { $findMatchingParent } from '@lexical/utils'
import { $getSelection, $isRangeSelection } from 'lexical'
import { withMergedProps } from 'payload/utilities'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import type { LinkFields } from './nodes/LinkNode'
import type { SerializedAutoLinkNode } from './nodes/AutoLinkNode'
import type { LinkFields, SerializedLinkNode } from './nodes/LinkNode'
import { LinkIcon } from '../../lexical/ui/icons/Link'
import { getSelectedNode } from '../../lexical/utils/getSelectedNode'
import { FeaturesSectionWithEntries } from '../common/floatingSelectToolbarFeaturesButtonsSection'
import { linkAfterReadPromiseHOC } from './afterReadPromise'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
import './index.scss'
import { AutoLinkNode } from './nodes/AutoLinkNode'
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode'
@@ -21,6 +23,7 @@ import { AutoLinkPlugin } from './plugins/autoLink'
import { FloatingLinkEditorPlugin } from './plugins/floatingLinkEditor'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './plugins/floatingLinkEditor/LinkEditor'
import { LinkPlugin } from './plugins/link'
import { linkPopulationPromiseHOC } from './populationPromise'
export type LinkFeatureProps = {
fields?:
@@ -29,7 +32,7 @@ export type LinkFeatureProps = {
}
export const LinkFeature = (props: LinkFeatureProps): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
floatingSelectToolbar: {
sections: [
@@ -74,13 +77,58 @@ export const LinkFeature = (props: LinkFeatureProps): FeatureProvider => {
},
nodes: [
{
afterReadPromises: [linkAfterReadPromiseHOC(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?.id
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [LinkNode.getType()],
} as HTMLConverter<SerializedLinkNode>,
},
node: LinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
type: LinkNode.getType(),
// TODO: Add validation similar to upload for internal links and fields
},
{
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?.id
return `<a href="${href}"${rel}>${childrenText}</a>`
},
nodeTypes: [AutoLinkNode.getType()],
} as HTMLConverter<SerializedAutoLinkNode>,
},
node: AutoLinkNode,
populationPromises: [linkPopulationPromiseHOC(props)],
type: AutoLinkNode.getType(),
},
],

View File

@@ -1,23 +1,22 @@
import type { LinkFeatureProps } from '.'
import type { AfterReadPromise } from '../types'
import type { PopulationPromise } from '../types'
import type { SerializedLinkNode } from './nodes/LinkNode'
import { populate } from '../../../populate/populate'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const linkAfterReadPromiseHOC = (
export const linkPopulationPromiseHOC = (
props: LinkFeatureProps,
): AfterReadPromise<SerializedLinkNode> => {
const linkAfterReadPromise: AfterReadPromise<SerializedLinkNode> = ({
afterReadPromises,
): PopulationPromise<SerializedLinkNode> => {
const linkPopulationPromise: PopulationPromise<SerializedLinkNode> = ({
currentDepth,
depth,
field,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) => {
const promises: Promise<void>[] = []
@@ -43,12 +42,12 @@ export const linkAfterReadPromiseHOC = (
}
if (Array.isArray(props.fields)) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: node.fields || {},
depth,
fields: props.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -58,5 +57,5 @@ export const linkAfterReadPromiseHOC = (
return promises
}
return linkAfterReadPromise
return linkPopulationPromise
}

View File

@@ -2,20 +2,20 @@ import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { RelationshipIcon } from '../../lexical/ui/icons/Relationship'
import { relationshipAfterReadPromise } from './afterReadPromise'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss'
import { RelationshipNode } from './nodes/RelationshipNode'
import RelationshipPlugin from './plugins'
import { relationshipPopulationPromise } from './populationPromise'
export const RelationshipFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
nodes: [
{
afterReadPromises: [relationshipAfterReadPromise],
node: RelationshipNode,
populationPromises: [relationshipPopulationPromise],
type: RelationshipNode.getType(),
// TODO: Add validation similar to upload
},

View File

@@ -1,9 +1,9 @@
import type { AfterReadPromise } from '../types'
import type { PopulationPromise } from '../types'
import type { SerializedRelationshipNode } from './nodes/RelationshipNode'
import { populate } from '../../../populate/populate'
export const relationshipAfterReadPromise: AfterReadPromise<SerializedRelationshipNode> = ({
export const relationshipPopulationPromise: PopulationPromise<SerializedRelationshipNode> = ({
currentDepth,
depth,
field,

View File

@@ -1,14 +1,18 @@
import type { Field } from 'payload/types'
import payload from 'payload'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
import type { SerializedUploadNode } from './nodes/UploadNode'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { UploadIcon } from '../../lexical/ui/icons/Upload'
import { uploadAfterReadPromiseHOC } from './afterReadPromise'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss'
import { UploadNode } from './nodes/UploadNode'
import { UploadPlugin } from './plugin'
import { uploadPopulationPromiseHOC } from './populationPromise'
import { uploadValidation } from './validate'
export type UploadFeatureProps = {
@@ -21,12 +25,30 @@ export type UploadFeatureProps = {
export const UploadFeature = (props?: UploadFeatureProps): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
nodes: [
{
afterReadPromises: [uploadAfterReadPromiseHOC(props)],
converters: {
html: {
converter: async ({ node }) => {
const uploadDocument = await payload.findByID({
id: node.value.id,
collection: node.relationTo,
})
const url = (payload?.config?.serverURL || '') + uploadDocument?.url
if (!(uploadDocument?.mimeType as string)?.startsWith('image')) {
return `<a href="${url}" rel="noopener noreferrer">Upload node which is not an image</a>`
}
return `<img src="${url}" alt="${uploadDocument?.filename}" width="${uploadDocument?.width}" height="${uploadDocument?.height}"/>`
},
nodeTypes: [UploadNode.getType()],
} as HTMLConverter<SerializedUploadNode>,
},
node: UploadNode,
populationPromises: [uploadPopulationPromiseHOC(props)],
type: UploadNode.getType(),
validations: [uploadValidation()],
},

View File

@@ -1,23 +1,22 @@
import type { UploadFeatureProps } from '.'
import type { AfterReadPromise } from '../types'
import type { PopulationPromise } from '../types'
import type { SerializedUploadNode } from './nodes/UploadNode'
import { populate } from '../../../populate/populate'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const uploadAfterReadPromiseHOC = (
export const uploadPopulationPromiseHOC = (
props?: UploadFeatureProps,
): AfterReadPromise<SerializedUploadNode> => {
const uploadAfterReadPromise: AfterReadPromise<SerializedUploadNode> = ({
afterReadPromises,
): PopulationPromise<SerializedUploadNode> => {
const uploadPopulationPromise: PopulationPromise<SerializedUploadNode> = ({
currentDepth,
depth,
field,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}) => {
const promises: Promise<void>[] = []
@@ -42,12 +41,12 @@ export const uploadAfterReadPromiseHOC = (
}
if (Array.isArray(props?.collections?.[node?.relationTo]?.fields)) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: node.fields || {},
depth,
fields: props?.collections?.[node?.relationTo]?.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -59,5 +58,5 @@ export const uploadAfterReadPromiseHOC = (
return promises
}
return uploadAfterReadPromise
return uploadPopulationPromise
}

View File

@@ -0,0 +1,20 @@
import type { SerializedParagraphNode } from 'lexical'
import type { HTMLConverter } from '../types'
import { convertLexicalNodesToHTML } from '../index'
export const ParagraphHTMLConverter: HTMLConverter<SerializedParagraphNode> = {
async converter({ converters, node, parent }) {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
return `<p>${childrenText}</p>`
},
nodeTypes: ['paragraph'],
}

View File

@@ -0,0 +1,36 @@
import type { SerializedTextNode } from 'lexical'
import type { HTMLConverter } from '../types'
import { NodeFormat } from '../../../../../lexical/utils/nodeFormat'
export const TextHTMLConverter: HTMLConverter<SerializedTextNode> = {
converter({ node }) {
let text = node.text
if (node.format & NodeFormat.IS_BOLD) {
text = `<strong>${text}</strong>`
}
if (node.format & NodeFormat.IS_ITALIC) {
text = `<em>${text}</em>`
}
if (node.format & NodeFormat.IS_STRIKETHROUGH) {
text = `<span style="text-decoration: line-through">${text}</span>`
}
if (node.format & NodeFormat.IS_UNDERLINE) {
text = `<span style="text-decoration: underline">${text}</span>`
}
if (node.format & NodeFormat.IS_CODE) {
text = `<code>${text}</code>`
}
if (node.format & NodeFormat.IS_SUBSCRIPT) {
text = `<sub>${text}</sub>`
}
if (node.format & NodeFormat.IS_SUPERSCRIPT) {
text = `<sup>${text}</sup>`
}
return text
},
nodeTypes: ['text'],
}

View File

@@ -0,0 +1,6 @@
import type { HTMLConverter } from './types'
import { ParagraphHTMLConverter } from './converters/paragraph'
import { TextHTMLConverter } from './converters/text'
export const defaultHTMLConverters: HTMLConverter[] = [ParagraphHTMLConverter, TextHTMLConverter]

View File

@@ -0,0 +1,54 @@
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { HTMLConverter, SerializedLexicalNodeWithParent } from './types'
export async function convertLexicalToHTML({
converters,
data,
}: {
converters: HTMLConverter[]
data: SerializedEditorState
}): Promise<string> {
if (data?.root?.children?.length) {
return await convertLexicalNodesToHTML({
converters,
lexicalNodes: data?.root?.children,
parent: data?.root,
})
}
return ''
}
export async function convertLexicalNodesToHTML({
converters,
lexicalNodes,
parent,
}: {
converters: HTMLConverter[]
lexicalNodes: SerializedLexicalNode[]
parent: SerializedLexicalNodeWithParent
}): Promise<string> {
const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown'))
const htmlArray = await Promise.all(
lexicalNodes.map(async (node, i) => {
const converterForNode = converters.find((converter) =>
converter.nodeTypes.includes(node.type),
)
if (!converterForNode) {
if (unknownConverter) {
return unknownConverter.converter({ childIndex: i, converters, node, parent })
}
return '<span>unknown node</span>'
}
return converterForNode.converter({
childIndex: i,
converters,
node,
parent,
})
}),
)
return htmlArray.join('') || ''
}

View File

@@ -0,0 +1,20 @@
import type { SerializedLexicalNode } from 'lexical'
export type HTMLConverter<T extends SerializedLexicalNode = SerializedLexicalNode> = {
converter: ({
childIndex,
converters,
node,
parent,
}: {
childIndex: number
converters: HTMLConverter[]
node: T
parent: SerializedLexicalNodeWithParent
}) => Promise<string> | string
nodeTypes: string[]
}
export type SerializedLexicalNodeWithParent = SerializedLexicalNode & {
parent?: SerializedLexicalNode
}

View File

@@ -0,0 +1,87 @@
import type { SerializedEditorState } from 'lexical'
import type { RichTextField, TextField } from 'payload/types'
import type { LexicalRichTextAdapter } from '../../../../../index'
import type { AdapterProps } from '../../../../../types'
import type { HTMLConverter } from '../converter/types'
import type { HTMLConverterFeatureProps } from '../index'
import { cloneDeep } from '../../../../../index'
import { convertLexicalToHTML } from '../converter'
import { defaultHTMLConverters } from '../converter/defaultConverters'
type Props = {
name: string
}
export const lexicalHTML: (lexicalFieldName: string, props: Props) => TextField = (
lexicalFieldName,
props,
) => {
const { name = 'lexicalHTML' } = props
return {
name: name,
admin: {
hidden: true,
},
hooks: {
afterRead: [
async ({ collection, context, data, originalDoc, siblingData }) => {
const lexicalField: RichTextField<SerializedEditorState, AdapterProps> =
collection.fields.find(
(field) => 'name' in field && field.name === lexicalFieldName,
) as RichTextField<SerializedEditorState, AdapterProps>
const lexicalFieldData: SerializedEditorState = siblingData[lexicalFieldName]
if (!lexicalFieldData) {
return ''
}
if (!lexicalField) {
throw new Error(
'You cannot use the lexicalHTML field because the lexical field was not found',
)
}
const config = (lexicalField?.editor as LexicalRichTextAdapter)?.editorConfig
if (!config) {
throw new Error(
'The linked lexical field does not have an editorConfig. This is needed for the lexicalHTML field.',
)
}
if (!config?.resolvedFeatureMap?.has('htmlConverter')) {
throw new Error(
'You cannot use the lexicalHTML field because the htmlConverter feature was not found',
)
}
const htmlConverterFeature = config.resolvedFeatureMap.get('htmlConverter')
const htmlConverterFeatureProps: HTMLConverterFeatureProps = htmlConverterFeature.props
const defaultConvertersWithConvertersFromFeatures = cloneDeep(defaultHTMLConverters)
for (const converter of config.features.converters.html) {
defaultConvertersWithConvertersFromFeatures.push(converter)
}
const finalConverters =
htmlConverterFeatureProps?.converters &&
typeof htmlConverterFeatureProps?.converters === 'function'
? htmlConverterFeatureProps.converters({
defaultConverters: defaultConvertersWithConvertersFromFeatures,
})
: (htmlConverterFeatureProps?.converters as HTMLConverter[]) ||
defaultConvertersWithConvertersFromFeatures
return await convertLexicalToHTML({
converters: finalConverters,
data: lexicalFieldData,
})
},
],
},
type: 'text',
}
}

View File

@@ -0,0 +1,31 @@
import type { FeatureProvider } from '../../types'
import type { HTMLConverter } from './converter/types'
export type HTMLConverterFeatureProps = {
converters?:
| (({ defaultConverters }: { defaultConverters: HTMLConverter[] }) => HTMLConverter[])
| HTMLConverter[]
}
/**
* This feature only manages the converters. They are read and actually run / executed by the
* Lexical field.
*/
export const HTMLConverterFeature = (props?: HTMLConverterFeatureProps): FeatureProvider => {
if (!props) {
props = {}
}
/*const defaultConvertersWithConvertersFromFeatures = defaultConverters
defaultConvertersWithConver tersFromFeatures.set(props?
*/
return {
feature: () => {
return {
props,
}
},
key: 'htmlConverter',
}
}

View File

@@ -5,6 +5,7 @@ import type { FeatureProvider } from '../../types'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { ChecklistIcon } from '../../../lexical/ui/icons/Checklist'
import { ListHTMLConverter, ListItemHTMLConverter } from '../htmlConverter'
import { CHECK_LIST } from './markdownTransformers'
// 345
@@ -19,10 +20,16 @@ export const CheckListFeature = (): FeatureProvider => {
? []
: [
{
converters: {
html: ListHTMLConverter,
},
node: ListNode,
type: ListNode.getType(),
},
{
converters: {
html: ListItemHTMLConverter,
},
node: ListItemNode,
type: ListItemNode.getType(),
},

View File

@@ -5,6 +5,7 @@ import type { FeatureProvider } from '../../types'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { OrderedListIcon } from '../../../lexical/ui/icons/OrderedList'
import { ListHTMLConverter, ListItemHTMLConverter } from '../htmlConverter'
import { ORDERED_LIST } from './markdownTransformer'
export const OrderedListFeature = (): FeatureProvider => {
@@ -16,10 +17,19 @@ export const OrderedListFeature = (): FeatureProvider => {
? []
: [
{
converters: {
html: ListHTMLConverter,
},
node: ListNode,
type: ListNode.getType(),
},
{ node: ListItemNode, type: ListItemNode.getType() },
{
converters: {
html: ListItemHTMLConverter,
},
node: ListItemNode,
type: ListItemNode.getType(),
},
],
plugins: featureProviderMap.has('unorderedList')
? []

View File

@@ -5,6 +5,7 @@ import type { FeatureProvider } from '../../types'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { UnorderedListIcon } from '../../../lexical/ui/icons/UnorderedList'
import { ListHTMLConverter, ListItemHTMLConverter } from '../htmlConverter'
import { UNORDERED_LIST } from './markdownTransformer'
export const UnoderedListFeature = (): FeatureProvider => {
@@ -14,10 +15,16 @@ export const UnoderedListFeature = (): FeatureProvider => {
markdownTransformers: [UNORDERED_LIST],
nodes: [
{
converters: {
html: ListHTMLConverter,
},
node: ListNode,
type: ListNode.getType(),
},
{
converters: {
html: ListItemHTMLConverter,
},
node: ListItemNode,
type: ListItemNode.getType(),
},

View File

@@ -0,0 +1,53 @@
import type { SerializedListItemNode, SerializedListNode } from '@lexical/list'
import { ListItemNode, ListNode } from '@lexical/list'
import type { HTMLConverter } from '../converters/html/converter/types'
import { convertLexicalNodesToHTML } from '../converters/html/converter'
export const ListHTMLConverter: HTMLConverter<SerializedListNode> = {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
return `<${node?.tag} class="${node?.listType}">${childrenText}</${node?.tag}>`
},
nodeTypes: [ListNode.getType()],
}
export const ListItemHTMLConverter: HTMLConverter<SerializedListItemNode> = {
converter: async ({ converters, node, parent }) => {
const childrenText = await convertLexicalNodesToHTML({
converters,
lexicalNodes: node.children,
parent: {
...node,
parent,
},
})
if ('listType' in parent && parent?.listType === 'check') {
return `<li aria-checked=${node.checked ? 'true' : 'false'} class="${
'list-item-checkbox' + node.checked
? 'list-item-checkbox-checked'
: 'list-item-checkbox-unchecked'
}"
role="checkbox"
tabIndex=${-1}
value=${node?.value}
>
{serializedChildren}
</li>`
} else {
return `<li value=${node?.value}>${childrenText}</li>`
}
},
nodeTypes: [ListItemNode.getType()],
}

View File

@@ -4,7 +4,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const HeadingConverter: SlateNodeConverter = {
export const SlateHeadingConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({

View File

@@ -4,7 +4,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const IndentConverter: SlateNodeConverter = {
export const SlateIndentConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
console.log('slateToLexical > IndentConverter > converter', JSON.stringify(slateNode, null, 2))
const convertChildren = (node: any, indentLevel: number = 0): SerializedLexicalNode => {

View File

@@ -3,7 +3,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const LinkConverter: SlateNodeConverter = {
export const SlateLinkConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({

View File

@@ -4,7 +4,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const ListItemConverter: SlateNodeConverter = {
export const SlateListItemConverter: SlateNodeConverter = {
converter({ childIndex, converters, slateNode }) {
return {
checked: undefined,

View File

@@ -4,7 +4,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const OrderedListConverter: SlateNodeConverter = {
export const SlateOrderedListConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({

View File

@@ -1,7 +1,7 @@
import type { SerializedRelationshipNode } from '../../../../../..'
import type { SlateNodeConverter } from '../types'
export const RelationshipConverter: SlateNodeConverter = {
export const SlateRelationshipConverter: SlateNodeConverter = {
converter({ slateNode }) {
return {
format: '',

View File

@@ -3,7 +3,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const UnknownConverter: SlateNodeConverter = {
export const SlateUnknownConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({

View File

@@ -4,7 +4,7 @@ import type { SlateNodeConverter } from '../types'
import { convertSlateNodesToLexical } from '..'
export const UnorderedListConverter: SlateNodeConverter = {
export const SlateUnorderedListConverter: SlateNodeConverter = {
converter({ converters, slateNode }) {
return {
children: convertSlateNodesToLexical({

View File

@@ -1,7 +1,7 @@
import type { SerializedUploadNode } from '../../../../../..'
import type { SlateNodeConverter } from '../types'
export const UploadConverter: SlateNodeConverter = {
export const SlateUploadConverter: SlateNodeConverter = {
converter({ slateNode }) {
return {
fields: {

View File

@@ -1,23 +1,23 @@
import type { SlateNodeConverter } from './types'
import { HeadingConverter } from './converters/heading'
import { IndentConverter } from './converters/indent'
import { LinkConverter } from './converters/link'
import { ListItemConverter } from './converters/listItem'
import { OrderedListConverter } from './converters/orderedList'
import { RelationshipConverter } from './converters/relationship'
import { UnknownConverter } from './converters/unknown'
import { UnorderedListConverter } from './converters/unorderedList'
import { UploadConverter } from './converters/upload'
import { SlateHeadingConverter } from './converters/heading'
import { SlateIndentConverter } from './converters/indent'
import { SlateLinkConverter } from './converters/link'
import { SlateListItemConverter } from './converters/listItem'
import { SlateOrderedListConverter } from './converters/orderedList'
import { SlateRelationshipConverter } from './converters/relationship'
import { SlateUnknownConverter } from './converters/unknown'
import { SlateUnorderedListConverter } from './converters/unorderedList'
import { SlateUploadConverter } from './converters/upload'
export const defaultConverters: SlateNodeConverter[] = [
UnknownConverter,
UploadConverter,
UnorderedListConverter,
OrderedListConverter,
RelationshipConverter,
ListItemConverter,
LinkConverter,
HeadingConverter,
IndentConverter,
export const defaultSlateConverters: SlateNodeConverter[] = [
SlateUnknownConverter,
SlateUploadConverter,
SlateUnorderedListConverter,
SlateOrderedListConverter,
SlateRelationshipConverter,
SlateListItemConverter,
SlateLinkConverter,
SlateHeadingConverter,
SlateIndentConverter,
]

View File

@@ -2,7 +2,7 @@ import type { FeatureProvider } from '../../types'
import type { SlateNodeConverter } from './converter/types'
import { convertSlateToLexical } from './converter'
import { defaultConverters } from './converter/defaultConverters'
import { defaultSlateConverters } from './converter/defaultConverters'
import { UnknownConvertedNode } from './nodes/unknownConvertedNode'
type Props = {
@@ -18,11 +18,11 @@ export const SlateToLexicalFeature = (props?: Props): FeatureProvider => {
props.converters =
props?.converters && typeof props?.converters === 'function'
? props.converters({ defaultConverters: defaultConverters })
: (props?.converters as SlateNodeConverter[]) || defaultConverters
? props.converters({ defaultConverters: defaultSlateConverters })
: (props?.converters as SlateNodeConverter[]) || defaultSlateConverters
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
feature: () => {
return {
hooks: {
load({ incomingEditorState }) {

View File

@@ -6,27 +6,28 @@ import type { PayloadRequest, RichTextField, ValidateOptions } from 'payload/typ
import type React from 'react'
import type { AdapterProps } from '../../types'
import type { EditorConfig } from '..//lexical/config/types'
import type { EditorConfig } from '../lexical/config/types'
import type { FloatingToolbarSection } from '../lexical/plugins/FloatingSelectToolbar/types'
import type { SlashMenuGroup } from '../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import type { HTMLConverter } from './converters/html/converter/types'
export type AfterReadPromise<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
afterReadPromises,
export type PopulationPromise<T extends SerializedLexicalNode = SerializedLexicalNode> = ({
currentDepth,
depth,
field,
node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
}: {
afterReadPromises: Map<string, Array<AfterReadPromise>>
currentDepth: number
depth: number
field: RichTextField<SerializedEditorState, AdapterProps>
node: T
overrideAccess: boolean
populationPromises: Map<string, Array<PopulationPromise>>
req: PayloadRequest
showHiddenFields: boolean
siblingDoc: Record<string, unknown>
@@ -52,6 +53,15 @@ export type Feature = {
sections: FloatingToolbarSection[]
}
hooks?: {
afterReadPromise?: ({
field,
incomingEditorState,
siblingDoc,
}: {
field: RichTextField<SerializedEditorState, AdapterProps>
incomingEditorState: SerializedEditorState
siblingDoc: Record<string, unknown>
}) => Promise<void> | null
load?: ({
incomingEditorState,
}: {
@@ -65,8 +75,11 @@ export type Feature = {
}
markdownTransformers?: Transformer[]
nodes?: Array<{
afterReadPromises?: Array<AfterReadPromise>
converters?: {
html?: HTMLConverter
}
node: Klass<LexicalNode>
populationPromises?: Array<PopulationPromise>
type: string
validations?: Array<NodeValidation>
}>
@@ -128,14 +141,27 @@ export type FeatureProviderMap = Map<string, FeatureProvider>
export type SanitizedFeatures = Required<
Pick<ResolvedFeature, 'markdownTransformers' | 'nodes'>
> & {
/** The node types mapped to their afterReadPromises */
afterReadPromises: Map<string, Array<AfterReadPromise>>
/** The node types mapped to their converters */
converters: {
html: HTMLConverter[]
}
/** The keys of all enabled features */
enabledFeatures: string[]
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
hooks: {
afterReadPromises: Array<
({
field,
incomingEditorState,
siblingDoc,
}: {
field: RichTextField<SerializedEditorState, AdapterProps>
incomingEditorState: SerializedEditorState
siblingDoc: Record<string, unknown>
}) => Promise<void> | null
>
load: Array<
({
incomingEditorState,
@@ -166,6 +192,8 @@ export type SanitizedFeatures = Required<
position: 'floatingAnchorElem' // Determines at which position the Component will be added.
}
>
/** The node types mapped to their populationPromises */
populationPromises: Map<string, Array<PopulationPromise>>
slashMenu: {
dynamicOptions: Array<
({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => SlashMenuGroup[]

View File

@@ -5,18 +5,22 @@ import { loadFeatures } from './loader'
export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeatures => {
const sanitized: SanitizedFeatures = {
afterReadPromises: new Map(),
converters: {
html: [],
},
enabledFeatures: [],
floatingSelectToolbar: {
sections: [],
},
hooks: {
afterReadPromises: [],
load: [],
save: [],
},
markdownTransformers: [],
nodes: [],
plugins: [],
populationPromises: new Map(),
slashMenu: {
dynamicOptions: [],
groupsWithOptions: [],
@@ -26,6 +30,11 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
features.forEach((feature) => {
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)
}
@@ -37,12 +46,15 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
if (feature.nodes?.length) {
sanitized.nodes = sanitized.nodes.concat(feature.nodes)
feature.nodes.forEach((node) => {
if (node?.afterReadPromises?.length) {
sanitized.afterReadPromises.set(node.type, node.afterReadPromises)
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) {

View File

@@ -27,9 +27,11 @@ export type LexicalEditorProps = {
lexical?: LexicalEditorConfig
}
export function lexicalEditor(
props?: LexicalEditorProps,
): RichTextAdapter<SerializedEditorState, AdapterProps> {
export type LexicalRichTextAdapter = RichTextAdapter<SerializedEditorState, AdapterProps> & {
editorConfig: SanitizedEditorConfig
}
export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapter {
let finalSanitizedEditorConfig: SanitizedEditorConfig
if (!props || (!props.features && !props.lexical)) {
finalSanitizedEditorConfig = cloneDeep(defaultSanitizedEditorConfig)
@@ -59,7 +61,30 @@ export function lexicalEditor(
Component: RichTextField,
toMergeIntoProps: { editorConfig: finalSanitizedEditorConfig },
}),
afterReadPromise({
afterReadPromise: ({ field, incomingEditorState, siblingDoc }) => {
return new Promise<void>((resolve, reject) => {
const promises: Promise<void>[] = []
if (finalSanitizedEditorConfig?.features?.hooks?.afterReadPromises?.length) {
for (const afterReadPromise of finalSanitizedEditorConfig.features.hooks
.afterReadPromises) {
promises.push(
afterReadPromise({
field,
incomingEditorState,
siblingDoc,
}),
)
}
}
Promise.all(promises)
.then(() => resolve())
.catch((error) => reject(error))
})
},
editorConfig: finalSanitizedEditorConfig,
populationPromise({
currentDepth,
depth,
field,
@@ -68,14 +93,14 @@ export function lexicalEditor(
showHiddenFields,
siblingDoc,
}) {
// check if there are any features with nodes which have afterReadPromises for this field
if (finalSanitizedEditorConfig?.features?.afterReadPromises?.size) {
// check if there are any features with nodes which have populationPromises for this field
if (finalSanitizedEditorConfig?.features?.populationPromises?.size) {
return richTextRelationshipPromise({
afterReadPromises: finalSanitizedEditorConfig.features.afterReadPromises,
currentDepth,
depth,
field,
overrideAccess,
populationPromises: finalSanitizedEditorConfig.features.populationPromises,
req,
showHiddenFields,
siblingDoc,
@@ -99,8 +124,8 @@ export {
BlockNode,
type SerializedBlockNode,
} 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 {
@@ -109,7 +134,6 @@ export {
AutoLinkNode,
type SerializedAutoLinkNode,
} from './field/features/Link/nodes/AutoLinkNode'
export {
$createLinkNode,
$isLinkNode,
@@ -118,6 +142,7 @@ export {
type SerializedLinkNode,
TOGGLE_LINK_COMMAND,
} from './field/features/Link/nodes/LinkNode'
export { ParagraphFeature } from './field/features/Paragraph'
export { RelationshipFeature } from './field/features/Relationship'
export {
@@ -139,6 +164,20 @@ export {
} from './field/features/Upload/nodes/UploadNode'
export { AlignFeature } from './field/features/align'
export { TextDropdownSectionWithEntries } from './field/features/common/floatingSelectToolbarTextDropdownSection'
export {
HTMLConverterFeature,
type HTMLConverterFeatureProps,
} from './field/features/converters/html'
export {
convertLexicalNodesToHTML,
convertLexicalToHTML,
} from './field/features/converters/html/converter'
export { ParagraphHTMLConverter } from './field/features/converters/html/converter/converters/paragraph'
export { TextHTMLConverter } from './field/features/converters/html/converter/converters/text'
export { defaultHTMLConverters } from './field/features/converters/html/converter/defaultConverters'
export type { HTMLConverter } from './field/features/converters/html/converter/types'
export { lexicalHTML } from './field/features/converters/html/field'
export { TreeviewFeature } from './field/features/debug/TreeView'
export { BoldTextFeature } from './field/features/format/Bold'
@@ -155,13 +194,34 @@ export { OrderedListFeature } from './field/features/lists/OrderedList'
export { UnoderedListFeature } from './field/features/lists/UnorderedList'
export { LexicalPluginToLexicalFeature } from './field/features/migrations/LexicalPluginToLexical'
export { SlateToLexicalFeature } from './field/features/migrations/SlateToLexical'
export { SlateHeadingConverter } from './field/features/migrations/SlateToLexical/converter/converters/heading'
export { SlateIndentConverter } from './field/features/migrations/SlateToLexical/converter/converters/indent'
export { SlateLinkConverter } from './field/features/migrations/SlateToLexical/converter/converters/link'
export { SlateListItemConverter } from './field/features/migrations/SlateToLexical/converter/converters/listItem'
export { SlateOrderedListConverter } from './field/features/migrations/SlateToLexical/converter/converters/orderedList'
export { SlateRelationshipConverter } from './field/features/migrations/SlateToLexical/converter/converters/relationship'
export { SlateUnknownConverter } from './field/features/migrations/SlateToLexical/converter/converters/unknown'
export { SlateUnorderedListConverter } from './field/features/migrations/SlateToLexical/converter/converters/unorderedList'
export { SlateUploadConverter } from './field/features/migrations/SlateToLexical/converter/converters/upload'
export { defaultSlateConverters } from './field/features/migrations/SlateToLexical/converter/defaultConverters'
export {
convertSlateNodesToLexical,
convertSlateToLexical,
} from './field/features/migrations/SlateToLexical/converter/index'
export type {
SlateNode,
SlateNodeConverter,
} from './field/features/migrations/SlateToLexical/converter/types'
export type {
AfterReadPromise,
Feature,
FeatureProvider,
FeatureProviderMap,
NodeValidation,
PopulationPromise,
ResolvedFeature,
ResolvedFeatureMap,
SanitizedFeatures,

View File

@@ -2,17 +2,17 @@ import type { Field, PayloadRequest, RichTextAdapter } from 'payload/types'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload/types'
import type { AfterReadPromise } from '../field/features/types'
import type { PopulationPromise } from '../field/features/types'
import { populate } from './populate'
type NestedRichTextFieldsArgs = {
afterReadPromises: Map<string, Array<AfterReadPromise>>
currentDepth?: number
data: unknown
depth: number
fields: Field[]
overrideAccess: boolean
populationPromises: Map<string, Array<PopulationPromise>>
promises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
@@ -20,12 +20,12 @@ type NestedRichTextFieldsArgs = {
}
export const recurseNestedFields = ({
afterReadPromises,
currentDepth = 0,
data,
depth,
fields,
overrideAccess = false,
populationPromises,
promises,
req,
showHiddenFields,
@@ -118,12 +118,12 @@ export const recurseNestedFields = ({
} else if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
if (fieldAffectsData(field) && typeof data[field.name] === 'object') {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: data[field.name],
depth,
fields: field.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -131,12 +131,12 @@ export const recurseNestedFields = ({
})
} else {
recurseNestedFields({
afterReadPromises,
currentDepth,
data,
depth,
fields: field.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -146,12 +146,12 @@ export const recurseNestedFields = ({
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
recurseNestedFields({
afterReadPromises,
currentDepth,
data,
depth,
fields: tab.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -164,12 +164,12 @@ export const recurseNestedFields = ({
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
if (block) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: data[field.name][i],
depth,
fields: block.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -182,12 +182,12 @@ export const recurseNestedFields = ({
if (field.type === 'array') {
data[field.name].forEach((_, i) => {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: data[field.name][i],
depth,
fields: field.fields,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -200,8 +200,8 @@ export const recurseNestedFields = ({
if (field.type === 'richText') {
const editor: RichTextAdapter = field?.editor
if (editor?.afterReadPromise) {
const afterReadPromise = editor.afterReadPromise({
if (editor?.populationPromise) {
const afterReadPromise = editor.populationPromise({
currentDepth,
depth,
field,

View File

@@ -1,22 +1,22 @@
import type { SerializedEditorState, SerializedLexicalNode } from 'lexical'
import type { PayloadRequest, RichTextAdapter, RichTextField } from 'payload/types'
import type { AfterReadPromise } from '../field/features/types'
import type { PopulationPromise } from '../field/features/types'
import type { AdapterProps } from '../types'
export type Args = Parameters<
RichTextAdapter<SerializedEditorState, AdapterProps>['afterReadPromise']
RichTextAdapter<SerializedEditorState, AdapterProps>['populationPromise']
>[0] & {
afterReadPromises: Map<string, Array<AfterReadPromise>>
populationPromises: Map<string, Array<PopulationPromise>>
}
type RecurseRichTextArgs = {
afterReadPromises: Map<string, Array<AfterReadPromise>>
children: SerializedLexicalNode[]
currentDepth: number
depth: number
field: RichTextField<SerializedEditorState, AdapterProps>
overrideAccess: boolean
populationPromises: Map<string, Array<PopulationPromise>>
promises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
@@ -24,12 +24,12 @@ type RecurseRichTextArgs = {
}
export const recurseRichText = ({
afterReadPromises,
children,
currentDepth = 0,
depth,
field,
overrideAccess = false,
populationPromises,
promises,
req,
showHiddenFields,
@@ -41,16 +41,16 @@ export const recurseRichText = ({
if (Array.isArray(children)) {
children.forEach((node) => {
if (afterReadPromises?.has(node.type)) {
for (const promise of afterReadPromises.get(node.type)) {
if (populationPromises?.has(node.type)) {
for (const promise of populationPromises.get(node.type)) {
promises.push(
...promise({
afterReadPromises,
currentDepth,
depth,
field,
node: node,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
@@ -61,12 +61,12 @@ export const recurseRichText = ({
if ('children' in node && Array.isArray(node?.children) && node?.children?.length) {
recurseRichText({
afterReadPromises,
children: node.children as SerializedLexicalNode[],
currentDepth,
depth,
field,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,
@@ -78,11 +78,11 @@ export const recurseRichText = ({
}
export const richTextRelationshipPromise = async ({
afterReadPromises,
currentDepth,
depth,
field,
overrideAccess,
populationPromises,
req,
showHiddenFields,
siblingDoc,
@@ -90,12 +90,12 @@ export const richTextRelationshipPromise = async ({
const promises = []
recurseRichText({
afterReadPromises,
children: (siblingDoc[field?.name] as SerializedEditorState)?.root?.children ?? [],
currentDepth,
depth,
field,
overrideAccess,
populationPromises,
promises,
req,
showHiddenFields,

View File

@@ -5,7 +5,7 @@ import type { AdapterArguments } from '../types'
import { populate } from './populate'
import { recurseNestedFields } from './recurseNestedFields'
export type Args = Parameters<RichTextAdapter<any[], AdapterArguments>['afterReadPromise']>[0]
export type Args = Parameters<RichTextAdapter<any[], AdapterArguments>['populationPromise']>[0]
type RecurseRichTextArgs = {
children: unknown[]

View File

@@ -19,7 +19,7 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
Component: RichTextField,
toMergeIntoProps: args,
}),
afterReadPromise({
populationPromise({
currentDepth,
depth,
field,

View File

@@ -2,6 +2,7 @@ import type { CollectionConfig } from '../../../../packages/payload/src/collecti
import {
BlocksFeature,
HTMLConverterFeature,
LexicalPluginToLexicalFeature,
LinkFeature,
TreeviewFeature,
@@ -34,45 +35,6 @@ export const LexicalFields: CollectionConfig = {
type: 'text',
required: true,
},
{
name: 'richTextLexicalWithLexicalPluginData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
LexicalPluginToLexicalFeature(),
TreeviewFeature(),
LinkFeature({
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
],
}),
},
{
name: 'richTextLexicalCustomFields',
type: 'richText',
@@ -81,6 +43,7 @@ export const LexicalFields: CollectionConfig = {
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeviewFeature(),
HTMLConverterFeature(),
LinkFeature({
fields: [
{
@@ -122,6 +85,45 @@ export const LexicalFields: CollectionConfig = {
],
}),
},
{
name: 'richTextLexicalWithLexicalPluginData',
type: 'richText',
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
LexicalPluginToLexicalFeature(),
TreeviewFeature(),
LinkFeature({
fields: [
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
},
],
}),
UploadFeature({
collections: {
uploads: {
fields: [
{
name: 'caption',
type: 'richText',
editor: lexicalEditor(),
},
],
},
},
}),
],
}),
},
],
}

View File

@@ -2,11 +2,13 @@ import type { CollectionConfig } from '../../../../packages/payload/src/collecti
import {
BlocksFeature,
HTMLConverterFeature,
LinkFeature,
TreeviewFeature,
UploadFeature,
lexicalEditor,
} from '../../../../packages/richtext-lexical/src'
import { lexicalHTML } from '../../../../packages/richtext-lexical/src/field/features/converters/html/field'
import { slateEditor } from '../../../../packages/richtext-slate/src'
import { RelationshipBlock, SelectFieldBlock, TextBlock, UploadAndRichTextBlock } from './blocks'
import { generateLexicalRichText } from './generateLexicalRichText'
@@ -34,6 +36,7 @@ const RichTextFields: CollectionConfig = {
features: ({ defaultFeatures }) => [
...defaultFeatures,
TreeviewFeature(),
HTMLConverterFeature({}),
LinkFeature({
fields: [
{
@@ -68,6 +71,7 @@ const RichTextFields: CollectionConfig = {
],
}),
},
lexicalHTML('richTextLexicalCustomFields', { name: 'richTextLexicalCustomFields_htmll' }),
{
name: 'richTextLexical',
type: 'richText',