From 4dc6c09347aed73d12e21b92f5c66c6ed19ccb19 Mon Sep 17 00:00:00 2001 From: Alessio Gravili Date: Sat, 14 Oct 2023 13:36:32 +0200 Subject: [PATCH] feat(richtext-lexical): SlateToLexical migration feature --- packages/richtext-lexical/src/cell/index.tsx | 25 +++- packages/richtext-lexical/src/field/Field.tsx | 11 +- .../converter/converters/heading.ts | 25 ++++ .../converter/converters/indent.ts | 65 +++++++++ .../converter/converters/link.ts | 29 ++++ .../converter/converters/listItem.ts | 26 ++++ .../converter/converters/orderedList.ts | 27 ++++ .../converter/converters/relationship.ts | 17 +++ .../converter/converters/unknown.ts | 27 ++++ .../converter/converters/unorderedList.ts | 27 ++++ .../converter/converters/upload.ts | 20 +++ .../converter/defaultConverters.ts | 23 +++ .../SlateToLexical/converter/index.ts | 137 ++++++++++++++++++ .../SlateToLexical/converter/types.ts | 22 +++ .../migrations/SlateToLexical/index.ts | 56 +++++++ .../nodes/unknownConvertedNode/index.scss | 16 ++ .../nodes/unknownConvertedNode/index.tsx | 97 +++++++++++++ .../src/field/features/types.ts | 28 ++++ .../src/field/lexical/LexicalProvider.tsx | 11 +- .../src/field/lexical/config/sanitize.ts | 13 ++ .../src/field/lexical/utils/nodeFormat.ts | 124 ++++++++++++++++ packages/richtext-lexical/src/index.ts | 15 ++ test/fields/collections/Lexical/index.ts | 1 + 23 files changed, 837 insertions(+), 5 deletions(-) create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/index.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/types.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.scss create mode 100644 packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.tsx create mode 100644 packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts diff --git a/packages/richtext-lexical/src/cell/index.tsx b/packages/richtext-lexical/src/cell/index.tsx index 0227e6d3e3..f493c047b3 100644 --- a/packages/richtext-lexical/src/cell/index.tsx +++ b/packages/richtext-lexical/src/cell/index.tsx @@ -16,17 +16,38 @@ export const RichTextCell: React.FC< const [preview, setPreview] = React.useState('Loading...') useEffect(() => { - if (data == null) { + let dataToUse = data + if (dataToUse == null) { setPreview('') return } + + // Transform data through load hooks + if (editorConfig?.features?.hooks?.load?.length) { + editorConfig.features.hooks.load.forEach((hook) => { + dataToUse = hook({ incomingEditorState: dataToUse }) + }) + } + + // If data is from Slate and not Lexical + if (dataToUse && Array.isArray(dataToUse) && !('root' in dataToUse)) { + setPreview('') + return + } + + // If data is from payload-plugin-lexical + if (dataToUse && 'jsonContent' in dataToUse) { + setPreview('') + return + } + // initialize headless editor const headlessEditor = createHeadlessEditor({ namespace: editorConfig.lexical.namespace, nodes: getEnabledNodes({ editorConfig }), theme: editorConfig.lexical.theme, }) - headlessEditor.setEditorState(headlessEditor.parseEditorState(data)) + headlessEditor.setEditorState(headlessEditor.parseEditorState(dataToUse)) const textContent = headlessEditor.getEditorState().read(() => { diff --git a/packages/richtext-lexical/src/field/Field.tsx b/packages/richtext-lexical/src/field/Field.tsx index 6ee0abcd7b..1eb7cd6a25 100644 --- a/packages/richtext-lexical/src/field/Field.tsx +++ b/packages/richtext-lexical/src/field/Field.tsx @@ -89,9 +89,16 @@ const RichText: React.FC = (props) => { fieldProps={props} initialState={initialValue} onChange={(editorState, editor, tags) => { - const json = editorState.toJSON() + let serializedEditorState = editorState.toJSON() - setValue(json) + // Transform state through save hooks + if (editorConfig?.features?.hooks?.save?.length) { + editorConfig.features.hooks.save.forEach((hook) => { + serializedEditorState = hook({ incomingEditorState: serializedEditorState }) + }) + } + + setValue(serializedEditorState) }} readOnly={readOnly} setValue={setValue} 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 new file mode 100644 index 0000000000..ac031c6887 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/heading.ts @@ -0,0 +1,25 @@ +import type { SerializedHeadingNode } from '@lexical/rich-text' + +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const HeadingConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'heading', + slateNodes: slateNode.children || [], + }), + direction: 'ltr', + format: '', + indent: 0, + tag: slateNode.type as 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6', // Slate puts the tag (h1 / h2 / ...) inside of node.type + type: 'heading', + version: 1, + } as const as SerializedHeadingNode + }, + nodeTypes: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], +} 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 new file mode 100644 index 0000000000..b5e7212977 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/indent.ts @@ -0,0 +1,65 @@ +import type { SerializedLexicalNode, SerializedParagraphNode } from 'lexical' + +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const IndentConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + console.log('slateToLexical > IndentConverter > converter', JSON.stringify(slateNode, null, 2)) + const convertChildren = (node: any, indentLevel: number = 0): SerializedLexicalNode => { + if ( + (node?.type && (!node.children || node.type !== 'indent')) || + (!node?.type && node?.text) + ) { + console.log( + 'slateToLexical > IndentConverter > convertChildren > node', + JSON.stringify(node, null, 2), + ) + console.log( + 'slateToLexical > IndentConverter > convertChildren > nodeOutput', + JSON.stringify( + convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'indent', + slateNodes: [node], + }), + + null, + 2, + ), + ) + + return { + ...convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'indent', + slateNodes: [node], + })[0], + indent: indentLevel, + } as const as SerializedLexicalNode + } + + const children = node.children.map((child: any) => convertChildren(child, indentLevel + 1)) + console.log('slateToLexical > IndentConverter > children', JSON.stringify(children, null, 2)) + return { + children: children, + direction: 'ltr', + format: '', + indent: indentLevel, + type: 'paragraph', + version: 1, + } as const as SerializedParagraphNode + } + + console.log( + 'slateToLexical > IndentConverter > output', + JSON.stringify(convertChildren(slateNode), null, 2), + ) + + return convertChildren(slateNode) + }, + nodeTypes: ['indent'], +} 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 new file mode 100644 index 0000000000..80458d90b0 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/link.ts @@ -0,0 +1,29 @@ +import type { SerializedLinkNode } from '../../../../Link/nodes/LinkNode' +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const LinkConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'link', + slateNodes: slateNode.children || [], + }), + direction: 'ltr', + fields: { + doc: slateNode.doc || undefined, + linkType: slateNode.linkType || 'custom', + newTab: slateNode.newTab || false, + url: slateNode.url || undefined, + }, + format: '', + indent: 0, + type: 'link', + version: 1, + } as const as SerializedLinkNode + }, + nodeTypes: ['link'], +} 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 new file mode 100644 index 0000000000..1775803dab --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/listItem.ts @@ -0,0 +1,26 @@ +import type { SerializedListItemNode } from '@lexical/list' + +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const ListItemConverter: SlateNodeConverter = { + converter({ childIndex, converters, slateNode }) { + return { + checked: undefined, + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'listitem', + slateNodes: slateNode.children || [], + }), + direction: 'ltr', + format: '', + indent: 0, + type: 'listitem', + value: childIndex + 1, + version: 1, + } as const as SerializedListItemNode + }, + nodeTypes: ['li'], +} 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 new file mode 100644 index 0000000000..d3cef26504 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/orderedList.ts @@ -0,0 +1,27 @@ +import type { SerializedListNode } from '@lexical/list' + +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const OrderedListConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'list', + slateNodes: slateNode.children || [], + }), + direction: 'ltr', + format: '', + indent: 0, + listType: 'number', + start: 1, + tag: 'ol', + type: 'list', + version: 1, + } as const as SerializedListNode + }, + nodeTypes: ['ol'], +} 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 new file mode 100644 index 0000000000..d881e05385 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/relationship.ts @@ -0,0 +1,17 @@ +import type { SerializedRelationshipNode } from '../../../../../..' +import type { SlateNodeConverter } from '../types' + +export const RelationshipConverter: SlateNodeConverter = { + converter({ slateNode }) { + return { + format: '', + relationTo: slateNode.relationTo, + type: 'relationship', + value: { + id: slateNode?.value?.id || '', + }, + version: 1, + } as const as SerializedRelationshipNode + }, + nodeTypes: ['relationship'], +} 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 new file mode 100644 index 0000000000..05fa300eeb --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unknown.ts @@ -0,0 +1,27 @@ +import type { SerializedUnknownConvertedNode } from '../../nodes/unknownConvertedNode' +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const UnknownConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'unknownConverted', + slateNodes: slateNode.children || [], + }), + data: { + nodeData: slateNode, + nodeType: slateNode.type, + }, + direction: 'ltr', + format: '', + indent: 0, + type: 'unknownConverted', + version: 1, + } as const as SerializedUnknownConvertedNode + }, + nodeTypes: ['unknown'], +} 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 new file mode 100644 index 0000000000..fd82b3a7e2 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/unorderedList.ts @@ -0,0 +1,27 @@ +import type { SerializedListNode } from '@lexical/list' + +import type { SlateNodeConverter } from '../types' + +import { convertSlateNodesToLexical } from '..' + +export const UnorderedListConverter: SlateNodeConverter = { + converter({ converters, slateNode }) { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'list', + slateNodes: slateNode.children || [], + }), + direction: 'ltr', + format: '', + indent: 0, + listType: 'bullet', + start: 1, + tag: 'ul', + type: 'list', + version: 1, + } as const as SerializedListNode + }, + nodeTypes: ['ul'], +} 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 new file mode 100644 index 0000000000..a3b59d6a5c --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/converters/upload.ts @@ -0,0 +1,20 @@ +import type { SerializedUploadNode } from '../../../../../..' +import type { SlateNodeConverter } from '../types' + +export const UploadConverter: SlateNodeConverter = { + converter({ slateNode }) { + return { + fields: { + ...slateNode.fields, + }, + format: '', + relationTo: slateNode.relationTo, + type: 'upload', + value: { + id: slateNode.value?.id || '', + }, + version: 1, + } as const as SerializedUploadNode + }, + nodeTypes: ['upload'], +} 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 new file mode 100644 index 0000000000..d0f5ff75b0 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/defaultConverters.ts @@ -0,0 +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' + +export const defaultConverters: SlateNodeConverter[] = [ + UnknownConverter, + UploadConverter, + UnorderedListConverter, + OrderedListConverter, + RelationshipConverter, + ListItemConverter, + LinkConverter, + HeadingConverter, + IndentConverter, +] diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/index.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/index.ts new file mode 100644 index 0000000000..bf52b2ee46 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/index.ts @@ -0,0 +1,137 @@ +import type { + SerializedEditorState, + SerializedLexicalNode, + SerializedParagraphNode, + SerializedTextNode, +} from 'lexical' + +import type { SlateNode, SlateNodeConverter } from './types' + +import { NodeFormat } from '../../../../lexical/utils/nodeFormat' + +export function convertSlateToLexical({ + converters, + slateData, +}: { + converters: SlateNodeConverter[] + slateData: SlateNode[] +}): SerializedEditorState { + return { + root: { + children: convertSlateNodesToLexical({ + canContainParagraphs: true, + converters, + parentNodeType: 'root', + slateNodes: slateData, + }), + direction: 'ltr', + format: '', + indent: 0, + type: 'root', + version: 1, + }, + } +} + +export function convertSlateNodesToLexical({ + canContainParagraphs, + converters, + parentNodeType, + slateNodes, +}: { + canContainParagraphs: boolean + converters: SlateNodeConverter[] + /** + * Type of the parent lexical node (not the type of the original, parent slate type) + */ + parentNodeType: string + slateNodes: SlateNode[] +}): SerializedLexicalNode[] { + const unknownConverter = converters.find((converter) => converter.nodeTypes.includes('unknown')) + return ( + slateNodes.map((slateNode, i) => { + if (!('type' in slateNode)) { + if (canContainParagraphs) { + // This is a paragraph node. They do not have a type property in Slate + return convertParagraphNode(converters, slateNode) + } else { + // This is a simple text node. canContainParagraphs may be false if this is nested inside of a paragraph already, since paragraphs cannot contain paragraphs + return convertTextNode(slateNode) + } + } + if (slateNode.type === 'p') { + return convertParagraphNode(converters, slateNode) + } + + const converter = converters.find((converter) => converter.nodeTypes.includes(slateNode.type)) + + if (converter) { + return converter.converter({ childIndex: i, converters, parentNodeType, slateNode }) + } + + console.warn('slateToLexical > No converter found for node type: ' + slateNode.type) + return unknownConverter?.converter({ + childIndex: i, + converters, + parentNodeType, + slateNode, + }) + }) || [] + ) +} + +export function convertParagraphNode( + converters: SlateNodeConverter[], + node: SlateNode, +): SerializedParagraphNode { + return { + children: convertSlateNodesToLexical({ + canContainParagraphs: false, + converters, + parentNodeType: 'paragraph', + slateNodes: node.children || [], + }), + direction: 'ltr', + format: '', + indent: 0, + type: 'paragraph', + version: 1, + } +} +export function convertTextNode(node: SlateNode): SerializedTextNode { + return { + detail: 0, + format: convertNodeToFormat(node), + mode: 'normal', + style: '', + text: node.text, + type: 'text', + version: 1, + } +} + +export function convertNodeToFormat(node: SlateNode): number { + let format = 0 + if (node.bold) { + format = format | NodeFormat.IS_BOLD + } + if (node.italic) { + format = format | NodeFormat.IS_ITALIC + } + if (node.strikethrough) { + format = format | NodeFormat.IS_STRIKETHROUGH + } + if (node.underline) { + format = format | NodeFormat.IS_UNDERLINE + } + if (node.subscript) { + format = format | NodeFormat.IS_SUBSCRIPT + } + if (node.superscript) { + format = format | NodeFormat.IS_SUPERSCRIPT + } + if (node.code) { + format = format | NodeFormat.IS_CODE + } + return format +} diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/types.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/types.ts new file mode 100644 index 0000000000..3710efe87a --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/converter/types.ts @@ -0,0 +1,22 @@ +import type { SerializedLexicalNode } from 'lexical' + +export type SlateNodeConverter = { + converter: ({ + childIndex, + converters, + parentNodeType, + slateNode, + }: { + childIndex: number + converters: SlateNodeConverter[] + parentNodeType: string + slateNode: SlateNode + }) => T + nodeTypes: string[] +} + +export type SlateNode = { + [key: string]: any + children?: SlateNode[] + type?: string // doesn't always have type, e.g. for paragraphs +} diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts new file mode 100644 index 0000000000..6dc9ff010d --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/index.ts @@ -0,0 +1,56 @@ +import type { FeatureProvider } from '../../types' +import type { SlateNodeConverter } from './converter/types' + +import { convertSlateToLexical } from './converter' +import { defaultConverters } from './converter/defaultConverters' +import { UnknownConvertedNode } from './nodes/unknownConvertedNode' + +type Props = { + converters?: + | (({ defaultConverters }: { defaultConverters: SlateNodeConverter[] }) => SlateNodeConverter[]) + | SlateNodeConverter[] +} + +export const SlateToLexicalFeature = (props?: Props): FeatureProvider => { + if (!props) { + props = {} + } + + props.converters = + props?.converters && typeof props?.converters === 'function' + ? props.converters({ defaultConverters: defaultConverters }) + : (props?.converters as SlateNodeConverter[]) || defaultConverters + + return { + feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => { + return { + hooks: { + load({ incomingEditorState }) { + if ( + !incomingEditorState || + !Array.isArray(incomingEditorState) || + 'root' in incomingEditorState + ) { + // incomingEditorState null or not from Slate + return incomingEditorState + } + // Slate => convert to lexical + + return convertSlateToLexical({ + converters: props.converters as SlateNodeConverter[], + slateData: incomingEditorState, + }) + }, + }, + nodes: [ + { + node: UnknownConvertedNode, + type: UnknownConvertedNode.getType(), + }, + ], + props, + } + }, + key: 'slateToLexical', + } +} diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.scss b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.scss new file mode 100644 index 0000000000..7eedad7329 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.scss @@ -0,0 +1,16 @@ +@import 'payload/scss'; + +span.unknownConverted { + text-transform: uppercase; + font-family: 'Roboto Mono', monospace; + letter-spacing: 2px; + font-size: base(0.5); + margin: 0 0 base(1); + background: red; + color: white; + display: inline-block; + + div { + background: red; + } +} diff --git a/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.tsx b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.tsx new file mode 100644 index 0000000000..dffbc0cae3 --- /dev/null +++ b/packages/richtext-lexical/src/field/features/migrations/SlateToLexical/nodes/unknownConvertedNode/index.tsx @@ -0,0 +1,97 @@ +import type { SerializedLexicalNode, Spread } from 'lexical' + +import { addClassNamesToElement } from '@lexical/utils' +import { DecoratorNode, type EditorConfig, type LexicalNode, type NodeKey } from 'lexical' +import React from 'react' + +import './index.scss' + +export type UnknownConvertedNodeData = { + nodeData: unknown + nodeType: string +} + +export type SerializedUnknownConvertedNode = Spread< + { + data: UnknownConvertedNodeData + }, + SerializedLexicalNode +> + +/** @noInheritDoc */ +export class UnknownConvertedNode extends DecoratorNode { + __data: UnknownConvertedNodeData + + constructor({ data, key }: { data: UnknownConvertedNodeData; key?: NodeKey }) { + super(key) + this.__data = data + } + + static clone(node: UnknownConvertedNode): UnknownConvertedNode { + return new UnknownConvertedNode({ + data: node.__data, + key: node.__key, + }) + } + + static getType(): string { + return 'unknownConverted' + } + + static importJSON(serializedNode: SerializedUnknownConvertedNode): UnknownConvertedNode { + const node = $createUnknownConvertedNode({ data: serializedNode.data }) + return node + } + + canInsertTextAfter(): true { + return true + } + + canInsertTextBefore(): true { + return true + } + + createDOM(config: EditorConfig): HTMLElement { + const element = document.createElement('span') + addClassNamesToElement(element, 'unknownConverted') + return element + } + + decorate(): JSX.Element | null { + return
Unknown converted Slate node: {this.__data?.nodeType}
+ } + + exportJSON(): SerializedUnknownConvertedNode { + return { + data: this.__data, + type: this.getType(), + version: 1, + } + } + + // Mutation + + isInline(): boolean { + return true + } + + updateDOM(prevNode: UnknownConvertedNode, dom: HTMLElement): boolean { + return false + } +} + +export function $createUnknownConvertedNode({ + data, +}: { + data: UnknownConvertedNodeData +}): UnknownConvertedNode { + return new UnknownConvertedNode({ + data, + }) +} + +export function $isUnknownConvertedNode( + node: LexicalNode | null | undefined, +): node is UnknownConvertedNode { + return node instanceof UnknownConvertedNode +} diff --git a/packages/richtext-lexical/src/field/features/types.ts b/packages/richtext-lexical/src/field/features/types.ts index e711e18eaa..0277bc6fe0 100644 --- a/packages/richtext-lexical/src/field/features/types.ts +++ b/packages/richtext-lexical/src/field/features/types.ts @@ -51,6 +51,18 @@ export type Feature = { floatingSelectToolbar?: { sections: FloatingToolbarSection[] } + hooks?: { + load?: ({ + incomingEditorState, + }: { + incomingEditorState: SerializedEditorState + }) => SerializedEditorState + save?: ({ + incomingEditorState, + }: { + incomingEditorState: SerializedEditorState + }) => SerializedEditorState + } markdownTransformers?: Transformer[] nodes?: Array<{ afterReadPromises?: Array @@ -123,6 +135,22 @@ export type SanitizedFeatures = Required< floatingSelectToolbar: { sections: FloatingToolbarSection[] } + hooks: { + load: Array< + ({ + incomingEditorState, + }: { + incomingEditorState: SerializedEditorState + }) => SerializedEditorState + > + save: Array< + ({ + incomingEditorState, + }: { + incomingEditorState: SerializedEditorState + }) => SerializedEditorState + > + } plugins?: Array< | { // plugins are anything which is not directly part of the editor. Like, creating a command which creates a node, or opens a modal, or some other more "outside" functionality diff --git a/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx b/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx index 5d7ec3f19c..9eb10a7a39 100644 --- a/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx +++ b/packages/richtext-lexical/src/field/lexical/LexicalProvider.tsx @@ -21,7 +21,16 @@ export type LexicalProviderProps = { value: SerializedEditorState } export const LexicalProvider: React.FC = (props) => { - const { editorConfig, fieldProps, initialState, onChange, readOnly, setValue, value } = props + const { editorConfig, fieldProps, onChange, readOnly, setValue } = props + let { initialState, value } = props + + // Transform initialState through load hooks + if (editorConfig?.features?.hooks?.load?.length) { + editorConfig.features.hooks.load.forEach((hook) => { + initialState = hook({ incomingEditorState: initialState }) + value = hook({ incomingEditorState: value }) + }) + } if ( (value && Array.isArray(value) && !('root' in value)) || diff --git a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts index cf39b20c43..ad793c0a00 100644 --- a/packages/richtext-lexical/src/field/lexical/config/sanitize.ts +++ b/packages/richtext-lexical/src/field/lexical/config/sanitize.ts @@ -10,6 +10,10 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature floatingSelectToolbar: { sections: [], }, + hooks: { + load: [], + save: [], + }, markdownTransformers: [], nodes: [], plugins: [], @@ -21,6 +25,15 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature } features.forEach((feature) => { + if (feature.hooks) { + if (feature.hooks?.load?.length) { + sanitized.hooks.load = sanitized.hooks.load.concat(feature.hooks.load) + } + if (feature.hooks?.save?.length) { + sanitized.hooks.save = sanitized.hooks.save.concat(feature.hooks.save) + } + } + if (feature.nodes?.length) { sanitized.nodes = sanitized.nodes.concat(feature.nodes) feature.nodes.forEach((node) => { diff --git a/packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts b/packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts new file mode 100644 index 0000000000..5cb26c167c --- /dev/null +++ b/packages/richtext-lexical/src/field/lexical/utils/nodeFormat.ts @@ -0,0 +1,124 @@ +/* eslint-disable perfectionist/sort-objects */ +/* eslint-disable regexp/no-obscure-range */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +//This copy-and-pasted from lexical here here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts + +import type { ElementFormatType, TextFormatType } from 'lexical' +import type { TextDetailType, TextModeType } from 'lexical/nodes/LexicalTextNode' + +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +// DOM +export const NodeFormat = { + DOM_ELEMENT_TYPE: 1, + DOM_TEXT_TYPE: 3, + // Reconciling + NO_DIRTY_NODES: 0, + HAS_DIRTY_NODES: 1, + FULL_RECONCILE: 2, + // Text node modes + IS_NORMAL: 0, + IS_TOKEN: 1, + IS_SEGMENTED: 2, + IS_INERT: 3, + // Text node formatting + IS_BOLD: 1, + IS_ITALIC: 1 << 1, + IS_STRIKETHROUGH: 1 << 2, + IS_UNDERLINE: 1 << 3, + IS_CODE: 1 << 4, + IS_SUBSCRIPT: 1 << 5, + IS_SUPERSCRIPT: 1 << 6, + IS_HIGHLIGHT: 1 << 7, + // Text node details + IS_DIRECTIONLESS: 1, + IS_UNMERGEABLE: 1 << 1, + // Element node formatting + IS_ALIGN_LEFT: 1, + IS_ALIGN_CENTER: 2, + IS_ALIGN_RIGHT: 3, + IS_ALIGN_JUSTIFY: 4, + IS_ALIGN_START: 5, + IS_ALIGN_END: 6, +} as const + +export const IS_ALL_FORMATTING = + NodeFormat.IS_BOLD | + NodeFormat.IS_ITALIC | + NodeFormat.IS_STRIKETHROUGH | + NodeFormat.IS_UNDERLINE | + NodeFormat.IS_CODE | + NodeFormat.IS_SUBSCRIPT | + NodeFormat.IS_SUPERSCRIPT | + NodeFormat.IS_HIGHLIGHT + +// Reconciliation +export const NON_BREAKING_SPACE = '\u00A0' + +export const DOUBLE_LINE_BREAK = '\n\n' + +// For FF, we need to use a non-breaking space, or it gets composition +// in a stuck state. + +const RTL = '\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC' +const LTR = + 'A-Za-z\u00C0-\u00D6\u00D8-\u00F6' + + '\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C' + + '\uFE00-\uFE6F\uFEFD-\uFFFF' + +// eslint-disable-next-line no-misleading-character-class +export const RTL_REGEX = new RegExp('^[^' + LTR + ']*[' + RTL + ']') +// eslint-disable-next-line no-misleading-character-class +export const LTR_REGEX = new RegExp('^[^' + RTL + ']*[' + LTR + ']') + +export const TEXT_TYPE_TO_FORMAT: Record = { + bold: NodeFormat.IS_BOLD, + code: NodeFormat.IS_CODE, + highlight: NodeFormat.IS_HIGHLIGHT, + italic: NodeFormat.IS_ITALIC, + strikethrough: NodeFormat.IS_STRIKETHROUGH, + subscript: NodeFormat.IS_SUBSCRIPT, + superscript: NodeFormat.IS_SUPERSCRIPT, + underline: NodeFormat.IS_UNDERLINE, +} + +export const DETAIL_TYPE_TO_DETAIL: Record = { + directionless: NodeFormat.IS_DIRECTIONLESS, + unmergeable: NodeFormat.IS_UNMERGEABLE, +} + +export const ELEMENT_TYPE_TO_FORMAT: Record, number> = { + center: NodeFormat.IS_ALIGN_CENTER, + end: NodeFormat.IS_ALIGN_END, + justify: NodeFormat.IS_ALIGN_JUSTIFY, + left: NodeFormat.IS_ALIGN_LEFT, + right: NodeFormat.IS_ALIGN_RIGHT, + start: NodeFormat.IS_ALIGN_START, +} + +export const ELEMENT_FORMAT_TO_TYPE: Record = { + [NodeFormat.IS_ALIGN_CENTER]: 'center', + [NodeFormat.IS_ALIGN_END]: 'end', + [NodeFormat.IS_ALIGN_JUSTIFY]: 'justify', + [NodeFormat.IS_ALIGN_LEFT]: 'left', + [NodeFormat.IS_ALIGN_RIGHT]: 'right', + [NodeFormat.IS_ALIGN_START]: 'start', +} + +export const TEXT_MODE_TO_TYPE: Record = { + normal: NodeFormat.IS_NORMAL, + segmented: NodeFormat.IS_SEGMENTED, + token: NodeFormat.IS_TOKEN, +} + +export const TEXT_TYPE_TO_MODE: Record = { + [NodeFormat.IS_NORMAL]: 'normal', + [NodeFormat.IS_SEGMENTED]: 'segmented', + [NodeFormat.IS_TOKEN]: 'token', +} diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts index b626646298..b74d97ae87 100644 --- a/packages/richtext-lexical/src/index.ts +++ b/packages/richtext-lexical/src/index.ts @@ -153,6 +153,7 @@ export { IndentFeature } from './field/features/indent' export { CheckListFeature } from './field/features/lists/CheckList' export { OrderedListFeature } from './field/features/lists/OrderedList' export { UnoderedListFeature } from './field/features/lists/UnorderedList' +export { SlateToLexicalFeature } from './field/features/migrations/SlateToLexical' export type { AfterReadPromise, Feature, @@ -201,6 +202,20 @@ export { isHTMLElement } from './field/lexical/utils/guard' export { invariant } from './field/lexical/utils/invariant' export { joinClasses } from './field/lexical/utils/joinClasses' export { createBlockNode } from './field/lexical/utils/markdown/createBlockNode' +export { + DETAIL_TYPE_TO_DETAIL, + DOUBLE_LINE_BREAK, + ELEMENT_FORMAT_TO_TYPE, + ELEMENT_TYPE_TO_FORMAT, + IS_ALL_FORMATTING, + LTR_REGEX, + NON_BREAKING_SPACE, + NodeFormat, + RTL_REGEX, + TEXT_MODE_TO_TYPE, + TEXT_TYPE_TO_FORMAT, + TEXT_TYPE_TO_MODE, +} from './field/lexical/utils/nodeFormat' export { Point, isPoint } from './field/lexical/utils/point' export { Rect } from './field/lexical/utils/rect' export { setFloatingElemPosition } from './field/lexical/utils/setFloatingElemPosition' diff --git a/test/fields/collections/Lexical/index.ts b/test/fields/collections/Lexical/index.ts index 5fadb7f9c4..914442b0fa 100644 --- a/test/fields/collections/Lexical/index.ts +++ b/test/fields/collections/Lexical/index.ts @@ -21,6 +21,7 @@ export const LexicalFields: CollectionConfig = { slug: 'lexical-fields', admin: { useAsTitle: 'title', + listSearchableFields: ['title', 'richTextLexicalCustomFields'], }, access: { read: () => true,