diff --git a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts index 87e1b8635..0168c80a5 100644 --- a/packages/payload/src/admin/components/forms/field-types/RichText/types.ts +++ b/packages/payload/src/admin/components/forms/field-types/RichText/types.ts @@ -12,7 +12,17 @@ export type RichTextFieldProps = Omit< export type RichTextAdapter = { CellComponent: React.FC>> FieldComponent: React.FC> - afterReadPromise?: (data: { + afterReadPromise?: ({ + field, + incomingEditorState, + siblingDoc, + }: { + field: RichTextField + incomingEditorState: Value + siblingDoc: Record + }) => Promise | null + + populationPromise?: (data: { currentDepth?: number depth: number field: RichTextField diff --git a/packages/payload/src/config/schema.ts b/packages/payload/src/config/schema.ts index d8682a0fb..9d77c93e8 100644 --- a/packages/payload/src/config/schema.ts +++ b/packages/payload/src/config/schema.ts @@ -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({ diff --git a/packages/payload/src/exports/README.md b/packages/payload/src/exports/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/payload/src/fields/config/schema.ts b/packages/payload/src/fields/config/schema.ts index 6a901140a..b0b4f060b 100644 --- a/packages/payload/src/fields/config/schema.ts +++ b/packages/payload/src/fields/config/schema.ts @@ -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(), }) diff --git a/packages/payload/src/fields/hooks/afterRead/promise.ts b/packages/payload/src/fields/hooks/afterRead/promise.ts index 5a813a3cb..c464c09cb 100644 --- a/packages/payload/src/fields/hooks/afterRead/promise.ts +++ b/packages/payload/src/fields/hooks/afterRead/promise.ts @@ -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) } diff --git a/packages/payload/src/graphql/schema/buildObjectType.ts b/packages/payload/src/graphql/schema/buildObjectType.ts index a6f8a63b6..17083dce9 100644 --- a/packages/payload/src/graphql/schema/buildObjectType.ts +++ b/packages/payload/src/graphql/schema/buildObjectType.ts @@ -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, diff --git a/packages/richtext-lexical/src/field/features/BlockQuote/index.ts b/packages/richtext-lexical/src/field/features/BlockQuote/index.ts index 23264d3db..0f9509ed7 100644 --- a/packages/richtext-lexical/src/field/features/BlockQuote/index.ts +++ b/packages/richtext-lexical/src/field/features/BlockQuote/index.ts @@ -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 `
${childrenText}
` + }, + nodeTypes: [QuoteNode.getType()], + } as HTMLConverter, + }, node: QuoteNode, type: QuoteNode.getType(), }, diff --git a/packages/richtext-lexical/src/field/features/Blocks/index.tsx b/packages/richtext-lexical/src/field/features/Blocks/index.tsx index 69ed495f6..f2e95d50e 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/index.tsx +++ b/packages/richtext-lexical/src/field/features/Blocks/index.tsx @@ -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)], }, diff --git a/packages/richtext-lexical/src/field/features/Blocks/afterReadPromise.ts b/packages/richtext-lexical/src/field/features/Blocks/populationPromise.ts similarity index 83% rename from packages/richtext-lexical/src/field/features/Blocks/afterReadPromise.ts rename to packages/richtext-lexical/src/field/features/Blocks/populationPromise.ts index 11858c23a..0df4703bb 100644 --- a/packages/richtext-lexical/src/field/features/Blocks/afterReadPromise.ts +++ b/packages/richtext-lexical/src/field/features/Blocks/populationPromise.ts @@ -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 => { - const blockAfterReadPromise: AfterReadPromise = ({ - afterReadPromises, +): PopulationPromise => { + const blockPopulationPromise: PopulationPromise = ({ 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 } diff --git a/packages/richtext-lexical/src/field/features/Heading/index.ts b/packages/richtext-lexical/src/field/features/Heading/index.ts index a7eee4c45..72c738dca 100644 --- a/packages/richtext-lexical/src/field/features/Heading/index.ts +++ b/packages/richtext-lexical/src/field/features/Heading/index.ts @@ -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 + '' + }, + nodeTypes: [HeadingNode.getType()], + } as HTMLConverter, + }, + 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) }, }), ], diff --git a/packages/richtext-lexical/src/field/features/Link/index.tsx b/packages/richtext-lexical/src/field/features/Link/index.tsx index 260ac2928..9c0b0eed2 100644 --- a/packages/richtext-lexical/src/field/features/Link/index.tsx +++ b/packages/richtext-lexical/src/field/features/Link/index.tsx @@ -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 `${childrenText}` + }, + nodeTypes: [LinkNode.getType()], + } as HTMLConverter, + }, 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 `${childrenText}` + }, + nodeTypes: [AutoLinkNode.getType()], + } as HTMLConverter, + }, node: AutoLinkNode, + populationPromises: [linkPopulationPromiseHOC(props)], type: AutoLinkNode.getType(), }, ], diff --git a/packages/richtext-lexical/src/field/features/Link/afterReadPromise.ts b/packages/richtext-lexical/src/field/features/Link/populationPromise.ts similarity index 80% rename from packages/richtext-lexical/src/field/features/Link/afterReadPromise.ts rename to packages/richtext-lexical/src/field/features/Link/populationPromise.ts index dd7271a7c..53ab4dade 100644 --- a/packages/richtext-lexical/src/field/features/Link/afterReadPromise.ts +++ b/packages/richtext-lexical/src/field/features/Link/populationPromise.ts @@ -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 => { - const linkAfterReadPromise: AfterReadPromise = ({ - afterReadPromises, +): PopulationPromise => { + const linkPopulationPromise: PopulationPromise = ({ currentDepth, depth, field, node, overrideAccess, + populationPromises, req, showHiddenFields, - siblingDoc, }) => { const promises: Promise[] = [] @@ -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 } diff --git a/packages/richtext-lexical/src/field/features/Relationship/index.tsx b/packages/richtext-lexical/src/field/features/Relationship/index.tsx index 7a5135daf..b566a09d7 100644 --- a/packages/richtext-lexical/src/field/features/Relationship/index.tsx +++ b/packages/richtext-lexical/src/field/features/Relationship/index.tsx @@ -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 }, diff --git a/packages/richtext-lexical/src/field/features/Relationship/afterReadPromise.ts b/packages/richtext-lexical/src/field/features/Relationship/populationPromise.ts similarity index 82% rename from packages/richtext-lexical/src/field/features/Relationship/afterReadPromise.ts rename to packages/richtext-lexical/src/field/features/Relationship/populationPromise.ts index ec8516aee..28df7df1b 100644 --- a/packages/richtext-lexical/src/field/features/Relationship/afterReadPromise.ts +++ b/packages/richtext-lexical/src/field/features/Relationship/populationPromise.ts @@ -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 = ({ +export const relationshipPopulationPromise: PopulationPromise = ({ currentDepth, depth, field, diff --git a/packages/richtext-lexical/src/field/features/Upload/index.tsx b/packages/richtext-lexical/src/field/features/Upload/index.tsx index 29b85bd42..0483b6a1e 100644 --- a/packages/richtext-lexical/src/field/features/Upload/index.tsx +++ b/packages/richtext-lexical/src/field/features/Upload/index.tsx @@ -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 `Upload node which is not an image` + } + + return `${uploadDocument?.filename}` + }, + nodeTypes: [UploadNode.getType()], + } as HTMLConverter, + }, node: UploadNode, + populationPromises: [uploadPopulationPromiseHOC(props)], type: UploadNode.getType(), validations: [uploadValidation()], }, diff --git a/packages/richtext-lexical/src/field/features/Upload/afterReadPromise.ts b/packages/richtext-lexical/src/field/features/Upload/populationPromise.ts similarity index 80% rename from packages/richtext-lexical/src/field/features/Upload/afterReadPromise.ts rename to packages/richtext-lexical/src/field/features/Upload/populationPromise.ts index c4178a204..1bf823190 100644 --- a/packages/richtext-lexical/src/field/features/Upload/afterReadPromise.ts +++ b/packages/richtext-lexical/src/field/features/Upload/populationPromise.ts @@ -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 => { - const uploadAfterReadPromise: AfterReadPromise = ({ - afterReadPromises, +): PopulationPromise => { + const uploadPopulationPromise: PopulationPromise = ({ currentDepth, depth, field, node, overrideAccess, + populationPromises, req, showHiddenFields, - siblingDoc, }) => { const promises: Promise[] = [] @@ -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 } diff --git a/packages/richtext-lexical/src/field/features/converters/html/converter/converters/paragraph.ts b/packages/richtext-lexical/src/field/features/converters/html/converter/converters/paragraph.ts new file mode 100644 index 000000000..78294e971 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/converter/converters/paragraph.ts @@ -0,0 +1,20 @@ +import type { SerializedParagraphNode } from 'lexical' + +import type { HTMLConverter } from '../types' + +import { convertLexicalNodesToHTML } from '../index' + +export const ParagraphHTMLConverter: HTMLConverter = { + async converter({ converters, node, parent }) { + const childrenText = await convertLexicalNodesToHTML({ + converters, + lexicalNodes: node.children, + parent: { + ...node, + parent, + }, + }) + return `

${childrenText}

` + }, + nodeTypes: ['paragraph'], +} diff --git a/packages/richtext-lexical/src/field/features/converters/html/converter/converters/text.ts b/packages/richtext-lexical/src/field/features/converters/html/converter/converters/text.ts new file mode 100644 index 000000000..91d2010ce --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/converter/converters/text.ts @@ -0,0 +1,36 @@ +import type { SerializedTextNode } from 'lexical' + +import type { HTMLConverter } from '../types' + +import { NodeFormat } from '../../../../../lexical/utils/nodeFormat' + +export const TextHTMLConverter: HTMLConverter = { + converter({ node }) { + let text = node.text + + if (node.format & NodeFormat.IS_BOLD) { + text = `${text}` + } + if (node.format & NodeFormat.IS_ITALIC) { + text = `${text}` + } + if (node.format & NodeFormat.IS_STRIKETHROUGH) { + text = `${text}` + } + if (node.format & NodeFormat.IS_UNDERLINE) { + text = `${text}` + } + if (node.format & NodeFormat.IS_CODE) { + text = `${text}` + } + if (node.format & NodeFormat.IS_SUBSCRIPT) { + text = `${text}` + } + if (node.format & NodeFormat.IS_SUPERSCRIPT) { + text = `${text}` + } + + return text + }, + nodeTypes: ['text'], +} diff --git a/packages/richtext-lexical/src/field/features/converters/html/converter/defaultConverters.ts b/packages/richtext-lexical/src/field/features/converters/html/converter/defaultConverters.ts new file mode 100644 index 000000000..2bc9a5d14 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/converter/defaultConverters.ts @@ -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] diff --git a/packages/richtext-lexical/src/field/features/converters/html/converter/index.ts b/packages/richtext-lexical/src/field/features/converters/html/converter/index.ts new file mode 100644 index 000000000..7b7dd1bd1 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/converter/index.ts @@ -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 { + 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 { + 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 'unknown node' + } + return converterForNode.converter({ + childIndex: i, + converters, + node, + parent, + }) + }), + ) + + return htmlArray.join('') || '' +} diff --git a/packages/richtext-lexical/src/field/features/converters/html/converter/types.ts b/packages/richtext-lexical/src/field/features/converters/html/converter/types.ts new file mode 100644 index 000000000..0b5943cab --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/converter/types.ts @@ -0,0 +1,20 @@ +import type { SerializedLexicalNode } from 'lexical' + +export type HTMLConverter = { + converter: ({ + childIndex, + converters, + node, + parent, + }: { + childIndex: number + converters: HTMLConverter[] + node: T + parent: SerializedLexicalNodeWithParent + }) => Promise | string + nodeTypes: string[] +} + +export type SerializedLexicalNodeWithParent = SerializedLexicalNode & { + parent?: SerializedLexicalNode +} diff --git a/packages/richtext-lexical/src/field/features/converters/html/field/index.ts b/packages/richtext-lexical/src/field/features/converters/html/field/index.ts new file mode 100644 index 000000000..e1d0b186c --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/field/index.ts @@ -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 = + collection.fields.find( + (field) => 'name' in field && field.name === lexicalFieldName, + ) as RichTextField + + 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', + } +} diff --git a/packages/richtext-lexical/src/field/features/converters/html/index.ts b/packages/richtext-lexical/src/field/features/converters/html/index.ts new file mode 100644 index 000000000..e7d9d84a9 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/converters/html/index.ts @@ -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', + } +} diff --git a/packages/richtext-lexical/src/field/features/lists/CheckList/index.ts b/packages/richtext-lexical/src/field/features/lists/CheckList/index.ts index ceb89816b..c6ce97d54 100644 --- a/packages/richtext-lexical/src/field/features/lists/CheckList/index.ts +++ b/packages/richtext-lexical/src/field/features/lists/CheckList/index.ts @@ -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(), }, diff --git a/packages/richtext-lexical/src/field/features/lists/OrderedList/index.ts b/packages/richtext-lexical/src/field/features/lists/OrderedList/index.ts index 2e62cafba..1d20d1eb8 100644 --- a/packages/richtext-lexical/src/field/features/lists/OrderedList/index.ts +++ b/packages/richtext-lexical/src/field/features/lists/OrderedList/index.ts @@ -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') ? [] diff --git a/packages/richtext-lexical/src/field/features/lists/UnorderedList/index.ts b/packages/richtext-lexical/src/field/features/lists/UnorderedList/index.ts index a9aa060c2..3dbca2b99 100644 --- a/packages/richtext-lexical/src/field/features/lists/UnorderedList/index.ts +++ b/packages/richtext-lexical/src/field/features/lists/UnorderedList/index.ts @@ -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(), }, diff --git a/packages/richtext-lexical/src/field/features/lists/htmlConverter.ts b/packages/richtext-lexical/src/field/features/lists/htmlConverter.ts new file mode 100644 index 000000000..3482b0412 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/lists/htmlConverter.ts @@ -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 = { + converter: async ({ converters, node, parent }) => { + const childrenText = await convertLexicalNodesToHTML({ + converters, + lexicalNodes: node.children, + parent: { + ...node, + parent, + }, + }) + + return `<${node?.tag} class="${node?.listType}">${childrenText}` + }, + nodeTypes: [ListNode.getType()], +} + +export const ListItemHTMLConverter: HTMLConverter = { + converter: async ({ converters, node, parent }) => { + const childrenText = await convertLexicalNodesToHTML({ + converters, + lexicalNodes: node.children, + parent: { + ...node, + parent, + }, + }) + + if ('listType' in parent && parent?.listType === 'check') { + return `` + } else { + return `
  • ${childrenText}
  • ` + } + }, + nodeTypes: [ListItemNode.getType()], +} diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts index ac031c688..ff9f988c7 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts @@ -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({ diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts index b5e721297..0aadc1891 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts @@ -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 => { diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts index 80458d90b..b4c1ff676 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts @@ -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({ diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts index 1775803da..64365fbaa 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts @@ -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, diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts index d3cef2650..0014d97f4 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts @@ -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({ diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts index d881e0538..18406e895 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts @@ -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: '', diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts index 05fa300ee..91a4ea890 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts @@ -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({ diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts index fd82b3a7e..db530aa19 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts @@ -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({ diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts index a3b59d6a5..ba4953462 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts @@ -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: { diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts index d0f5ff75b..c779c7d02 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts @@ -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, ] diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts index 6dc9ff010..a3752dd96 100644 --- a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts @@ -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 }) { diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index 0277bc6fe..fb3a208f5 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -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 = ({ - afterReadPromises, +export type PopulationPromise = ({ currentDepth, depth, field, node, overrideAccess, + populationPromises, req, showHiddenFields, siblingDoc, }: { - afterReadPromises: Map> currentDepth: number depth: number field: RichTextField node: T overrideAccess: boolean + populationPromises: Map> req: PayloadRequest showHiddenFields: boolean siblingDoc: Record @@ -52,6 +53,15 @@ export type Feature = { sections: FloatingToolbarSection[] } hooks?: { + afterReadPromise?: ({ + field, + incomingEditorState, + siblingDoc, + }: { + field: RichTextField + incomingEditorState: SerializedEditorState + siblingDoc: Record + }) => Promise | null load?: ({ incomingEditorState, }: { @@ -65,8 +75,11 @@ export type Feature = { } markdownTransformers?: Transformer[] nodes?: Array<{ - afterReadPromises?: Array + converters?: { + html?: HTMLConverter + } node: Klass + populationPromises?: Array type: string validations?: Array }> @@ -128,14 +141,27 @@ export type FeatureProviderMap = Map export type SanitizedFeatures = Required< Pick > & { - /** The node types mapped to their afterReadPromises */ - afterReadPromises: Map> + /** 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 + incomingEditorState: SerializedEditorState + siblingDoc: Record + }) => Promise | 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> slashMenu: { dynamicOptions: Array< ({ editor, queryString }: { editor: LexicalEditor; queryString: string }) => SlashMenuGroup[] diff --git a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts index ad793c0a0..8a8b66670 100644 --- a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts @@ -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) { diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index f0b524774..fb5c98c2e 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -27,9 +27,11 @@ export type LexicalEditorProps = { lexical?: LexicalEditorConfig } -export function lexicalEditor( - props?: LexicalEditorProps, -): RichTextAdapter { +export type LexicalRichTextAdapter = RichTextAdapter & { + 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((resolve, reject) => { + const promises: Promise[] = [] + + 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, diff --git a/packages/richtext-lexical/src/populate/recurseNestedFields.ts b/packages/richtext-lexical/src/populate/recurseNestedFields.ts index 65846d126..ab9948824 100644 --- a/packages/richtext-lexical/src/populate/recurseNestedFields.ts +++ b/packages/richtext-lexical/src/populate/recurseNestedFields.ts @@ -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> currentDepth?: number data: unknown depth: number fields: Field[] overrideAccess: boolean + populationPromises: Map> promises: Promise[] 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, diff --git a/packages/richtext-lexical/src/populate/richTextRelationshipPromise.ts b/packages/richtext-lexical/src/populate/richTextRelationshipPromise.ts index 198b5a6cd..d5af36a61 100644 --- a/packages/richtext-lexical/src/populate/richTextRelationshipPromise.ts +++ b/packages/richtext-lexical/src/populate/richTextRelationshipPromise.ts @@ -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['afterReadPromise'] + RichTextAdapter['populationPromise'] >[0] & { - afterReadPromises: Map> + populationPromises: Map> } type RecurseRichTextArgs = { - afterReadPromises: Map> children: SerializedLexicalNode[] currentDepth: number depth: number field: RichTextField overrideAccess: boolean + populationPromises: Map> promises: Promise[] 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, diff --git a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts index dbc3f8dab..233fd1743 100644 --- a/packages/richtext-slate/src/data/richTextRelationshipPromise.ts +++ b/packages/richtext-slate/src/data/richTextRelationshipPromise.ts @@ -5,7 +5,7 @@ import type { AdapterArguments } from '../types' import { populate } from './populate' import { recurseNestedFields } from './recurseNestedFields' -export type Args = Parameters['afterReadPromise']>[0] +export type Args = Parameters['populationPromise']>[0] type RecurseRichTextArgs = { children: unknown[] diff --git a/packages/richtext-slate/src/index.ts b/packages/richtext-slate/src/index.ts index 3e84c7995..c696c97b1 100644 --- a/packages/richtext-slate/src/index.ts +++ b/packages/richtext-slate/src/index.ts @@ -19,7 +19,7 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter [ - ...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(), + }, + ], + }, + }, + }), + ], + }), + }, ], } diff --git a/test/fields/collections/RichText/index.ts b/test/fields/collections/RichText/index.ts index 6899095c1..41eacd3f1 100644 --- a/test/fields/collections/RichText/index.ts +++ b/test/fields/collections/RichText/index.ts @@ -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',