From cd29978fafdfee7a85fb46380a3043d6c41ca8a6 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 1 Mar 2025 20:42:10 -0700 Subject: [PATCH] feat(richtext-lexical): add htmlToLexical helper (#11479) This adds a new `convertHTMLToLexical` helper that makes converting HTML to Lexical easy --- docs/rich-text/converters.mdx | 60 +++++++------------ .../converters/htmlToLexical/index.ts | 50 ++++++++++++++++ packages/richtext-lexical/src/index.ts | 29 ++++----- 3 files changed, 88 insertions(+), 51 deletions(-) create mode 100644 packages/richtext-lexical/src/features/converters/htmlToLexical/index.ts diff --git a/docs/rich-text/converters.mdx b/docs/rich-text/converters.mdx index 3625edc6e..5c49aa715 100644 --- a/docs/rich-text/converters.mdx +++ b/docs/rich-text/converters.mdx @@ -338,8 +338,8 @@ export const UploadFeature: FeatureProviderProviderServer< Lexical provides a seamless way to perform conversions between various other formats: -- HTML to Lexical (or, importing HTML into the lexical editor) -- Markdown to Lexical (or, importing Markdown into the lexical editor) +- HTML to Lexical +- Markdown to Lexical - Lexical to Markdown A headless editor can perform such conversions outside of the main editor instance. Follow this method to initiate a headless editor: @@ -455,46 +455,22 @@ export const MyCollection: CollectionConfig = { ## HTML => Lexical -Once you have your headless editor instance, you can use it to convert HTML to Lexical: +If you have access to the Payload Config and the lexical editor config, you can convert HTML to the lexical editor state with the following: ```ts -import { $generateNodesFromDOM } from '@payloadcms/richtext-lexical/lexical/html' -import { $getRoot, $getSelection } from '@payloadcms/richtext-lexical/lexical' +import { convertHTMLToLexical, editorConfigFactory } from '@payloadcms/richtext-lexical' +// Make sure you have jsdom and @types/jsdom installed import { JSDOM } from 'jsdom' -headlessEditor.update( - () => { - // In a headless environment you can use a package such as JSDom to parse the HTML string. - const dom = new JSDOM(htmlString) - - // Once you have the DOM instance it's easy to generate LexicalNodes. - const nodes = $generateNodesFromDOM(headlessEditor, dom.window.document) - - // Select the root - $getRoot().select() - - // Insert them at a selection. - const selection = $getSelection() - selection.insertNodes(nodes) - }, - { discrete: true }, -) - -// Do this if you then want to get the editor JSON -const editorJSON = headlessEditor.getEditorState().toJSON() +const html = convertHTMLToLexical({ + editorConfig: await editorConfigFactory.default({ + config, // <= make sure you have access to your Payload Config + }), + html: '

text

', + JSDOM, // pass the JSDOM import. As it's a relatively large package, richtext-lexical does not include it by default. +}) ``` -Functions prefixed with a `$` can only be run inside an `editor.update()` or `editorState.read()` callback. - -This has been taken from the [lexical serialization & deserialization docs](https://lexical.dev/docs/concepts/serialization#html---lexical). - - - **Note:** - - Using the `discrete: true` flag ensures instant updates to the editor state. If - immediate reading of the updated state isn't necessary, you can omit the flag. - - ## Markdown => Lexical Convert markdown content to the Lexical editor format with the following: @@ -516,6 +492,17 @@ headlessEditor.update( const editorJSON = headlessEditor.getEditorState().toJSON() ``` +Functions prefixed with a `$` can only be run inside an `editor.update()` or `editorState.read()` callback. + +This has been taken from the [lexical serialization & deserialization docs](https://lexical.dev/docs/concepts/serialization#html---lexical). + + + **Note:** + + Using the `discrete: true` flag ensures instant updates to the editor state. If + immediate reading of the updated state isn't necessary, you can omit the flag. + + ## Lexical => Markdown Export content from the Lexical editor into Markdown format using these steps: @@ -564,7 +551,6 @@ Here's the code for it: ```ts import type { SerializedEditorState } from '@payloadcms/richtext-lexical/lexical' - import { $getRoot } from '@payloadcms/richtext-lexical/lexical' const yourEditorState: SerializedEditorState // <= your current editor state here diff --git a/packages/richtext-lexical/src/features/converters/htmlToLexical/index.ts b/packages/richtext-lexical/src/features/converters/htmlToLexical/index.ts new file mode 100644 index 000000000..1db2425df --- /dev/null +++ b/packages/richtext-lexical/src/features/converters/htmlToLexical/index.ts @@ -0,0 +1,50 @@ +import { createHeadlessEditor } from '@lexical/headless' +import { $getRoot, $getSelection, type SerializedLexicalNode } from 'lexical' + +import type { SanitizedServerEditorConfig } from '../../../lexical/config/types.js' +import type { DefaultNodeTypes, TypedEditorState } from '../../../nodeTypes.js' + +import {} from '../../../lexical/config/server/sanitize.js' +import { getEnabledNodes } from '../../../lexical/nodes/index.js' +import { $generateNodesFromDOM } from '../../../lexical-proxy/@lexical-html.js' + +export const convertHTMLToLexical = ({ + editorConfig, + html, + JSDOM, +}: { + editorConfig: SanitizedServerEditorConfig + html: string + JSDOM: new (html: string) => { + window: { + document: Document + } + } +}): TypedEditorState => { + const headlessEditor = createHeadlessEditor({ + nodes: getEnabledNodes({ + editorConfig, + }), + }) + + headlessEditor.update( + () => { + const dom = new JSDOM(html) + + const nodes = $generateNodesFromDOM(headlessEditor, dom.window.document) + + $getRoot().select() + + const selection = $getSelection() + if (selection === null) { + throw new Error('Selection is null') + } + selection.insertNodes(nodes) + }, + { discrete: true }, + ) + + const editorJSON = headlessEditor.getEditorState().toJSON() + + return editorJSON as TypedEditorState +} diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index 48630bc21..e3d3acef7 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -886,48 +886,49 @@ export { HTMLConverterFeature, type HTMLConverterFeatureProps, } from './features/converters/html/index.js' +export { convertHTMLToLexical } from './features/converters/htmlToLexical/index.js' export { TestRecorderFeature } from './features/debug/testRecorder/server/index.js' export { TreeViewFeature } from './features/debug/treeView/server/index.js' export { EXPERIMENTAL_TableFeature } from './features/experimental_table/server/index.js' export { BoldFeature } from './features/format/bold/feature.server.js' export { InlineCodeFeature } from './features/format/inlineCode/feature.server.js' -export { ItalicFeature } from './features/format/italic/feature.server.js' +export { ItalicFeature } from './features/format/italic/feature.server.js' export { StrikethroughFeature } from './features/format/strikethrough/feature.server.js' export { SubscriptFeature } from './features/format/subscript/feature.server.js' export { SuperscriptFeature } from './features/format/superscript/feature.server.js' export { UnderlineFeature } from './features/format/underline/feature.server.js' export { HeadingFeature, type HeadingFeatureProps } from './features/heading/server/index.js' export { HorizontalRuleFeature } from './features/horizontalRule/server/index.js' + export { IndentFeature } from './features/indent/server/index.js' export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js' - export { LinkNode } from './features/link/nodes/LinkNode.js' export type { LinkFields } from './features/link/nodes/types.js' export { LinkFeature, type LinkFeatureServerProps } from './features/link/server/index.js' export { ChecklistFeature } from './features/lists/checklist/server/index.js' export { OrderedListFeature } from './features/lists/orderedList/server/index.js' + export { UnorderedListFeature } from './features/lists/unorderedList/server/index.js' export type { SlateNode, SlateNodeConverter, } from './features/migrations/slateToLexical/converter/types.js' - export { ParagraphFeature } from './features/paragraph/server/index.js' export { RelationshipFeature, type RelationshipFeatureProps, } from './features/relationship/server/index.js' + export { type RelationshipData, RelationshipServerNode, } from './features/relationship/server/nodes/RelationshipNode.js' - export { FixedToolbarFeature } from './features/toolbars/fixed/server/index.js' -export { InlineToolbarFeature } from './features/toolbars/inline/server/index.js' +export { InlineToolbarFeature } from './features/toolbars/inline/server/index.js' export type { ToolbarGroup, ToolbarGroupItem } from './features/toolbars/types.js' export type { BaseClientFeatureProps, @@ -942,6 +943,7 @@ export type { SanitizedClientFeatures, SanitizedPlugin, } from './features/typesClient.js' + export type { AfterChangeNodeHook, AfterChangeNodeHookArgs, @@ -967,37 +969,36 @@ export type { export { createNode } from './features/typeUtilities.js' // Only useful in feature.server.ts export { UploadFeature } from './features/upload/server/feature.server.js' - export type { UploadFeatureProps } from './features/upload/server/feature.server.js' -export { type UploadData, UploadServerNode } from './features/upload/server/nodes/UploadNode.js' +export { type UploadData, UploadServerNode } from './features/upload/server/nodes/UploadNode.js' export type { EditorConfigContextType } from './lexical/config/client/EditorConfigProvider.js' + export { defaultEditorConfig, defaultEditorFeatures, defaultEditorLexicalConfig, } from './lexical/config/server/default.js' - export { loadFeatures, sortFeaturesForOptimalLoading } from './lexical/config/server/loader.js' + export { sanitizeServerEditorConfig, sanitizeServerFeatures, } from './lexical/config/server/sanitize.js' - export type { ClientEditorConfig, SanitizedClientEditorConfig, SanitizedServerEditorConfig, ServerEditorConfig, } from './lexical/config/types.js' -export { getEnabledNodes, getEnabledNodesFromServerNodes } from './lexical/nodes/index.js' export type { AdapterProps } +export { getEnabledNodes, getEnabledNodesFromServerNodes } from './lexical/nodes/index.js' + export type { SlashMenuGroup, SlashMenuItem, } from './lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/types.js' - export { DETAIL_TYPE_TO_DETAIL, DOUBLE_LINE_BREAK, @@ -1012,26 +1013,26 @@ export { TEXT_TYPE_TO_FORMAT, TEXT_TYPE_TO_MODE, } from './lexical/utils/nodeFormat.js' + export { sanitizeUrl, validateUrl } from './lexical/utils/url.js' export type * from './nodeTypes.js' export { $convertFromMarkdownString } from './packages/@lexical/markdown/index.js' - export { defaultRichTextValue } from './populateGraphQL/defaultValue.js' export { populate } from './populateGraphQL/populate.js' + export type { LexicalEditorProps, LexicalFieldAdminProps, LexicalRichTextAdapter } from './types.js' export { createServerFeature } from './utilities/createServerFeature.js' - export { editorConfigFactory } from './utilities/editorConfigFactory.js' export type { FieldsDrawerProps } from './utilities/fieldsDrawer/Drawer.js' export { extractPropsFromJSXPropsString } from './utilities/jsx/extractPropsFromJSXPropsString.js' + export { extractFrontmatter, frontmatterToObject, objectToFrontmatter, propsToJSXString, } from './utilities/jsx/jsx.js' - export { upgradeLexicalData } from './utilities/upgradeLexicalData/index.js'