chore: defines ClientFunction pattern

This commit is contained in:
James
2024-02-22 10:55:51 -05:00
parent 5720009e29
commit 4003a8023c
13 changed files with 309 additions and 142 deletions

View File

@@ -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<string, React.ReactNode>
}
> = (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<EnabledFeatures>(() => {
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 <div {...attributes}>{children}</div>
@@ -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}
>
<div className={`${baseClass}__toolbar-wrap`}>
{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) {

View File

@@ -0,0 +1,49 @@
import type { EnabledFeatures } from './types'
export const createFeatureMap = (
richTextComponentMap: Map<string, React.ReactNode>,
): 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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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<FieldProps> = (props) => (
<Suspense fallback={<ShimmerEffect height="35vh" />}>
<RichTextEditor {...props} />
</Suspense>
)
const RichTextField: React.FC<
FormFieldBase & {
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
> = (props) => {
const { richTextComponentMap } = props
const { schemaPath } = useFieldPath()
const clientFunctions = useClientFunctions()
const [hasLoadedPlugins, setHasLoadedPlugins] = useState(false)
const [features] = useState<EnabledFeatures>(() => {
return createFeatureMap(richTextComponentMap)
})
const [plugins, setPlugins] = useState<RichTextPlugin[]>([])
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 (
<React.Fragment>
{Array.isArray(features.plugins) &&
features.plugins.map((Plugin, i) => {
return <React.Fragment key={i}>{Plugin}</React.Fragment>
})}
</React.Fragment>
)
}
return (
<Suspense fallback={<ShimmerEffect height="35vh" />}>
<RichTextEditor
{...props}
elements={features.elements}
leaves={features.leaves}
plugins={plugins}
/>
</Suspense>
)
}
export default RichTextField

View File

@@ -13,4 +13,5 @@ export type EnabledFeatures = {
name: string
}
}
plugins: React.ReactNode[]
}

View File

@@ -48,6 +48,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
componentMap.set(`leaf.button.${leafObject.name}`, <LeafButton />)
componentMap.set(`leaf.component.${leafObject.name}`, <LeafComponent />)
if (Array.isArray(leafObject.plugins)) {
leafObject.plugins.forEach((Plugin, i) => {
componentMap.set(`leaf.plugin.${leafObject.name}.${i}`, <Plugin />)
})
}
}
})
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
@@ -66,6 +72,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
if (ElementButton) componentMap.set(`element.button.${element.name}`, <ElementButton />)
componentMap.set(`element.component.${element.name}`, <ElementComponent />)
if (Array.isArray(element.plugins)) {
element.plugins.forEach((Plugin, i) => {
componentMap.set(`element.plugin.${element.name}.${i}`, <Plugin />)
})
}
switch (element.name) {
case 'link': {
const linkFields = sanitizeFields({
@@ -84,7 +96,7 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
componentMap.set('link.fields', mappedFields)
return
break
}
case 'upload':

View File

@@ -11,20 +11,21 @@ export function nodeIsTextNode(node: ElementNode | TextNode): node is TextNode {
return 'text' in node
}
type RichTextPlugin = (editor: Editor) => Editor
export type RichTextPluginComponent = React.ComponentType
export type RichTextPlugin = (editor: Editor) => Editor
export type RichTextCustomElement = {
Button?: React.ComponentType<any>
Element: React.ComponentType<any>
name: string
plugins?: RichTextPlugin[]
plugins?: RichTextPluginComponent[]
}
export type RichTextCustomLeaf = {
Button: React.ComponentType<any>
Leaf: React.ComponentType<any>
name: string
plugins?: RichTextPlugin[]
plugins?: RichTextPluginComponent[]
}
export type RichTextElement =

View File

@@ -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)
}

View File

@@ -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'

View File

@@ -0,0 +1,45 @@
'use client'
import React from 'react'
type AddClientFunctionContextType = (func: any) => void
type ClientFunctionsContextType = Record<string, any>
const AddClientFunctionContext = React.createContext<AddClientFunctionContextType>(() => null)
const ClientFunctionsContext = React.createContext<ClientFunctionsContextType>({})
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 (
<AddClientFunctionContext.Provider value={addClientFunction}>
<ClientFunctionsContext.Provider value={clientFunctions}>
{children}
</ClientFunctionsContext.Provider>
</AddClientFunctionContext.Provider>
)
}
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)
}

View File

@@ -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<Props> = ({
<Fragment>
<ConfigProvider config={config}>
<ComponentMapProvider componentMap={componentMap}>
<TranslationProvider
lang={lang}
translations={translations}
fallbackLang={fallbackLang}
languageOptions={languageOptions}
>
<WindowInfoProvider
breakpoints={{
l: '(max-width: 1440px)',
m: '(max-width: 1024px)',
s: '(max-width: 768px)',
xs: '(max-width: 400px)',
}}
<ClientFunctionProvider>
<TranslationProvider
lang={lang}
translations={translations}
fallbackLang={fallbackLang}
languageOptions={languageOptions}
>
<ScrollInfoProvider>
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
<AuthProvider>
<PreferencesProvider>
<ThemeProvider>
<ParamsProvider>
<SearchParamsProvider>
<LocaleProvider>
<StepNavProvider>
<LoadingOverlayProvider>
<DocumentInfoProvider>
<DocumentEventsProvider>
<ActionsProvider>
<NavProvider>
<CustomProvider>{children}</CustomProvider>
</NavProvider>
</ActionsProvider>
</DocumentEventsProvider>
</DocumentInfoProvider>
</LoadingOverlayProvider>
</StepNavProvider>
</LocaleProvider>
</SearchParamsProvider>
</ParamsProvider>
</ThemeProvider>
</PreferencesProvider>
<ModalContainer />
</AuthProvider>
</ModalProvider>
</ScrollInfoProvider>
</WindowInfoProvider>
</TranslationProvider>
<WindowInfoProvider
breakpoints={{
l: '(max-width: 1440px)',
m: '(max-width: 1024px)',
s: '(max-width: 768px)',
xs: '(max-width: 400px)',
}}
>
<ScrollInfoProvider>
<ModalProvider classPrefix="payload" transTime={0} zIndex="var(--z-modal)">
<AuthProvider>
<PreferencesProvider>
<ThemeProvider>
<ParamsProvider>
<SearchParamsProvider>
<LocaleProvider>
<StepNavProvider>
<LoadingOverlayProvider>
<DocumentInfoProvider>
<DocumentEventsProvider>
<ActionsProvider>
<NavProvider>
<CustomProvider>{children}</CustomProvider>
</NavProvider>
</ActionsProvider>
</DocumentEventsProvider>
</DocumentInfoProvider>
</LoadingOverlayProvider>
</StepNavProvider>
</LocaleProvider>
</SearchParamsProvider>
</ParamsProvider>
</ThemeProvider>
</PreferencesProvider>
<ModalContainer />
</AuthProvider>
</ModalProvider>
</ScrollInfoProvider>
</WindowInfoProvider>
</TranslationProvider>
</ClientFunctionProvider>
</ComponentMapProvider>
</ConfigProvider>
<ToastContainer icon={false} position="bottom-center" transition={Slide} />