chore: server-side rendered rich text elements

This commit is contained in:
James
2024-02-21 17:34:53 -05:00
parent 04e2d1a89a
commit 5720009e29
56 changed files with 905 additions and 552 deletions

View File

@@ -33,7 +33,14 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
const fieldSchemaMap = getFieldSchemaMap(req.payload.config) const fieldSchemaMap = getFieldSchemaMap(req.payload.config)
const { id, operation, docPreferences, formState, schemaPath } = reqData as BuildFormStateArgs const {
id,
operation,
docPreferences,
formState,
data: incomingData,
schemaPath,
} = reqData as BuildFormStateArgs
const schemaPathSegments = schemaPath.split('.') const schemaPathSegments = schemaPath.split('.')
let fieldSchema: Field[] let fieldSchema: Field[]
@@ -59,7 +66,7 @@ export const buildFormState = async ({ req }: { req: PayloadRequest }) => {
) )
} }
const data = reduceFieldsToValues(formState, true) const data = incomingData || reduceFieldsToValues(formState, true)
const result = await buildStateFromSchema({ const result = await buildStateFromSchema({
id, id,

View File

@@ -1,6 +1,7 @@
import type { JSONSchema4 } from 'json-schema' import type { JSONSchema4 } from 'json-schema'
import type { RichTextField, Validate } from '../fields/config/types' import type { SanitizedConfig } from '../config/types'
import type { Field, RichTextField, Validate } from '../fields/config/types'
import type { PayloadRequest, RequestContext } from '../types' import type { PayloadRequest, RequestContext } from '../types'
import type { CellComponentProps } from './elements/Cell' import type { CellComponentProps } from './elements/Cell'
@@ -26,7 +27,15 @@ type RichTextAdapterBase<
incomingEditorState: Value incomingEditorState: Value
siblingDoc: Record<string, unknown> siblingDoc: Record<string, unknown>
}) => Promise<void> | null }) => Promise<void> | null
generateComponentMap: () => Map<string, React.ReactNode> | Promise<Map<string, React.ReactNode>> generateComponentMap: (args: {
config: SanitizedConfig
schemaPath: string
}) => Map<string, React.ReactNode>
generateSchemaMap: (args: {
config: SanitizedConfig
schemaMap: Map<string, Field[]>
schemaPath: string
}) => Map<string, Field[]>
outputSchema?: ({ outputSchema?: ({
field, field,
isRequired, isRequired,

View File

@@ -6,13 +6,14 @@ import type {
} from '../../collections/config/types' } from '../../collections/config/types'
import type { PaginatedDocs, TypeWithVersion } from '../../exports/database' import type { PaginatedDocs, TypeWithVersion } from '../../exports/database'
import type { SanitizedGlobalConfig } from '../../globals/config/types' import type { SanitizedGlobalConfig } from '../../globals/config/types'
import type { DocumentPreferences } from '../../preferences/types'
export type DocumentInfoContext = { export type DocumentInfoContext = {
collectionSlug?: SanitizedCollectionConfig['slug'] collectionSlug?: SanitizedCollectionConfig['slug']
docConfig?: SanitizedCollectionConfig | SanitizedGlobalConfig docConfig?: SanitizedCollectionConfig | SanitizedGlobalConfig
docPermissions: DocumentPermissions docPermissions: DocumentPermissions
getDocPermissions: () => Promise<void> getDocPermissions: () => Promise<void>
getDocPreferences: () => Promise<{ [key: string]: unknown }> getDocPreferences: () => Promise<DocumentPreferences>
getVersions: () => Promise<void> getVersions: () => Promise<void>
globalSlug?: SanitizedGlobalConfig['slug'] globalSlug?: SanitizedGlobalConfig['slug']
id?: number | string id?: number | string

View File

@@ -4,7 +4,6 @@ import type { SendMailOptions } from 'nodemailer'
import type pino from 'pino' import type pino from 'pino'
import crypto from 'crypto' import crypto from 'crypto'
import path from 'path'
import type { AuthStrategy } from './auth' import type { AuthStrategy } from './auth'
import type { Result as ForgotPasswordResult } from './auth/operations/forgotPassword' import type { Result as ForgotPasswordResult } from './auth/operations/forgotPassword'

View File

@@ -11,7 +11,7 @@ export const extractTranslations = (keys: string[]): Record<string, Record<strin
if (resource?.[section]?.[target]) { if (resource?.[section]?.[target]) {
result[key][language] = resource[section][target] result[key][language] = resource[section][target]
} else { } else {
console.error(`Missing translation for ${key} in ${language}`) // console.error(`Missing translation for ${key} in ${language}`)
} }
}) })
}) })

View File

