From 4003a8023c2d0af1d382130390c47211ab7c015b Mon Sep 17 00:00:00 2001 From: James Date: Thu, 22 Feb 2024 10:55:51 -0500 Subject: [PATCH] chore: defines ClientFunction pattern --- .../richtext-slate/src/field/RichText.tsx | 113 +++++++----------- .../src/field/createFeatureMap.ts | 49 ++++++++ .../src/field/elements/link/WithLinks.tsx | 24 ++++ .../src/field/elements/link/index.tsx | 4 +- .../src/field/elements/link/utilities.tsx | 15 --- packages/richtext-slate/src/field/index.tsx | 72 +++++++++-- packages/richtext-slate/src/field/types.ts | 1 + packages/richtext-slate/src/index.tsx | 14 ++- packages/richtext-slate/src/types.ts | 7 +- .../src/utilities/useSlatePlugin.tsx | 13 ++ packages/ui/src/exports/providers.ts | 1 + .../ui/src/providers/ClientFunction/index.tsx | 45 +++++++ packages/ui/src/providers/Root/index.tsx | 93 +++++++------- 13 files changed, 309 insertions(+), 142 deletions(-) create mode 100644 packages/richtext-slate/src/field/createFeatureMap.ts create mode 100644 packages/richtext-slate/src/field/elements/link/WithLinks.tsx create mode 100644 packages/richtext-slate/src/utilities/useSlatePlugin.tsx create mode 100644 packages/ui/src/providers/ClientFunction/index.tsx diff --git a/packages/richtext-slate/src/field/RichText.tsx b/packages/richtext-slate/src/field/RichText.tsx index dc9d27341..33ea1a612 100644 --- a/packages/richtext-slate/src/field/RichText.tsx +++ b/packages/richtext-slate/src/field/RichText.tsx @@ -1,6 +1,6 @@ 'use client' -import type { BaseEditor, BaseOperation } from 'slate' +import type { BaseEditor, BaseOperation, Editor } from 'slate' import type { HistoryEditor } from 'slate-history' import type { ReactEditor } from 'slate-react' @@ -13,12 +13,20 @@ import { withHistory } from 'slate-history' import { Editable, Slate, withReact } from 'slate-react' import type { FormFieldBase } from '../../../ui/src/forms/fields/shared' -import type { ElementNode, TextNode } from '../types' +import type { + ElementNode, + RichTextCustomElement, + RichTextPlugin, + RichTextPluginComponent, + TextNode, +} from '../types' import type { EnabledFeatures } from './types' import { withCondition } from '../../../ui/src/forms/withCondition' +import { useClientFunctions } from '../../../ui/src/providers/ClientFunction' import { defaultRichTextValue } from '../data/defaultValue' import { richTextValidate } from '../data/validation' +import { createFeatureMap } from './createFeatureMap' import listTypes from './elements/listTypes' import hotkeys from './hotkeys' import './index.scss' @@ -42,7 +50,10 @@ declare module 'slate' { const RichText: React.FC< FormFieldBase & { + elements: EnabledFeatures['elements'] + leaves: EnabledFeatures['leaves'] name: string + plugins: RichTextPlugin[] richTextComponentMap: Map } > = (props) => { @@ -52,57 +63,18 @@ const RichText: React.FC< Error, Label, className, + elements, + leaves, path: pathFromProps, placeholder, + plugins, readOnly, required, - richTextComponentMap, style, validate = richTextValidate, width, } = props - const [{ elements, leaves }] = useState(() => { - const features: EnabledFeatures = { - elements: {}, - leaves: {}, - } - - for (const [key, value] of richTextComponentMap) { - if (key.startsWith('leaf.button.') || key.startsWith('leaf.component.')) { - const leafName = key.replace('leaf.button.', '').replace('leaf.component.', '') - - if (!features.leaves[leafName]) { - features.leaves[leafName] = { - name: leafName, - Button: null, - Leaf: null, - } - } - - if (key.startsWith('leaf.button.')) features.leaves[leafName].Button = value - if (key.startsWith('leaf.component.')) features.leaves[leafName].Leaf = value - } - - if (key.startsWith('element.button.') || key.startsWith('element.component.')) { - const elementName = key.replace('element.button.', '').replace('element.component.', '') - - if (!features.elements[elementName]) { - features.elements[elementName] = { - name: elementName, - Button: null, - Element: null, - } - } - - if (key.startsWith('element.button.')) features.elements[elementName].Button = value - if (key.startsWith('element.component.')) features.elements[elementName].Element = value - } - } - - return features - }) - const { i18n } = useTranslation() const editorRef = useRef(null) const toolbarRef = useRef(null) @@ -124,6 +96,20 @@ const RichText: React.FC< validate: memoizedValidate, }) + const editor = useMemo(() => { + let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor()))) + + CreatedEditor = withHTML(CreatedEditor) + + if (plugins.length) { + CreatedEditor = plugins.reduce((editorWithPlugins, plugin) => { + return plugin(editorWithPlugins) + }, CreatedEditor) + } + + return CreatedEditor + }, [plugins]) + const renderElement = useCallback( ({ attributes, children, element }) => { // return
{children}
@@ -228,34 +214,13 @@ const RichText: React.FC< [path, props, schemaPath, leaves], ) - const classes = [ - baseClass, - 'field-type', - className, - showError && 'error', - readOnly && `${baseClass}--read-only`, - ] - .filter(Boolean) - .join(' ') - - const editor = useMemo(() => { - let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor()))) - - CreatedEditor = withHTML(CreatedEditor) - // CreatedEditor = enablePlugins(CreatedEditor, elements) - // CreatedEditor = enablePlugins(CreatedEditor, leaves) - - return CreatedEditor - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [path]) - // All slate changes fire the onChange event // including selection changes // so we will filter the set_selection operations out // and only fire setValue when onChange is because of value const handleChange = useCallback( (val: unknown) => { - const ops = editor.operations.filter((o: BaseOperation) => { + const ops = editor?.operations.filter((o: BaseOperation) => { if (o) { return o.type !== 'set_selection' } @@ -268,7 +233,7 @@ const RichText: React.FC< } } }, - [editor.operations, readOnly, setValue, value], + [editor?.operations, readOnly, setValue, value], ) useEffect(() => { @@ -306,6 +271,16 @@ const RichText: React.FC< // } // }, [path, editor]); + const classes = [ + baseClass, + 'field-type', + className, + showError && 'error', + readOnly && `${baseClass}--read-only`, + ] + .filter(Boolean) + .join(' ') + let valueToRender = value if (typeof valueToRender === 'string') { @@ -345,7 +320,7 @@ const RichText: React.FC< ref={toolbarRef} >
- {Object.values(elements).map((element, i) => { + {Object.values(elements).map((element) => { const Button = element?.Button if (Button) { @@ -363,7 +338,7 @@ const RichText: React.FC< return null })} - {Object.values(leaves).map((leaf, i) => { + {Object.values(leaves).map((leaf) => { const Button = leaf?.Button if (Button) { diff --git a/packages/richtext-slate/src/field/createFeatureMap.ts b/packages/richtext-slate/src/field/createFeatureMap.ts new file mode 100644 index 000000000..05a457f9f --- /dev/null +++ b/packages/richtext-slate/src/field/createFeatureMap.ts @@ -0,0 +1,49 @@ +import type { EnabledFeatures } from './types' + +export const createFeatureMap = ( + richTextComponentMap: Map, +): EnabledFeatures => { + const features: EnabledFeatures = { + elements: {}, + leaves: {}, + plugins: [], + } + + for (const [key, value] of richTextComponentMap) { + if (key.startsWith('leaf.button') || key.startsWith('leaf.component.')) { + const leafName = key.replace('leaf.button.', '').replace('leaf.component.', '') + + if (!features.leaves[leafName]) { + features.leaves[leafName] = { + name: leafName, + Button: null, + Leaf: null, + } + } + + if (key.startsWith('leaf.button.')) features.leaves[leafName].Button = value + if (key.startsWith('leaf.component.')) features.leaves[leafName].Leaf = value + } + + if (key.startsWith('element.button.') || key.startsWith('element.component.')) { + const elementName = key.replace('element.button.', '').replace('element.component.', '') + + if (!features.elements[elementName]) { + features.elements[elementName] = { + name: elementName, + Button: null, + Element: null, + } + } + + if (key.startsWith('element.button.')) features.elements[elementName].Button = value + if (key.startsWith('element.component.')) features.elements[elementName].Element = value + } + + if (key.startsWith('leaf.plugin.') || key.startsWith('element.plugin.')) { + features.plugins.push(value) + } + } + + return features +} diff --git a/packages/richtext-slate/src/field/elements/link/WithLinks.tsx b/packages/richtext-slate/src/field/elements/link/WithLinks.tsx new file mode 100644 index 000000000..ae88cf809 --- /dev/null +++ b/packages/richtext-slate/src/field/elements/link/WithLinks.tsx @@ -0,0 +1,24 @@ +'use client' + +import type React from 'react' +import type { Editor } from 'slate' + +import { useSlatePlugin } from '../../../utilities/useSlatePlugin' + +export const WithLinks: React.FC = () => { + useSlatePlugin('withLinks', (incomingEditor: Editor): Editor => { + const editor = incomingEditor + const { isInline } = editor + + editor.isInline = (element) => { + if (element.type === 'link') { + return true + } + + return isInline(element) + } + + return editor + }) + return null +} diff --git a/packages/richtext-slate/src/field/elements/link/index.tsx b/packages/richtext-slate/src/field/elements/link/index.tsx index 8851ae17e..39e09dc34 100644 --- a/packages/richtext-slate/src/field/elements/link/index.tsx +++ b/packages/richtext-slate/src/field/elements/link/index.tsx @@ -2,13 +2,13 @@ import type { RichTextCustomElement } from '../../..' import { LinkButton } from './Button' import { LinkElement } from './Element' -import { withLinks } from './utilities' +import { WithLinks } from './WithLinks' const link: RichTextCustomElement = { name: 'link', Button: LinkButton, Element: LinkElement, - plugins: [withLinks], + plugins: [WithLinks], } export default link diff --git a/packages/richtext-slate/src/field/elements/link/utilities.tsx b/packages/richtext-slate/src/field/elements/link/utilities.tsx index b3df862f7..6508099c1 100644 --- a/packages/richtext-slate/src/field/elements/link/utilities.tsx +++ b/packages/richtext-slate/src/field/elements/link/utilities.tsx @@ -30,21 +30,6 @@ export const wrapLink = (editor: Editor): void => { } } -export const withLinks = (incomingEditor: Editor): Editor => { - const editor = incomingEditor - const { isInline } = editor - - editor.isInline = (element) => { - if (element.type === 'link') { - return true - } - - return isInline(element) - } - - return editor -} - /** * This function is run to enrich the basefields which every link has with potential, custom user-added fields. */ diff --git a/packages/richtext-slate/src/field/index.tsx b/packages/richtext-slate/src/field/index.tsx index 4ac9be4b2..aa63ee350 100644 --- a/packages/richtext-slate/src/field/index.tsx +++ b/packages/richtext-slate/src/field/index.tsx @@ -1,16 +1,74 @@ 'use client' import { ShimmerEffect } from '@payloadcms/ui' -import React, { Suspense, lazy } from 'react' +import React, { Suspense, lazy, useEffect, useState } from 'react' -import type { FieldProps } from '../types' +import type { FormFieldBase } from '../../../ui/src/forms/fields/shared' +import type { RichTextPlugin } from '../types' +import type { EnabledFeatures } from './types' + +import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider' +import { useClientFunctions } from '../../../ui/src/providers/ClientFunction' +import { createFeatureMap } from './createFeatureMap' // @ts-expect-error Just TypeScript being broken // TODO: Open TypeScript issue const RichTextEditor = lazy(() => import('./RichText')) -const RichTextField: React.FC = (props) => ( - }> - - -) +const RichTextField: React.FC< + FormFieldBase & { + name: string + richTextComponentMap: Map + } +> = (props) => { + const { richTextComponentMap } = props + + const { schemaPath } = useFieldPath() + const clientFunctions = useClientFunctions() + const [hasLoadedPlugins, setHasLoadedPlugins] = useState(false) + + const [features] = useState(() => { + return createFeatureMap(richTextComponentMap) + }) + + const [plugins, setPlugins] = useState([]) + + useEffect(() => { + if (!hasLoadedPlugins) { + const plugins: RichTextPlugin[] = [] + + Object.entries(clientFunctions).forEach(([key, plugin]) => { + if (key.startsWith(`slatePlugin.${schemaPath}.`)) { + plugins.push(plugin) + } + }) + + if (plugins.length === features.plugins.length) { + setPlugins(plugins) + setHasLoadedPlugins(true) + } + } + }, [hasLoadedPlugins, clientFunctions, schemaPath, features.plugins.length]) + + if (!hasLoadedPlugins) { + return ( + + {Array.isArray(features.plugins) && + features.plugins.map((Plugin, i) => { + return {Plugin} + })} + + ) + } + + return ( + }> + + + ) +} export default RichTextField diff --git a/packages/richtext-slate/src/field/types.ts b/packages/richtext-slate/src/field/types.ts index f96ee64bf..601eb11b5 100644 --- a/packages/richtext-slate/src/field/types.ts +++ b/packages/richtext-slate/src/field/types.ts @@ -13,4 +13,5 @@ export type EnabledFeatures = { name: string } } + plugins: React.ReactNode[] } diff --git a/packages/richtext-slate/src/index.tsx b/packages/richtext-slate/src/index.tsx index 86d42b6be..6c588c561 100644 --- a/packages/richtext-slate/src/index.tsx +++ b/packages/richtext-slate/src/index.tsx @@ -48,6 +48,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter) componentMap.set(`leaf.component.${leafObject.name}`, ) + + if (Array.isArray(leafObject.plugins)) { + leafObject.plugins.forEach((Plugin, i) => { + componentMap.set(`leaf.plugin.${leafObject.name}.${i}`, ) + }) + } } }) ;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => { @@ -66,6 +72,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter) componentMap.set(`element.component.${element.name}`, ) + if (Array.isArray(element.plugins)) { + element.plugins.forEach((Plugin, i) => { + componentMap.set(`element.plugin.${element.name}.${i}`, ) + }) + } + switch (element.name) { case 'link': { const linkFields = sanitizeFields({ @@ -84,7 +96,7 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter Editor +export type RichTextPluginComponent = React.ComponentType +export type RichTextPlugin = (editor: Editor) => Editor export type RichTextCustomElement = { Button?: React.ComponentType Element: React.ComponentType name: string - plugins?: RichTextPlugin[] + plugins?: RichTextPluginComponent[] } export type RichTextCustomLeaf = { Button: React.ComponentType Leaf: React.ComponentType name: string - plugins?: RichTextPlugin[] + plugins?: RichTextPluginComponent[] } export type RichTextElement = diff --git a/packages/richtext-slate/src/utilities/useSlatePlugin.tsx b/packages/richtext-slate/src/utilities/useSlatePlugin.tsx new file mode 100644 index 000000000..6990f06ea --- /dev/null +++ b/packages/richtext-slate/src/utilities/useSlatePlugin.tsx @@ -0,0 +1,13 @@ +import type { Editor } from 'slate' + +import { useAddClientFunction } from '@payloadcms/ui/providers' + +import { useFieldPath } from '../../../ui/src/forms/FieldPathProvider' + +type Plugin = (editor: Editor) => Editor + +export const useSlatePlugin = (key: string, plugin: Plugin) => { + const { schemaPath } = useFieldPath() + + useAddClientFunction(`slatePlugin.${schemaPath}.${key}`, plugin) +} diff --git a/packages/ui/src/exports/providers.ts b/packages/ui/src/exports/providers.ts index 7ae4c65b0..b2db2d27f 100644 --- a/packages/ui/src/exports/providers.ts +++ b/packages/ui/src/exports/providers.ts @@ -17,3 +17,4 @@ export { CustomProvider } from '../providers/CustomProvider' export { useComponentMap } from '../providers/ComponentMapProvider' export type { IComponentMapContext } from '../providers/ComponentMapProvider' export { SetDocumentInfo } from '../providers/DocumentInfo/SetDocumentInfo' +export { ClientFunctionProvider, useAddClientFunction } from '../providers/ClientFunction' diff --git a/packages/ui/src/providers/ClientFunction/index.tsx b/packages/ui/src/providers/ClientFunction/index.tsx new file mode 100644 index 000000000..4844b015f --- /dev/null +++ b/packages/ui/src/providers/ClientFunction/index.tsx @@ -0,0 +1,45 @@ +'use client' +import React from 'react' + +type AddClientFunctionContextType = (func: any) => void +type ClientFunctionsContextType = Record + +const AddClientFunctionContext = React.createContext(() => null) +const ClientFunctionsContext = React.createContext({}) + +type AddFunctionArgs = { key: string; func: any } + +export const ClientFunctionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [clientFunctions, setClientFunctions] = React.useState({}) + + const addClientFunction = React.useCallback((args: AddFunctionArgs) => { + setClientFunctions((state) => { + const newState = { ...state } + newState[args.key] = args.func + return newState + }) + }, []) + + return ( + + + {children} + + + ) +} + +export const useAddClientFunction = (key: string, func: any) => { + const addClientFunction = React.useContext(AddClientFunctionContext) + + React.useEffect(() => { + addClientFunction({ + key, + func, + }) + }, [func, key]) +} + +export const useClientFunctions = () => { + return React.useContext(ClientFunctionsContext) +} diff --git a/packages/ui/src/providers/Root/index.tsx b/packages/ui/src/providers/Root/index.tsx index e83263fef..84327f633 100644 --- a/packages/ui/src/providers/Root/index.tsx +++ b/packages/ui/src/providers/Root/index.tsx @@ -23,6 +23,7 @@ import { ComponentMapProvider } from '../ComponentMapProvider' import { SearchParamsProvider } from '../SearchParams' import { ParamsProvider } from '../Params' import { DocumentInfoProvider } from '../DocumentInfo' +import { ClientFunctionProvider } from '../ClientFunction' type Props = { config: ClientConfig @@ -47,52 +48,54 @@ export const RootProvider: React.FC = ({ - - + - - - - - - - - - - - - - - - {children} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + {children} + + + + + + + + + + + + + + + + + +