@@ -18,8 +18,6 @@
}, },
"dependencies": { "dependencies": {
"@faceless-ui/modal": "2.0.1", "@faceless-ui/modal": "2.0.1",
"@payloadcms/translations": "workspace:^",
"@payloadcms/ui": "workspace:^",
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"react": "18.2.0", "react": "18.2.0",
"slate": "0.91.4", "slate": "0.91.4",
@@ -34,7 +32,9 @@
"payload": "workspace:*" "payload": "workspace:*"
}, },
"peerDependencies": { "peerDependencies": {
"payload": "^2.3.0" "payload": "^2.3.0",
"@payloadcms/translations": "workspace:^",
"@payloadcms/ui": "workspace:^"
}, },
"exports": { "exports": {
".": { ".": {

View File

@@ -13,7 +13,8 @@ import { withHistory } from 'slate-history'
import { Editable, Slate, withReact } from 'slate-react' import { Editable, Slate, withReact } from 'slate-react'
import type { FormFieldBase } from '../../../ui/src/forms/fields/shared' import type { FormFieldBase } from '../../../ui/src/forms/fields/shared'
import type { ElementNode, RichTextElement, RichTextLeaf, TextNode } from '../types' import type { ElementNode, TextNode } from '../types'
import type { EnabledFeatures } from './types'
import { withCondition } from '../../../ui/src/forms/withCondition' import { withCondition } from '../../../ui/src/forms/withCondition'
import { defaultRichTextValue } from '../data/defaultValue' import { defaultRichTextValue } from '../data/defaultValue'
@@ -24,25 +25,11 @@ import './index.scss'
import toggleLeaf from './leaves/toggle' import toggleLeaf from './leaves/toggle'
import withEnterBreakOut from './plugins/withEnterBreakOut' import withEnterBreakOut from './plugins/withEnterBreakOut'
import withHTML from './plugins/withHTML' import withHTML from './plugins/withHTML'
import { ElementButtonProvider } from './providers/ElementButtonProvider'
import { ElementProvider } from './providers/ElementProvider'
import { LeafButtonProvider } from './providers/LeafButtonProvider' import { LeafButtonProvider } from './providers/LeafButtonProvider'
import { LeafProvider } from './providers/LeafProvider' import { LeafProvider } from './providers/LeafProvider'
const defaultElements: RichTextElement[] = [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'ul',
'ol',
'indent',
'link',
'relationship',
'upload',
]
const defaultLeaves: RichTextLeaf[] = ['bold', 'italic', 'underline', 'strikethrough', 'code']
const baseClass = 'rich-text' const baseClass = 'rich-text'
declare module 'slate' { declare module 'slate' {
@@ -75,38 +62,47 @@ const RichText: React.FC<
width, width,
} = props } = props
const [leaves] = useState(() => { const [{ elements, leaves }] = useState<EnabledFeatures>(() => {
const enabledLeaves: Record< const features: EnabledFeatures = {
string, elements: {},
{ leaves: {},
Button: React.ReactNode }
Leaf: React.ReactNode
name: string
}
> = {}
for (const [key, value] of richTextComponentMap) { for (const [key, value] of richTextComponentMap) {
if (key.startsWith('leaf.button.') || key.startsWith('leaf.component.')) { if (key.startsWith('leaf.button.') || key.startsWith('leaf.component.')) {
const leafName = key.replace('leaf.button.', '').replace('leaf.component.', '') const leafName = key.replace('leaf.button.', '').replace('leaf.component.', '')
if (!enabledLeaves[leafName]) { if (!features.leaves[leafName]) {
enabledLeaves[leafName] = { features.leaves[leafName] = {
name: leafName, name: leafName,
Button: null, Button: null,
Leaf: null, Leaf: null,
} }
} }
if (key.startsWith('leaf.button.')) enabledLeaves[leafName].Button = value if (key.startsWith('leaf.button.')) features.leaves[leafName].Button = value
if (key.startsWith('leaf.component.')) enabledLeaves[leafName].Leaf = 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 enabledLeaves return features
}) })
const elements: RichTextElement[] = defaultElements
const { i18n } = useTranslation() const { i18n } = useTranslation()
const editorRef = useRef(null) const editorRef = useRef(null)
const toolbarRef = useRef(null) const toolbarRef = useRef(null)
@@ -128,65 +124,72 @@ const RichText: React.FC<
validate: memoizedValidate, validate: memoizedValidate,
}) })
const renderElement = useCallback(({ attributes, children, element }) => { const renderElement = useCallback(
// const matchedElement = enabledElements[element.type] ({ attributes, children, element }) => {
// const Element = matchedElement?.Element // return <div {...attributes}>{children}</div>
const attr = { ...attributes } const matchedElement = elements[element.type]
const Element = matchedElement?.Element
// // this converts text alignment to margin when dealing with void elements let attr = { ...attributes }
// if (element.textAlign) {
// if (element.type === 'relationship' || element.type === 'upload') {
// switch (element.textAlign) {
// case 'left':
// attr = { ...attr, style: { marginRight: 'auto' } }
// break
// case 'right':
// attr = { ...attr, style: { marginLeft: 'auto' } }
// break
// case 'center':
// attr = { ...attr, style: { marginLeft: 'auto', marginRight: 'auto' } }
// break
// default:
// attr = { ...attr, style: { textAlign: element.textAlign } }
// break
// }
// } else if (element.type === 'li') {
// switch (element.textAlign) {
// case 'right':
// attr = { ...attr, style: { listStylePosition: 'inside', textAlign: 'right' } }
// break
// case 'center':
// attr = { ...attr, style: { listStylePosition: 'inside', textAlign: 'center' } }
// break
// case 'left':
// default:
// attr = { ...attr, style: { listStylePosition: 'outside', textAlign: 'left' } }
// break
// }
// } else {
// attr = { ...attr, style: { textAlign: element.textAlign } }
// }
// }
// if (Element) { // this converts text alignment to margin when dealing with void elements
// const el = ( if (element.textAlign) {
// <Element if (element.type === 'relationship' || element.type === 'upload') {
// attributes={attr} switch (element.textAlign) {
// editorRef={editorRef} case 'left':
// element={element} attr = { ...attr, style: { marginRight: 'auto' } }
// fieldProps={props} break
// path={path} case 'right':
// > attr = { ...attr, style: { marginLeft: 'auto' } }
// {children} break
// </Element> case 'center':
// ) attr = { ...attr, style: { marginLeft: 'auto', marginRight: 'auto' } }
break
default:
attr = { ...attr, style: { textAlign: element.textAlign } }
break
}
} else if (element.type === 'li') {
switch (element.textAlign) {
case 'right':
attr = { ...attr, style: { listStylePosition: 'inside', textAlign: 'right' } }
break
case 'center':
attr = { ...attr, style: { listStylePosition: 'inside', textAlign: 'center' } }
break
case 'left':
default:
attr = { ...attr, style: { listStylePosition: 'outside', textAlign: 'left' } }
break
}
} else {
attr = { ...attr, style: { textAlign: element.textAlign } }
}
}
// return el if (Element) {
// } const el = (
<ElementProvider
attributes={attr}
childNodes={children}
editorRef={editorRef}
element={element}
fieldProps={props}
path={path}
schemaPath={schemaPath}
>
{Element}
</ElementProvider>
)
return <div {...attr}>{children}</div> return el
}, []) }
return <div {...attr}>{children}</div>
},
[elements, path, props, schemaPath],
)
const renderLeaf = useCallback( const renderLeaf = useCallback(
({ attributes, children, leaf }) => { ({ attributes, children, leaf }) => {
@@ -334,7 +337,7 @@ const RichText: React.FC<
value={valueToRender as any[]} value={valueToRender as any[]}
> >
<div className={`${baseClass}__wrapper`}> <div className={`${baseClass}__wrapper`}>
{elements?.length + Object.keys(leaves)?.length > 0 && ( {Object.keys(elements)?.length + Object.keys(leaves)?.length > 0 && (
<div <div
className={[`${baseClass}__toolbar`, drawerIsOpen && `${baseClass}__drawerIsOpen`] className={[`${baseClass}__toolbar`, drawerIsOpen && `${baseClass}__drawerIsOpen`]
.filter(Boolean) .filter(Boolean)
@@ -342,20 +345,24 @@ const RichText: React.FC<
ref={toolbarRef} ref={toolbarRef}
> >
<div className={`${baseClass}__toolbar-wrap`}> <div className={`${baseClass}__toolbar-wrap`}>
{/* {elements.map((element, i) => { {Object.values(elements).map((element, i) => {
let elementName: string const Button = element?.Button
if (typeof element === 'object' && element?.name) elementName = element.name
if (typeof element === 'string') elementName = element
const elementType = enabledElements[elementName]
const Button = elementType?.Button
if (Button) { if (Button) {
return <Button fieldProps={props} key={i} path={path} /> return (
<ElementButtonProvider
fieldProps={props}
key={element.name}
path={path}
schemaPath={schemaPath}
>
{Button}
</ElementButtonProvider>
)
} }
return null return null
})} */} })}
{Object.values(leaves).map((leaf, i) => { {Object.values(leaves).map((leaf, i) => {
const Button = leaf?.Button const Button = leaf?.Button
@@ -363,7 +370,7 @@ const RichText: React.FC<
return ( return (
<LeafButtonProvider <LeafButtonProvider
fieldProps={props} fieldProps={props}
key={i} key={leaf.name}
path={path} path={path}
schemaPath={schemaPath} schemaPath={schemaPath}
> >

View File

@@ -1,3 +1,5 @@
'use client'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useSlate } from 'slate-react' import { useSlate } from 'slate-react'

View File

@@ -0,0 +1,16 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
import './index.scss'
export const Blockquote = () => {
const { attributes, children } = useElement()
return (
<blockquote className="rich-text-blockquote" {...attributes}>
{children}
</blockquote>
)
}

View File

@@ -1,18 +1,17 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import BlockquoteIcon from '../../icons/Blockquote' import BlockquoteIcon from '../../icons/Blockquote'
import ElementButton from '../Button' import ElementButton from '../Button'
import './index.scss' import { Blockquote } from './Blockquote'
const Blockquote = ({ attributes, children }) => ( const name = 'blockquote'
<blockquote className="rich-text-blockquote" {...attributes}>
{children}
</blockquote>
)
const blockquote = { const blockquote: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="blockquote"> <ElementButton format={name}>
<BlockquoteIcon /> <BlockquoteIcon />
</ElementButton> </ElementButton>
), ),

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading1 = () => {
const { attributes, children } = useElement()
return <h1 {...attributes}>{children}</h1>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H1Icon from '../../icons/headings/H1' import H1Icon from '../../icons/headings/H1'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading1 } from './Heading1'
const H1 = ({ attributes, children }) => <h1 {...attributes}>{children}</h1> const name = 'h1'
const h1 = { const h1: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h1"> <ElementButton format={name}>
<H1Icon /> <H1Icon />
</ElementButton> </ElementButton>
), ),
Element: H1, Element: Heading1,
} }
export default h1 export default h1

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading2 = () => {
const { attributes, children } = useElement()
return <h2 {...attributes}>{children}</h2>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H2Icon from '../../icons/headings/H2' import H2Icon from '../../icons/headings/H2'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading2 } from './Heading2'
const H2 = ({ attributes, children }) => <h2 {...attributes}>{children}</h2> const name = 'h2'
const h2 = { const h2: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h2"> <ElementButton format={name}>
<H2Icon /> <H2Icon />
</ElementButton> </ElementButton>
), ),
Element: H2, Element: Heading2,
} }
export default h2 export default h2

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading3 = () => {
const { attributes, children } = useElement()
return <h3 {...attributes}>{children}</h3>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H3Icon from '../../icons/headings/H3' import H3Icon from '../../icons/headings/H3'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading3 } from './Heading3'
const H3 = ({ attributes, children }) => <h3 {...attributes}>{children}</h3> const name = 'h3'
const h3 = { const h3: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h3"> <ElementButton format={name}>
<H3Icon /> <H3Icon />
</ElementButton> </ElementButton>
), ),
Element: H3, Element: Heading3,
} }
export default h3 export default h3

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading4 = () => {
const { attributes, children } = useElement()
return <h4 {...attributes}>{children}</h4>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H4Icon from '../../icons/headings/H4' import H4Icon from '../../icons/headings/H4'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading4 } from './Heading4'
const H4 = ({ attributes, children }) => <h4 {...attributes}>{children}</h4> const name = 'h4'
const h4 = { const h4: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h4"> <ElementButton format={name}>
<H4Icon /> <H4Icon />
</ElementButton> </ElementButton>
), ),
Element: H4, Element: Heading4,
} }
export default h4 export default h4

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading5 = () => {
const { attributes, children } = useElement()
return <h5 {...attributes}>{children}</h5>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H5Icon from '../../icons/headings/H5' import H5Icon from '../../icons/headings/H5'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading5 } from './Heading5'
const H5 = ({ attributes, children }) => <h5 {...attributes}>{children}</h5> const name = 'h5'
const h5 = { const h5: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h5"> <ElementButton format={name}>
<H5Icon /> <H5Icon />
</ElementButton> </ElementButton>
), ),
Element: H5, Element: Heading5,
} }
export default h5 export default h5

View File

@@ -0,0 +1,11 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const Heading6 = () => {
const { attributes, children } = useElement()
return <h6 {...attributes}>{children}</h6>
}

View File

@@ -1,17 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import H6Icon from '../../icons/headings/H6' import H6Icon from '../../icons/headings/H6'
import ElementButton from '../Button' import ElementButton from '../Button'
import { Heading6 } from './Heading6'
const H6 = ({ attributes, children }) => <h6 {...attributes}>{children}</h6> const name = 'h6'
const h6 = { const h6: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ElementButton format="h6"> <ElementButton format={name}>
<H6Icon /> <H6Icon />
</ElementButton> </ElementButton>
), ),
Element: H6, Element: Heading6,
} }
export default h6 export default h6

View File

@@ -0,0 +1,214 @@
'use client'
import React, { useCallback } from 'react'
import { Editor, Element, Text, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react'
import type { ElementNode } from '../../../types'
import { indentType } from '.'
import IndentLeft from '../../icons/IndentLeft'
import IndentRight from '../../icons/IndentRight'
import { baseClass } from '../Button'
import { getCommonBlock } from '../getCommonBlock'
import isElementActive from '../isActive'
import { isBlockElement } from '../isBlockElement'
import listTypes from '../listTypes'
import { unwrapList } from '../unwrapList'
export const IndentButton: React.FC = () => {
const editor = useSlate()
const handleIndent = useCallback(
(e, dir) => {
e.preventDefault()
if (dir === 'left') {
if (isElementActive(editor, 'li')) {
const [, listPath] = getCommonBlock(
editor,
(n) => Element.isElement(n) && listTypes.includes(n.type),
)
const matchedParentList = Editor.above(editor, {
at: listPath,
match: (n: ElementNode) => !Editor.isEditor(n) && isBlockElement(editor, n),
})
if (matchedParentList) {
const [parentListItem, parentListItemPath] = matchedParentList
if (parentListItem.children.length > 1) {
// Remove nested list
Transforms.unwrapNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
listTypes.includes(node.type) &&
path.length === parentListItemPath.length + 1
return matches
},
})
// Set li type on any children that don't have a type
Transforms.setNodes(
editor,
{ type: 'li' },
{
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
node.type !== 'li' &&
path.length === parentListItemPath.length + 1
return matches
},
},
)
// Parent list item path has changed at this point
// so we need to re-fetch the parent node
const [newParentNode] = Editor.node(editor, parentListItemPath)
// If the parent node is an li,
// lift all li nodes within
if (Element.isElement(newParentNode) && newParentNode.type === 'li') {
// Lift the nested lis
Transforms.liftNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
path.length === parentListItemPath.length + 1 &&
node.type === 'li'
return matches
},
})
}
} else {
Transforms.unwrapNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
return (
Element.isElement(node) &&
node.type === 'li' &&
path.length === parentListItemPath.length
)
},
})
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
})
}
} else {
unwrapList(editor, listPath)
}
} else {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && n.type === indentType,
mode: 'lowest',
split: true,
})
}
}
if (dir === 'right') {
const isCurrentlyOL = isElementActive(editor, 'ol')
const isCurrentlyUL = isElementActive(editor, 'ul')
if (isCurrentlyOL || isCurrentlyUL) {
// Get the path of the first selected li -
// Multiple lis could be selected
// and the selection may start in the middle of the first li
const [[, firstSelectedLIPath]] = Array.from(
Editor.nodes(editor, {
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
}),
)
// Is the first selected li the first in its list?
const hasPrecedingLI = firstSelectedLIPath[firstSelectedLIPath.length - 1] > 0
// If the first selected li is NOT the first in its list,
// we need to inject it into the prior li
if (hasPrecedingLI) {
const [, precedingLIPath] = Editor.previous(editor, {
at: firstSelectedLIPath,
})
const [precedingLIChildren] = Editor.node(editor, [...precedingLIPath, 0])
const precedingLIChildrenIsText = Text.isText(precedingLIChildren)
if (precedingLIChildrenIsText) {
// Wrap the prior li text content so that it can be nested next to a list
Transforms.wrapNodes(editor, { children: [] }, { at: [...precedingLIPath, 0] })
}
// Move the selected lis after the prior li contents
Transforms.moveNodes(editor, {
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
to: [...precedingLIPath, 1],
})
// Wrap the selected lis in a new list
Transforms.wrapNodes(
editor,
{
children: [],
type: isCurrentlyOL ? 'ol' : 'ul',
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
)
} else {
// Otherwise, just wrap the node in a list / li
Transforms.wrapNodes(
editor,
{
children: [{ children: [], type: 'li' }],
type: isCurrentlyOL ? 'ol' : 'ul',
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
)
}
} else {
Transforms.wrapNodes(editor, { children: [], type: indentType })
}
}
ReactEditor.focus(editor)
},
[editor],
)
const canDeIndent = isElementActive(editor, 'li') || isElementActive(editor, indentType)
return (
<React.Fragment>
<button
className={[baseClass, !canDeIndent && `${baseClass}--disabled`].filter(Boolean).join(' ')}
onClick={canDeIndent ? (e) => handleIndent(e, 'left') : undefined}
type="button"
>
<IndentLeft />
</button>
<button className={baseClass} onClick={(e) => handleIndent(e, 'right')} type="button">
<IndentRight />
</button>
</React.Fragment>
)
}

View File

@@ -0,0 +1,15 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
export const IndentElement: React.FC = () => {
const { attributes, children } = useElement()
return (
<div style={{ paddingLeft: 25 }} {...attributes}>
{children}
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { IndentButton } from './Button'
import { IndentElement } from './Element'
export const indentType = 'indent'
const indent = {
name: indentType,
Button: IndentButton,
Element: IndentElement,
}
export default indent

View File

@@ -1,226 +0,0 @@
import React, { useCallback } from 'react'
import { Editor, Element, Text, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react'
import type { ElementNode } from '../../../types'
import IndentLeft from '../../icons/IndentLeft'
import IndentRight from '../../icons/IndentRight'
import { baseClass } from '../Button'
import { getCommonBlock } from '../getCommonBlock'
import isElementActive from '../isActive'
import { isBlockElement } from '../isBlockElement'
import listTypes from '../listTypes'
import { unwrapList } from '../unwrapList'
const indentType = 'indent'
const IndentWithPadding = ({ attributes, children }) => (
<div style={{ paddingLeft: 25 }} {...attributes}>
{children}
</div>
)
const indent = {
Button: () => {
const editor = useSlate()
const handleIndent = useCallback(
(e, dir) => {
e.preventDefault()
if (dir === 'left') {
if (isElementActive(editor, 'li')) {
const [, listPath] = getCommonBlock(
editor,
(n) => Element.isElement(n) && listTypes.includes(n.type),
)
const matchedParentList = Editor.above(editor, {
at: listPath,
match: (n: ElementNode) => !Editor.isEditor(n) && isBlockElement(editor, n),
})
if (matchedParentList) {
const [parentListItem, parentListItemPath] = matchedParentList
if (parentListItem.children.length > 1) {
// Remove nested list
Transforms.unwrapNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
listTypes.includes(node.type) &&
path.length === parentListItemPath.length + 1
return matches
},
})
// Set li type on any children that don't have a type
Transforms.setNodes(
editor,
{ type: 'li' },
{
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
node.type !== 'li' &&
path.length === parentListItemPath.length + 1
return matches
},
},
)
// Parent list item path has changed at this point
// so we need to re-fetch the parent node
const [newParentNode] = Editor.node(editor, parentListItemPath)
// If the parent node is an li,
// lift all li nodes within
if (Element.isElement(newParentNode) && newParentNode.type === 'li') {
// Lift the nested lis
Transforms.liftNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
path.length === parentListItemPath.length + 1 &&
node.type === 'li'
return matches
},
})
}
} else {
Transforms.unwrapNodes(editor, {
at: parentListItemPath,
match: (node, path) => {
return (
Element.isElement(node) &&
node.type === 'li' &&
path.length === parentListItemPath.length
)
},
})
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
})
}
} else {
unwrapList(editor, listPath)
}
} else {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && n.type === indentType,
mode: 'lowest',
split: true,
})
}
}
if (dir === 'right') {
const isCurrentlyOL = isElementActive(editor, 'ol')
const isCurrentlyUL = isElementActive(editor, 'ul')
if (isCurrentlyOL || isCurrentlyUL) {
// Get the path of the first selected li -
// Multiple lis could be selected
// and the selection may start in the middle of the first li
const [[, firstSelectedLIPath]] = Array.from(
Editor.nodes(editor, {
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
}),
)
// Is the first selected li the first in its list?
const hasPrecedingLI = firstSelectedLIPath[firstSelectedLIPath.length - 1] > 0
// If the first selected li is NOT the first in its list,
// we need to inject it into the prior li
if (hasPrecedingLI) {
const [, precedingLIPath] = Editor.previous(editor, {
at: firstSelectedLIPath,
})
const [precedingLIChildren] = Editor.node(editor, [...precedingLIPath, 0])
const precedingLIChildrenIsText = Text.isText(precedingLIChildren)
if (precedingLIChildrenIsText) {
// Wrap the prior li text content so that it can be nested next to a list
Transforms.wrapNodes(editor, { children: [] }, { at: [...precedingLIPath, 0] })
}
// Move the selected lis after the prior li contents
Transforms.moveNodes(editor, {
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
to: [...precedingLIPath, 1],
})
// Wrap the selected lis in a new list
Transforms.wrapNodes(
editor,
{
children: [],
type: isCurrentlyOL ? 'ol' : 'ul',
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
)
} else {
// Otherwise, just wrap the node in a list / li
Transforms.wrapNodes(
editor,
{
children: [{ children: [], type: 'li' }],
type: isCurrentlyOL ? 'ol' : 'ul',
},
{
match: (node) => Element.isElement(node) && node.type === 'li',
mode: 'lowest',
},
)
}
} else {
Transforms.wrapNodes(editor, { children: [], type: indentType })
}
}
ReactEditor.focus(editor)
},
[editor],
)
const canDeIndent = isElementActive(editor, 'li') || isElementActive(editor, indentType)
return (
<React.Fragment>
<button
className={[baseClass, !canDeIndent && `${baseClass}--disabled`]
.filter(Boolean)
.join(' ')}
onClick={canDeIndent ? (e) => handleIndent(e, 'left') : undefined}
type="button"
>
<IndentLeft />
</button>
<button className={baseClass} onClick={(e) => handleIndent(e, 'right')} type="button">
<IndentRight />
</button>
</React.Fragment>
)
},
Element: IndentWithPadding,
}
export default indent

View File

@@ -1,3 +1,5 @@
import type { RichTextCustomElement } from '../..'
import blockquote from './blockquote' import blockquote from './blockquote'
import h1 from './h1' import h1 from './h1'
import h2 from './h2' import h2 from './h2'
@@ -5,16 +7,16 @@ import h3 from './h3'
import h4 from './h4' import h4 from './h4'
import h5 from './h5' import h5 from './h5'
import h6 from './h6' import h6 from './h6'
import indent from './indent' // import indent from './indent'
import li from './li' import li from './li'
import link from './link' import link from './link'
import ol from './ol' import ol from './ol'
import relationship from './relationship' // import relationship from './relationship'
import textAlign from './textAlign' // import textAlign from './textAlign'
import ul from './ul' import ul from './ul'
import upload from './upload' // import upload from './upload'
const elements = { const elements: Record<string, RichTextCustomElement> = {
blockquote, blockquote,
h1, h1,
h2, h2,
@@ -22,14 +24,14 @@ const elements = {
h4, h4,
h5, h5,
h6, h6,
indent, // indent,
li, li,
link, link,
ol, ol,
relationship, // relationship,
textAlign, // textAlign,
ul, ul,
upload, // upload,
} }
export default elements export default elements

View File

@@ -0,0 +1,30 @@
'use client'
import React, { isValidElement } from 'react'
import { useElement } from '../../providers/ElementProvider'
import listTypes from '../listTypes'
import { Element } from 'slate'
export const ListItemElement: React.FC = () => {
const { attributes, children, element } = useElement<Element>()
if (!isValidElement(element)) {
return null
}
const listType = typeof element.children?.[0]?.type === 'string' ? element.children[0].type : ''
const disableListStyle = element.children.length >= 1 && listTypes.includes(listType)
return (
<li
style={{
listStyle: disableListStyle ? 'none' : undefined,
listStylePosition: disableListStyle ? 'outside' : undefined,
}}
{...attributes}
>
{children}
</li>
)
}

View File

@@ -1,25 +1,10 @@
import React from 'react' import type { RichTextCustomElement } from '../../..'
import listTypes from '../listTypes' import { ListItemElement } from './ListItem'
const LI = (props) => { const listItem: RichTextCustomElement = {
const { attributes, children, element } = props name: 'li',
const disableListStyle = Element: ListItemElement,
element.children.length >= 1 && listTypes.includes(element.children?.[0]?.type)
return (
<li
style={{
listStyle: disableListStyle ? 'none' : undefined,
listStylePosition: disableListStyle ? 'outside' : undefined,
}}
{...attributes}
>
{children}
</li>
)
} }
export default { export default listItem
Element: LI,
}

View File

@@ -1,30 +1,26 @@
'use client' 'use client'
import type { Fields } from '@payloadcms/ui' import type { FormState } from '@payloadcms/ui'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { import {
buildStateFromSchema, getFormState,
reduceFieldsToValues, reduceFieldsToValues,
useAuth,
useConfig, useConfig,
useDocumentInfo, useDocumentInfo,
useDrawerSlug, useDrawerSlug,
useLocale,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { sanitizeFields } from 'payload/config'
import React, { Fragment, useState } from 'react' import React, { Fragment, useState } from 'react'
import { Editor, Range, Transforms } from 'slate' import { Editor, Range, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react' import { ReactEditor, useSlate } from 'slate-react'
import type { FieldProps } from '../../../../types'
import LinkIcon from '../../../icons/Link' import LinkIcon from '../../../icons/Link'
import { useElementButton } from '../../../providers/ElementButtonProvider'
import ElementButton from '../../Button' import ElementButton from '../../Button'
import isElementActive from '../../isActive' import isElementActive from '../../isActive'
import { LinkDrawer } from '../LinkDrawer' import { LinkDrawer } from '../LinkDrawer'
import { transformExtraFields, unwrapLink } from '../utilities' import { unwrapLink } from '../utilities'
/** /**
* This function is called when an new link is created - not when an existing link is edited. * This function is called when an new link is created - not when an existing link is edited.
@@ -64,35 +60,23 @@ const insertLink = (editor, fields) => {
ReactEditor.focus(editor) ReactEditor.focus(editor)
} }
export const LinkButton: React.FC<{ export const LinkButton: React.FC = () => {
fieldProps: FieldProps const { fieldProps } = useElementButton()
path: string const [initialState, setInitialState] = useState<FormState>({})
}> = ({ fieldProps }) => {
const customFieldSchema = fieldProps?.admin?.link?.fields
const { user } = useAuth()
const { code: locale } = useLocale()
const [initialState, setInitialState] = useState<Fields>({})
const { i18n, t } = useTranslation() const { t } = useTranslation()
const editor = useSlate() const editor = useSlate()
const config = useConfig() const config = useConfig()
const [fieldSchema] = useState(() => {
const fieldsUnsanitized = transformExtraFields(customFieldSchema, config, i18n)
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fields = sanitizeFields({
config: config,
fields: fieldsUnsanitized,
validRelationships,
})
return fields
})
const { closeModal, openModal } = useModal() const { closeModal, openModal } = useModal()
const drawerSlug = useDrawerSlug('rich-text-link') const drawerSlug = useDrawerSlug('rich-text-link')
const { getDocPreferences } = useDocumentInfo() const { id, getDocPreferences } = useDocumentInfo()
const { richTextComponentMap } = fieldProps
const linkFieldsSchemaPath = `link.fields`
const fieldMap = richTextComponentMap.get(linkFieldsSchemaPath)
return ( return (
<Fragment> <Fragment>
@@ -104,24 +88,22 @@ export const LinkButton: React.FC<{
unwrapLink(editor) unwrapLink(editor)
} else { } else {
openModal(drawerSlug) openModal(drawerSlug)
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection) const isCollapsed = editor.selection && Range.isCollapsed(editor.selection)
if (!isCollapsed) { if (!isCollapsed) {
const data = { const data = {
text: editor.selection ? Editor.string(editor, editor.selection) : '', text: editor.selection ? Editor.string(editor, editor.selection) : '',
} }
const docPreferences = await getDocPreferences()
const preferences = await getDocPreferences() const state = await getFormState({
const state = await buildStateFromSchema({ apiRoute: config.routes.api,
config, body: {
data, id,
fieldSchema, data,
locale, docPreferences,
operation: 'create', operation: 'update',
preferences, schemaPath: linkFieldsSchemaPath,
t, },
user, serverURL: config.serverURL,
}) })
setInitialState(state) setInitialState(state)
} }
@@ -133,7 +115,7 @@ export const LinkButton: React.FC<{
</ElementButton> </ElementButton>
<LinkDrawer <LinkDrawer
drawerSlug={drawerSlug} drawerSlug={drawerSlug}
fieldSchema={fieldSchema} fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
handleClose={() => { handleClose={() => {
closeModal(drawerSlug) closeModal(drawerSlug)
}} }}

View File

@@ -1,15 +1,13 @@
'use client' 'use client'
import type { Fields } from '@payloadcms/ui'
import type { HTMLAttributes } from 'react'
import { useModal } from '@faceless-ui/modal' import { useModal } from '@faceless-ui/modal'
import { getTranslation } from '@payloadcms/translations' import { getTranslation } from '@payloadcms/translations'
import { import {
Button, Button,
FormState,
Popup, Popup,
Translation, Translation,
buildStateFromSchema, getFormState,
reduceFieldsToValues, reduceFieldsToValues,
useAuth, useAuth,
useConfig, useConfig,
@@ -18,17 +16,16 @@ import {
useLocale, useLocale,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
import { sanitizeFields } from 'payload/config'
import { deepCopyObject } from 'payload/utilities' import { deepCopyObject } from 'payload/utilities'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { Editor, Node, Transforms } from 'slate' import { Editor, Node, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react' import { ReactEditor, useSlate } from 'slate-react'
import type { FieldProps } from '../../../../types' import { useElement } from '../../../providers/ElementProvider'
import { LinkDrawer } from '../LinkDrawer' import { LinkDrawer } from '../LinkDrawer'
import { transformExtraFields, unwrapLink } from '../utilities' import { unwrapLink } from '../utilities'
import './index.scss' import './index.scss'
import { LinkElementType } from '../types'
const baseClass = 'rich-text-link' const baseClass = 'rich-text-link'
@@ -36,7 +33,7 @@ const baseClass = 'rich-text-link'
* This function is called when an existing link is edited. * This function is called when an existing link is edited.
* When a link is first created, another function is called: {@link ../Button/index.tsx#insertLink} * When a link is first created, another function is called: {@link ../Button/index.tsx#insertLink}
*/ */
const insertChange = (editor, fields, customFieldSchema) => { const insertChange = (editor, fields) => {
const data = reduceFieldsToValues(fields, true) const data = reduceFieldsToValues(fields, true)
const [, parentPath] = Editor.above(editor) const [, parentPath] = Editor.above(editor)
@@ -48,10 +45,6 @@ const insertChange = (editor, fields, customFieldSchema) => {
url: data.url, url: data.url,
} }
if (customFieldSchema) {
newNode.fields = data.fields
}
Transforms.setNodes(editor, newNode, { at: parentPath }) Transforms.setNodes(editor, newNode, { at: parentPath })
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' }) Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' })
@@ -61,16 +54,14 @@ const insertChange = (editor, fields, customFieldSchema) => {
ReactEditor.focus(editor) ReactEditor.focus(editor)
} }
export const LinkElement: React.FC<{ export const LinkElement = () => {
attributes: HTMLAttributes<HTMLDivElement> const { attributes, children, editorRef, element, fieldProps, schemaPath } =
children: React.ReactNode useElement<LinkElementType>()
editorRef: React.RefObject<HTMLDivElement>
element: any
fieldProps: FieldProps
}> = (props) => {
const { attributes, children, editorRef, element, fieldProps } = props
const customFieldSchema = fieldProps?.admin?.link?.fields const linkFieldsSchemaPath = `${schemaPath}.link.fields`
const { richTextComponentMap } = fieldProps
const fieldMap = richTextComponentMap.get(linkFieldsSchemaPath)
const editor = useSlate() const editor = useSlate()
const config = useConfig() const config = useConfig()
@@ -80,20 +71,8 @@ export const LinkElement: React.FC<{
const { closeModal, openModal, toggleModal } = useModal() const { closeModal, openModal, toggleModal } = useModal()
const [renderModal, setRenderModal] = useState(false) const [renderModal, setRenderModal] = useState(false)
const [renderPopup, setRenderPopup] = useState(false) const [renderPopup, setRenderPopup] = useState(false)
const [initialState, setInitialState] = useState<Fields>({}) const [initialState, setInitialState] = useState<FormState>({})
const { getDocPreferences } = useDocumentInfo() const { id, getDocPreferences } = useDocumentInfo()
const [fieldSchema] = useState(() => {
const fieldsUnsanitized = transformExtraFields(customFieldSchema, config, i18n)
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fields = sanitizeFields({
config: config,
fields: fieldsUnsanitized,
validRelationships,
})
return fields
})
const drawerSlug = useDrawerSlug('rich-text-link') const drawerSlug = useDrawerSlug('rich-text-link')
@@ -114,22 +93,25 @@ export const LinkElement: React.FC<{
url: element.url, url: element.url,
} }
const preferences = await getDocPreferences() const docPreferences = await getDocPreferences()
const state = await buildStateFromSchema({
config, const state = await getFormState({
data, apiRoute: config.routes.api,
fieldSchema, body: {
locale, id,
operation: 'update', data,
preferences, docPreferences,
t, operation: 'update',
user, schemaPath: linkFieldsSchemaPath,
},
serverURL: config.serverURL,
}) })
setInitialState(state) setInitialState(state)
} }
awaitInitialState() awaitInitialState()
}, [renderModal, element, fieldSchema, user, locale, t, getDocPreferences, config]) }, [renderModal, element, user, locale, t, getDocPreferences, config])
return ( return (
<span className={baseClass} {...attributes}> <span className={baseClass} {...attributes}>
@@ -137,13 +119,13 @@ export const LinkElement: React.FC<{
{renderModal && ( {renderModal && (
<LinkDrawer <LinkDrawer
drawerSlug={drawerSlug} drawerSlug={drawerSlug}
fieldSchema={fieldSchema} fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
handleClose={() => { handleClose={() => {
toggleModal(drawerSlug) toggleModal(drawerSlug)
setRenderModal(false) setRenderModal(false)
}} }}
handleModalSubmit={(fields) => { handleModalSubmit={(fields) => {
insertChange(editor, fields, customFieldSchema) insertChange(editor, fields)
closeModal(drawerSlug) closeModal(drawerSlug)
}} }}
initialState={initialState} initialState={initialState}

View File

@@ -5,7 +5,6 @@ import {
Form, Form,
FormSubmit, FormSubmit,
RenderFields, RenderFields,
fieldTypes,
useEditDepth, useEditDepth,
useTranslation, useTranslation,
} from '@payloadcms/ui' } from '@payloadcms/ui'
@@ -20,7 +19,7 @@ const baseClass = 'rich-text-link-edit-modal'
export const LinkDrawer: React.FC<Props> = ({ export const LinkDrawer: React.FC<Props> = ({
drawerSlug, drawerSlug,
fieldSchema, fieldMap,
handleModalSubmit, handleModalSubmit,
initialState, initialState,
}) => { }) => {
@@ -28,13 +27,8 @@ export const LinkDrawer: React.FC<Props> = ({
return ( return (
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink')}> <Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink')}>
<Form fields={fieldSchema} initialState={initialState} onSubmit={handleModalSubmit}> <Form initialState={initialState} onSubmit={handleModalSubmit}>
<RenderFields <RenderFields fieldMap={fieldMap} forceRender readOnly={false} />
fieldSchema={fieldSchema}
fieldTypes={fieldTypes}
forceRender
readOnly={false}
/>
<LinkSubmit /> <LinkSubmit />
</Form> </Form>
</Drawer> </Drawer>

View File

@@ -1,9 +1,9 @@
import type { Field, Fields } from 'payload/types' import type { FieldMap, FormState } from '@payloadcms/ui'
export type Props = { export type Props = {
drawerSlug: string drawerSlug: string
fieldSchema: Field[] fieldMap: FieldMap
handleClose: () => void handleClose: () => void
handleModalSubmit: (fields: Fields, data: Record<string, unknown>) => void handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
initialState?: Fields initialState?: FormState
} }

View File

@@ -1,8 +1,11 @@
import type { RichTextCustomElement } from '../../..'
import { LinkButton } from './Button' import { LinkButton } from './Button'
import { LinkElement } from './Element' import { LinkElement } from './Element'
import { withLinks } from './utilities' import { withLinks } from './utilities'
const link = { const link: RichTextCustomElement = {
name: 'link',
Button: LinkButton, Button: LinkButton,
Element: LinkElement, Element: LinkElement,
plugins: [withLinks], plugins: [withLinks],

View File

@@ -0,0 +1,9 @@
import { Element } from 'slate'
export type LinkElementType = Element & {
doc: Record<string, unknown>
fields: Record<string, unknown>
linkType: string
newTab: boolean
url: string
}

View File

@@ -0,0 +1,15 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
import './index.scss'
export const OrderedList: React.FC = () => {
const { attributes, children } = useElement()
return (
<ol className="rich-text-ol" {...attributes}>
{children}
</ol>
)
}

View File

@@ -1,22 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import OLIcon from '../../icons/OrderedList' import OLIcon from '../../icons/OrderedList'
import ListButton from '../ListButton' import ListButton from '../ListButton'
import './index.scss' import { OrderedList } from './OrderedList'
const OL = ({ attributes, children }) => ( const name = 'ol'
<ol className="rich-text-ol" {...attributes}>
{children}
</ol>
)
const ol = { const ol: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ListButton format="ol"> <ListButton format={name}>
<OLIcon /> <OLIcon />
</ListButton> </ListButton>
), ),
Element: OL, Element: OrderedList,
} }
export default ol export default ol

View File

@@ -0,0 +1,15 @@
'use client'
import React from 'react'
import { useElement } from '../../providers/ElementProvider'
import './index.scss'
export const UnorderedList: React.FC = () => {
const { attributes, children } = useElement()
return (
<ul className="rich-text-ul" {...attributes}>
{children}
</ul>
)
}

View File

@@ -1,22 +1,21 @@
import React from 'react' import React from 'react'
import type { RichTextCustomElement } from '../../..'
import ULIcon from '../../icons/UnorderedList' import ULIcon from '../../icons/UnorderedList'
import ListButton from '../ListButton' import ListButton from '../ListButton'
import './index.scss' import { UnorderedList } from './UnorderedList'
const UL = ({ attributes, children }) => ( const name = 'ul'
<ul className="rich-text-ul" {...attributes}>
{children}
</ul>
)
const ul = { const ul: RichTextCustomElement = {
name,
Button: () => ( Button: () => (
<ListButton format="ul"> <ListButton format={name}>
<ULIcon /> <ULIcon />
</ListButton> </ListButton>
), ),
Element: UL, Element: UnorderedList,
} }
export default ul export default ul

View File

@@ -0,0 +1,42 @@
'use client'
import React from 'react'
import type { FormFieldBase } from '../../../../ui/src/forms/fields/shared'
type ElementButtonContextType = {
fieldProps: FormFieldBase & {
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
path: string
schemaPath: string
}
const ElementButtonContext = React.createContext<ElementButtonContextType>({
fieldProps: {} as any,
path: '',
schemaPath: '',
})
export const ElementButtonProvider: React.FC<
ElementButtonContextType & {
children: React.ReactNode
}
> = (props) => {
const { children, ...rest } = props
return (
<ElementButtonContext.Provider
value={{
...rest,
}}
>
{children}
</ElementButtonContext.Provider>
)
}
export const useElementButton = () => {
const path = React.useContext(ElementButtonContext)
return path
}

View File

@@ -1,27 +1,45 @@
'use client' 'use client'
import type { Element } from 'slate'
import React from 'react' import React from 'react'
type ElementContextType = { import type { FormFieldBase } from '../../../../ui/src/forms/fields/shared'
type ElementContextType<T> = {
attributes: Record<string, unknown>
children: React.ReactNode
editorRef: React.MutableRefObject<HTMLDivElement>
element: T
fieldProps: FormFieldBase & {
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
path: string path: string
schemaPath: string schemaPath: string
} }
const ElementContext = React.createContext<ElementContextType>({ const ElementContext = React.createContext<ElementContextType<Element>>({
attributes: {},
children: null,
editorRef: null,
element: {} as Element,
fieldProps: {} as any,
path: '', path: '',
schemaPath: '', schemaPath: '',
}) })
export const ElementProvider: React.FC<{ export const ElementProvider: React.FC<
children: React.ReactNode ElementContextType<Element> & {
path: string childNodes: React.ReactNode
schemaPath: string }
}> = (props) => { > = (props) => {
const { children, ...rest } = props const { childNodes, children, ...rest } = props
return ( return (
<ElementContext.Provider <ElementContext.Provider
value={{ value={{
...rest, ...rest,
children: childNodes,
}} }}
> >
{children} {children}
@@ -29,7 +47,6 @@ export const ElementProvider: React.FC<{
) )
} }
export const useElement = () => { export const useElement = <T,>(): ElementContextType<T> => {
const path = React.useContext(ElementContext) return React.useContext(ElementContext) as ElementContextType<T>
return path
} }

View File

@@ -0,0 +1,16 @@
export type EnabledFeatures = {
elements: {
[name: string]: {
Button: React.ReactNode
Element: React.ReactNode
name: string
}
}
leaves: {
[name: string]: {
Button: React.ReactNode
Leaf: React.ReactNode
name: string
}
}
}

View File

@@ -1,15 +1,20 @@
import type { RichTextAdapter } from 'payload/types' import type { Field, RichTextAdapter } from 'payload/types'
import { withMergedProps } from '@payloadcms/ui/utilities' import { initI18n } from '@payloadcms/translations'
import { translations } from '@payloadcms/translations/client'
import { mapFields, withMergedProps } from '@payloadcms/ui/utilities'
import { sanitizeFields } from 'payload/config'
import { withNullableJSONSchemaType } from 'payload/utilities' import { withNullableJSONSchemaType } from 'payload/utilities'
import React from 'react' import React from 'react'
import type { AdapterArguments, RichTextCustomLeaf } from './types' import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from './types'
import RichTextCell from './cell' import RichTextCell from './cell'
import { richTextRelationshipPromise } from './data/richTextRelationshipPromise' import { richTextRelationshipPromise } from './data/richTextRelationshipPromise'
import { richTextValidate } from './data/validation' import { richTextValidate } from './data/validation'
import RichTextField from './field' import RichTextField from './field'
import elementTypes from './field/elements'
import { transformExtraFields } from './field/elements/link/utilities'
import leafTypes from './field/leaves' import leafTypes from './field/leaves'
export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], AdapterArguments, any> { export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], AdapterArguments, any> {
@@ -22,9 +27,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
Component: RichTextField, Component: RichTextField,
toMergeIntoProps: args, toMergeIntoProps: args,
}), }),
generateComponentMap: () => { generateComponentMap: ({ config }) => {
const componentMap = new Map() const componentMap = new Map()
const i18n = initI18n({ config: config.i18n, context: 'client', translations })
const validRelationships = config.collections.map((c) => c.slug) || []
;(args?.admin?.leaves || Object.values(leafTypes)).forEach((leaf) => { ;(args?.admin?.leaves || Object.values(leafTypes)).forEach((leaf) => {
let leafObject: RichTextCustomLeaf let leafObject: RichTextCustomLeaf
@@ -42,9 +50,92 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
componentMap.set(`leaf.component.${leafObject.name}`, <LeafComponent />) componentMap.set(`leaf.component.${leafObject.name}`, <LeafComponent />)
} }
}) })
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
let element: RichTextCustomElement
if (typeof el === 'object' && el !== null) {
element = el
} else if (typeof el === 'string' && elementTypes[el]) {
element = elementTypes[el]
}
if (element) {
const ElementButton = element.Button
const ElementComponent = element.Element
if (ElementButton) componentMap.set(`element.button.${element.name}`, <ElementButton />)
componentMap.set(`element.component.${element.name}`, <ElementComponent />)
switch (element.name) {
case 'link': {
const linkFields = sanitizeFields({
config: config,
fields: transformExtraFields(args.admin?.link?.fields, config, i18n),
validRelationships,
})
const mappedFields = mapFields({
config,
fieldSchema: linkFields,
operation: 'update',
permissions: {},
readOnly: false,
})
componentMap.set('link.fields', mappedFields)
return
}
case 'upload':
break
case 'relationship':
break
}
}
})
return componentMap return componentMap
}, },
generateSchemaMap: ({ config, schemaMap, schemaPath }) => {
const i18n = initI18n({ config: config.i18n, context: 'client', translations })
const validRelationships = config.collections.map((c) => c.slug) || []
;(args?.admin?.elements || Object.values(elementTypes)).forEach((el) => {
let element: RichTextCustomElement
if (typeof el === 'object' && el !== null) {
element = el
} else if (typeof el === 'string' && elementTypes[el]) {
element = elementTypes[el]
}
if (element) {
switch (element.name) {
case 'link': {
const linkFields = sanitizeFields({
config: config,
fields: transformExtraFields(args.admin?.link?.fields, config, i18n),
validRelationships,
})
schemaMap.set(`${schemaPath}.link`, linkFields)
return
}
case 'upload':
break
case 'relationship':
break
}
}
})
return schemaMap
},
outputSchema: ({ isRequired }) => { outputSchema: ({ isRequired }) => {
return { return {
items: { items: {

View File

@@ -14,7 +14,7 @@ export function nodeIsTextNode(node: ElementNode | TextNode): node is TextNode {
type RichTextPlugin = (editor: Editor) => Editor type RichTextPlugin = (editor: Editor) => Editor
export type RichTextCustomElement = { export type RichTextCustomElement = {
Button: React.ComponentType<any> Button?: React.ComponentType<any>
Element: React.ComponentType<any> Element: React.ComponentType<any>
name: string name: string
plugins?: RichTextPlugin[] plugins?: RichTextPlugin[]

View File

@@ -27,5 +27,5 @@
"src/**/*.json", "src/**/*.json",
"src/field/leaves/italic/Italic" "src/field/leaves/italic/Italic"
], ],
"references": [{ "path": "../payload" }] // db-mongodb depends on payload "references": [{ "path": "../payload" }, { "path": "../translations" }, { "path": "../ui" }] // db-mongodb depends on payload
} }

View File

@@ -26,7 +26,7 @@ import type { EditViewProps } from '../../views/types'
import { DefaultEditView } from '../../views/Edit' import { DefaultEditView } from '../../views/Edit'
import { Gutter } from '../Gutter' import { Gutter } from '../Gutter'
import { LoadingOverlay } from '../Loading' import { LoadingOverlay } from '../Loading'
import { getFormState } from '../../views/Edit/getFormState' import { getFormState } from '../../utilities/getFormState'
import { useFieldPath } from '../../forms/FieldPathProvider' import { useFieldPath } from '../../forms/FieldPathProvider'
const Content: React.FC<DocumentDrawerProps> = ({ collectionSlug, Header, drawerSlug, onSave }) => { const Content: React.FC<DocumentDrawerProps> = ({ collectionSlug, Header, drawerSlug, onSave }) => {

View File

@@ -7,3 +7,5 @@ export { withMergedProps } from '../utilities/withMergedProps'
export type { FieldMap, MappedField } from '../utilities/buildComponentMap/types' export type { FieldMap, MappedField } from '../utilities/buildComponentMap/types'
export { buildFieldSchemaMap } from '../utilities/buildFieldSchemaMap' export { buildFieldSchemaMap } from '../utilities/buildFieldSchemaMap'
export type { FieldSchemaMap } from '../utilities/buildFieldSchemaMap/types' export type { FieldSchemaMap } from '../utilities/buildFieldSchemaMap/types'
export { mapFields } from '../utilities/buildComponentMap/mapFields'
export { getFormState } from '../utilities/getFormState'

View File

@@ -51,7 +51,6 @@ const Form: React.FC<Props> = (props) => {
className, className,
disableSuccessStatus, disableSuccessStatus,
disabled, disabled,
fields: fieldsFromProps,
// fields: fieldsFromProps = collection?.fields || global?.fields, // fields: fieldsFromProps = collection?.fields || global?.fields,
handleResponse, handleResponse,
initialState, // fully formed initial field state initialState, // fully formed initial field state

View File

@@ -15,7 +15,12 @@ import {
} from 'payload/types' } from 'payload/types'
import { Option } from 'payload/types' import { Option } from 'payload/types'
import { FormState } from '../..' import { FormState } from '../..'
import type { FieldMap, ReducedBlock, MappedTab } from '../../utilities/buildComponentMap/types' import type {
FieldMap,
ReducedBlock,
MappedTab,
MappedField,
} from '../../utilities/buildComponentMap/types'
export const fieldBaseClass = 'field-type' export const fieldBaseClass = 'field-type'
@@ -111,7 +116,7 @@ export type FormFieldBase = {
} }
| { | {
// For `richText` fields // For `richText` fields
richTextComponentMap?: Map<string, React.ReactNode> richTextComponentMap?: Map<string, React.ReactNode | MappedField[]>
} }
) )

View File

@@ -26,6 +26,7 @@ export type BuildFormStateArgs = {
operation?: 'create' | 'update' operation?: 'create' | 'update'
docPreferences: DocumentPreferences docPreferences: DocumentPreferences
formState?: FormState formState?: FormState
data?: Record<string, unknown>
schemaPath: string schemaPath: string
} }

View File

@@ -45,6 +45,7 @@ export const buildComponentMap = (args: {
afterListTable?.map((Component) => <Component />) afterListTable?.map((Component) => <Component />)
const mappedFields = mapFields({ const mappedFields = mapFields({
config,
fieldSchema: fields, fieldSchema: fields,
operation, operation,
permissions, permissions,
@@ -70,6 +71,7 @@ export const buildComponentMap = (args: {
const { fields, slug } = globalConfig const { fields, slug } = globalConfig
const mappedFields = mapFields({ const mappedFields = mapFields({
config,
fieldSchema: fields, fieldSchema: fields,
operation, operation,
permissions, permissions,

View File

@@ -1,7 +1,7 @@
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import type { FieldPermissions } from 'payload/auth' import type { FieldPermissions } from 'payload/auth'
import type { CellProps, Field, FieldWithPath, LabelProps } from 'payload/types' import type { CellProps, Field, FieldWithPath, LabelProps, SanitizedConfig } from 'payload/types'
import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/types' import { fieldAffectsData, fieldIsPresentationalOnly } from 'payload/types'
import { fieldTypes } from '../../forms/fields' import { fieldTypes } from '../../forms/fields'
import { FormFieldBase } from '../../forms/fields/shared' import { FormFieldBase } from '../../forms/fields/shared'
@@ -14,9 +14,9 @@ import DefaultLabel from '../../forms/Label'
import DefaultError from '../../forms/Error' import DefaultError from '../../forms/Error'
import DefaultDescription from '../../forms/FieldDescription' import DefaultDescription from '../../forms/FieldDescription'
import { HiddenInput } from '../..' import { HiddenInput } from '../..'
import { richText } from 'payload/fields/validations'
export const mapFields = (args: { export const mapFields = (args: {
config: SanitizedConfig
fieldSchema: FieldWithPath[] fieldSchema: FieldWithPath[]
filter?: (field: Field) => boolean filter?: (field: Field) => boolean
operation?: 'create' | 'update' operation?: 'create' | 'update'
@@ -29,6 +29,7 @@ export const mapFields = (args: {
parentPath?: string parentPath?: string
}): FieldMap => { }): FieldMap => {
const { const {
config,
fieldSchema, fieldSchema,
filter, filter,
operation = 'update', operation = 'update',
@@ -95,6 +96,7 @@ export const mapFields = (args: {
field.fields && field.fields &&
Array.isArray(field.fields) && Array.isArray(field.fields) &&
mapFields({ mapFields({
config,
fieldSchema: field.fields, fieldSchema: field.fields,
filter, filter,
operation, operation,
@@ -110,6 +112,7 @@ export const mapFields = (args: {
Array.isArray(field.tabs) && Array.isArray(field.tabs) &&
field.tabs.map((tab) => { field.tabs.map((tab) => {
const tabFieldMap = mapFields({ const tabFieldMap = mapFields({
config,
fieldSchema: tab.fields, fieldSchema: tab.fields,
filter, filter,
operation, operation,
@@ -134,12 +137,13 @@ export const mapFields = (args: {
Array.isArray(field.blocks) && Array.isArray(field.blocks) &&
field.blocks.map((block) => { field.blocks.map((block) => {
const blockFieldMap = mapFields({ const blockFieldMap = mapFields({
config,
fieldSchema: block.fields, fieldSchema: block.fields,
filter, filter,
operation, operation,
permissions, permissions,
readOnly: readOnlyOverride, readOnly: readOnlyOverride,
parentPath: path, parentPath: `${path}.${block.slug}`,
}) })
const reducedBlock: ReducedBlock = { const reducedBlock: ReducedBlock = {
@@ -267,7 +271,7 @@ export const mapFields = (args: {
} }
if (typeof field.editor.generateComponentMap === 'function') { if (typeof field.editor.generateComponentMap === 'function') {
const result = field.editor.generateComponentMap() const result = field.editor.generateComponentMap({ config, schemaPath: path })
// @ts-ignore-next-line // TODO: the `richTextComponentMap` is not found on the union type // @ts-ignore-next-line // TODO: the `richTextComponentMap` is not found on the union type
fieldComponentProps.richTextComponentMap = result fieldComponentProps.richTextComponentMap = result
} }

View File

@@ -7,6 +7,7 @@ export const buildFieldSchemaMap = (config: SanitizedConfig): FieldSchemaMap =>
config.collections.forEach((collection) => { config.collections.forEach((collection) => {
traverseFields({ traverseFields({
config,
schemaPath: collection.slug, schemaPath: collection.slug,
fields: collection.fields, fields: collection.fields,
schemaMap: result, schemaMap: result,
@@ -15,6 +16,7 @@ export const buildFieldSchemaMap = (config: SanitizedConfig): FieldSchemaMap =>
config.globals.forEach((global) => { config.globals.forEach((global) => {
traverseFields({ traverseFields({
config,
schemaPath: global.slug, schemaPath: global.slug,
fields: global.fields, fields: global.fields,
schemaMap: result, schemaMap: result,

View File

@@ -1,18 +1,20 @@
import { Field, tabHasName } from 'payload/types' import { Field, SanitizedConfig, tabHasName } from 'payload/types'
import { FieldSchemaMap } from './types' import { FieldSchemaMap } from './types'
type Args = { type Args = {
config: SanitizedConfig
fields: Field[] fields: Field[]
schemaMap: FieldSchemaMap schemaMap: FieldSchemaMap
schemaPath: string schemaPath: string
} }
export const traverseFields = ({ fields, schemaMap, schemaPath }: Args) => { export const traverseFields = ({ config, fields, schemaMap, schemaPath }: Args) => {
fields.map((field) => { fields.map((field) => {
switch (field.type) { switch (field.type) {
case 'group': case 'group':
case 'array': case 'array':
traverseFields({ traverseFields({
config,
fields: field.fields, fields: field.fields,
schemaMap, schemaMap,
schemaPath: `${schemaPath}.${field.name}`, schemaPath: `${schemaPath}.${field.name}`,
@@ -22,6 +24,7 @@ export const traverseFields = ({ fields, schemaMap, schemaPath }: Args) => {
case 'collapsible': case 'collapsible':
case 'row': case 'row':
traverseFields({ traverseFields({
config,
fields: field.fields, fields: field.fields,
schemaMap, schemaMap,
schemaPath, schemaPath,
@@ -31,6 +34,7 @@ export const traverseFields = ({ fields, schemaMap, schemaPath }: Args) => {
case 'blocks': case 'blocks':
field.blocks.map((block) => { field.blocks.map((block) => {
traverseFields({ traverseFields({
config,
fields: block.fields, fields: block.fields,
schemaMap, schemaMap,
schemaPath: `${schemaPath}.${field.name}.${block.slug}`, schemaPath: `${schemaPath}.${field.name}.${block.slug}`,
@@ -38,10 +42,18 @@ export const traverseFields = ({ fields, schemaMap, schemaPath }: Args) => {
}) })
break break
case 'richText':
if (typeof field.editor.generateSchemaMap === 'function') {
field.editor.generateSchemaMap({ schemaPath, config, schemaMap })
}
break
case 'tabs': case 'tabs':
field.tabs.map((tab) => { field.tabs.map((tab) => {
const tabSchemaPath = tabHasName(tab) ? `${schemaPath}.${tab.name}` : schemaPath const tabSchemaPath = tabHasName(tab) ? `${schemaPath}.${tab.name}` : schemaPath
traverseFields({ traverseFields({
config,
fields: tab.fields, fields: tab.fields,
schemaMap, schemaMap,
schemaPath: tabSchemaPath, schemaPath: tabSchemaPath,

View File

@@ -1,6 +1,6 @@
import { SanitizedConfig } from 'payload/types' import { SanitizedConfig } from 'payload/types'
import { FormState } from '../../forms/Form/types' import { FormState } from '../forms/Form/types'
import { BuildFormStateArgs } from '../..' import { BuildFormStateArgs } from '..'
export const getFormState = async (args: { export const getFormState = async (args: {
serverURL: SanitizedConfig['serverURL'] serverURL: SanitizedConfig['serverURL']

View File

@@ -20,7 +20,7 @@ import { Props as FormProps, FormState } from '../../forms/Form/types'
import './index.scss' import './index.scss'
import { BuildFormStateArgs } from '../..' import { BuildFormStateArgs } from '../..'
import { getFormState } from './getFormState' import { getFormState } from '../../utilities/getFormState'
import { FieldPathProvider } from '../../forms/FieldPathProvider' import { FieldPathProvider } from '../../forms/FieldPathProvider'
const baseClass = 'collection-edit' const baseClass = 'collection-edit'