chore: server-side rendered rich text elements
This commit is contained in:
@@ -18,8 +18,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@faceless-ui/modal": "2.0.1",
|
||||
"@payloadcms/translations": "workspace:^",
|
||||
"@payloadcms/ui": "workspace:^",
|
||||
"is-hotkey": "0.2.0",
|
||||
"react": "18.2.0",
|
||||
"slate": "0.91.4",
|
||||
@@ -34,7 +32,9 @@
|
||||
"payload": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"payload": "^2.3.0"
|
||||
"payload": "^2.3.0",
|
||||
"@payloadcms/translations": "workspace:^",
|
||||
"@payloadcms/ui": "workspace:^"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -13,7 +13,8 @@ import { withHistory } from 'slate-history'
|
||||
import { Editable, Slate, withReact } from 'slate-react'
|
||||
|
||||
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 { defaultRichTextValue } from '../data/defaultValue'
|
||||
@@ -24,25 +25,11 @@ import './index.scss'
|
||||
import toggleLeaf from './leaves/toggle'
|
||||
import withEnterBreakOut from './plugins/withEnterBreakOut'
|
||||
import withHTML from './plugins/withHTML'
|
||||
import { ElementButtonProvider } from './providers/ElementButtonProvider'
|
||||
import { ElementProvider } from './providers/ElementProvider'
|
||||
import { LeafButtonProvider } from './providers/LeafButtonProvider'
|
||||
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'
|
||||
|
||||
declare module 'slate' {
|
||||
@@ -75,38 +62,47 @@ const RichText: React.FC<
|
||||
width,
|
||||
} = props
|
||||
|
||||
const [leaves] = useState(() => {
|
||||
const enabledLeaves: Record<
|
||||
string,
|
||||
{
|
||||
Button: React.ReactNode
|
||||
Leaf: React.ReactNode
|
||||
name: string
|
||||
}
|
||||
> = {}
|
||||
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 (!enabledLeaves[leafName]) {
|
||||
enabledLeaves[leafName] = {
|
||||
if (!features.leaves[leafName]) {
|
||||
features.leaves[leafName] = {
|
||||
name: leafName,
|
||||
Button: null,
|
||||
Leaf: null,
|
||||
}
|
||||
}
|
||||
|
||||
if (key.startsWith('leaf.button.')) enabledLeaves[leafName].Button = value
|
||||
if (key.startsWith('leaf.component.')) enabledLeaves[leafName].Leaf = value
|
||||
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 enabledLeaves
|
||||
return features
|
||||
})
|
||||
|
||||
const elements: RichTextElement[] = defaultElements
|
||||
|
||||
const { i18n } = useTranslation()
|
||||
const editorRef = useRef(null)
|
||||
const toolbarRef = useRef(null)
|
||||
@@ -128,65 +124,72 @@ const RichText: React.FC<
|
||||
validate: memoizedValidate,
|
||||
})
|
||||
|
||||
const renderElement = useCallback(({ attributes, children, element }) => {
|
||||
// const matchedElement = enabledElements[element.type]
|
||||
// const Element = matchedElement?.Element
|
||||
const renderElement = useCallback(
|
||||
({ attributes, children, 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
|
||||
// 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 } }
|
||||
// }
|
||||
// }
|
||||
let attr = { ...attributes }
|
||||
|
||||
// if (Element) {
|
||||
// const el = (
|
||||
// <Element
|
||||
// attributes={attr}
|
||||
// editorRef={editorRef}
|
||||
// element={element}
|
||||
// fieldProps={props}
|
||||
// path={path}
|
||||
// >
|
||||
// {children}
|
||||
// </Element>
|
||||
// )
|
||||
// this converts text alignment to margin when dealing with void elements
|
||||
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 } }
|
||||
}
|
||||
}
|
||||
|
||||
// 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(
|
||||
({ attributes, children, leaf }) => {
|
||||
@@ -334,7 +337,7 @@ const RichText: React.FC<
|
||||
value={valueToRender as any[]}
|
||||
>
|
||||
<div className={`${baseClass}__wrapper`}>
|
||||
{elements?.length + Object.keys(leaves)?.length > 0 && (
|
||||
{Object.keys(elements)?.length + Object.keys(leaves)?.length > 0 && (
|
||||
<div
|
||||
className={[`${baseClass}__toolbar`, drawerIsOpen && `${baseClass}__drawerIsOpen`]
|
||||
.filter(Boolean)
|
||||
@@ -342,20 +345,24 @@ const RichText: React.FC<
|
||||
ref={toolbarRef}
|
||||
>
|
||||
<div className={`${baseClass}__toolbar-wrap`}>
|
||||
{/* {elements.map((element, i) => {
|
||||
let elementName: string
|
||||
if (typeof element === 'object' && element?.name) elementName = element.name
|
||||
if (typeof element === 'string') elementName = element
|
||||
|
||||
const elementType = enabledElements[elementName]
|
||||
const Button = elementType?.Button
|
||||
{Object.values(elements).map((element, i) => {
|
||||
const Button = element?.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
|
||||
})} */}
|
||||
})}
|
||||
{Object.values(leaves).map((leaf, i) => {
|
||||
const Button = leaf?.Button
|
||||
|
||||
@@ -363,7 +370,7 @@ const RichText: React.FC<
|
||||
return (
|
||||
<LeafButtonProvider
|
||||
fieldProps={props}
|
||||
key={i}
|
||||
key={leaf.name}
|
||||
path={path}
|
||||
schemaPath={schemaPath}
|
||||
>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback } from 'react'
|
||||
import { useSlate } from 'slate-react'
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import BlockquoteIcon from '../../icons/Blockquote'
|
||||
import ElementButton from '../Button'
|
||||
import './index.scss'
|
||||
import { Blockquote } from './Blockquote'
|
||||
|
||||
const Blockquote = ({ attributes, children }) => (
|
||||
<blockquote className="rich-text-blockquote" {...attributes}>
|
||||
{children}
|
||||
</blockquote>
|
||||
)
|
||||
const name = 'blockquote'
|
||||
|
||||
const blockquote = {
|
||||
const blockquote: RichTextCustomElement = {
|
||||
name,
|
||||
Button: () => (
|
||||
<ElementButton format="blockquote">
|
||||
<ElementButton format={name}>
|
||||
<BlockquoteIcon />
|
||||
</ElementButton>
|
||||
),
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h1/Heading1.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h1/Heading1.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H1Icon from '../../icons/headings/H1'
|
||||
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: () => (
|
||||
<ElementButton format="h1">
|
||||
<ElementButton format={name}>
|
||||
<H1Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H1,
|
||||
Element: Heading1,
|
||||
}
|
||||
|
||||
export default h1
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h2/Heading2.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h2/Heading2.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H2Icon from '../../icons/headings/H2'
|
||||
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: () => (
|
||||
<ElementButton format="h2">
|
||||
<ElementButton format={name}>
|
||||
<H2Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H2,
|
||||
Element: Heading2,
|
||||
}
|
||||
|
||||
export default h2
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h3/Heading3.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h3/Heading3.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H3Icon from '../../icons/headings/H3'
|
||||
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: () => (
|
||||
<ElementButton format="h3">
|
||||
<ElementButton format={name}>
|
||||
<H3Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H3,
|
||||
Element: Heading3,
|
||||
}
|
||||
|
||||
export default h3
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h4/Heading4.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h4/Heading4.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H4Icon from '../../icons/headings/H4'
|
||||
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: () => (
|
||||
<ElementButton format="h4">
|
||||
<ElementButton format={name}>
|
||||
<H4Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H4,
|
||||
Element: Heading4,
|
||||
}
|
||||
|
||||
export default h4
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h5/Heading5.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h5/Heading5.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H5Icon from '../../icons/headings/H5'
|
||||
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: () => (
|
||||
<ElementButton format="h5">
|
||||
<ElementButton format={name}>
|
||||
<H5Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H5,
|
||||
Element: Heading5,
|
||||
}
|
||||
|
||||
export default h5
|
||||
|
||||
11
packages/richtext-slate/src/field/elements/h6/Heading6.tsx
Normal file
11
packages/richtext-slate/src/field/elements/h6/Heading6.tsx
Normal 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>
|
||||
}
|
||||
@@ -1,17 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import H6Icon from '../../icons/headings/H6'
|
||||
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: () => (
|
||||
<ElementButton format="h6">
|
||||
<ElementButton format={name}>
|
||||
<H6Icon />
|
||||
</ElementButton>
|
||||
),
|
||||
Element: H6,
|
||||
Element: Heading6,
|
||||
}
|
||||
|
||||
export default h6
|
||||
|
||||
214
packages/richtext-slate/src/field/elements/indent/Button.tsx
Normal file
214
packages/richtext-slate/src/field/elements/indent/Button.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
12
packages/richtext-slate/src/field/elements/indent/index.ts
Normal file
12
packages/richtext-slate/src/field/elements/indent/index.ts
Normal 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
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { RichTextCustomElement } from '../..'
|
||||
|
||||
import blockquote from './blockquote'
|
||||
import h1 from './h1'
|
||||
import h2 from './h2'
|
||||
@@ -5,16 +7,16 @@ import h3 from './h3'
|
||||
import h4 from './h4'
|
||||
import h5 from './h5'
|
||||
import h6 from './h6'
|
||||
import indent from './indent'
|
||||
// import indent from './indent'
|
||||
import li from './li'
|
||||
import link from './link'
|
||||
import ol from './ol'
|
||||
import relationship from './relationship'
|
||||
import textAlign from './textAlign'
|
||||
// import relationship from './relationship'
|
||||
// import textAlign from './textAlign'
|
||||
import ul from './ul'
|
||||
import upload from './upload'
|
||||
// import upload from './upload'
|
||||
|
||||
const elements = {
|
||||
const elements: Record<string, RichTextCustomElement> = {
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
@@ -22,14 +24,14 @@ const elements = {
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
indent,
|
||||
// indent,
|
||||
li,
|
||||
link,
|
||||
ol,
|
||||
relationship,
|
||||
textAlign,
|
||||
// relationship,
|
||||
// textAlign,
|
||||
ul,
|
||||
upload,
|
||||
// upload,
|
||||
}
|
||||
|
||||
export default elements
|
||||
|
||||
30
packages/richtext-slate/src/field/elements/li/ListItem.tsx
Normal file
30
packages/richtext-slate/src/field/elements/li/ListItem.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,25 +1,10 @@
|
||||
import React from 'react'
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import listTypes from '../listTypes'
|
||||
import { ListItemElement } from './ListItem'
|
||||
|
||||
const LI = (props) => {
|
||||
const { attributes, children, element } = props
|
||||
const disableListStyle =
|
||||
element.children.length >= 1 && listTypes.includes(element.children?.[0]?.type)
|
||||
|
||||
return (
|
||||
<li
|
||||
style={{
|
||||
listStyle: disableListStyle ? 'none' : undefined,
|
||||
listStylePosition: disableListStyle ? 'outside' : undefined,
|
||||
}}
|
||||
{...attributes}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
)
|
||||
const listItem: RichTextCustomElement = {
|
||||
name: 'li',
|
||||
Element: ListItemElement,
|
||||
}
|
||||
|
||||
export default {
|
||||
Element: LI,
|
||||
}
|
||||
export default listItem
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import type { Fields } from '@payloadcms/ui'
|
||||
import type { FormState } from '@payloadcms/ui'
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import {
|
||||
buildStateFromSchema,
|
||||
getFormState,
|
||||
reduceFieldsToValues,
|
||||
useAuth,
|
||||
useConfig,
|
||||
useDocumentInfo,
|
||||
useDrawerSlug,
|
||||
useLocale,
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
import React, { Fragment, useState } from 'react'
|
||||
import { Editor, Range, Transforms } from 'slate'
|
||||
import { ReactEditor, useSlate } from 'slate-react'
|
||||
|
||||
import type { FieldProps } from '../../../../types'
|
||||
|
||||
import LinkIcon from '../../../icons/Link'
|
||||
import { useElementButton } from '../../../providers/ElementButtonProvider'
|
||||
import ElementButton from '../../Button'
|
||||
import isElementActive from '../../isActive'
|
||||
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.
|
||||
@@ -64,35 +60,23 @@ const insertLink = (editor, fields) => {
|
||||
ReactEditor.focus(editor)
|
||||
}
|
||||
|
||||
export const LinkButton: React.FC<{
|
||||
fieldProps: FieldProps
|
||||
path: string
|
||||
}> = ({ fieldProps }) => {
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields
|
||||
const { user } = useAuth()
|
||||
const { code: locale } = useLocale()
|
||||
const [initialState, setInitialState] = useState<Fields>({})
|
||||
export const LinkButton: React.FC = () => {
|
||||
const { fieldProps } = useElementButton()
|
||||
const [initialState, setInitialState] = useState<FormState>({})
|
||||
|
||||
const { i18n, t } = useTranslation()
|
||||
const { t } = useTranslation()
|
||||
const editor = useSlate()
|
||||
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 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 (
|
||||
<Fragment>
|
||||
@@ -104,24 +88,22 @@ export const LinkButton: React.FC<{
|
||||
unwrapLink(editor)
|
||||
} else {
|
||||
openModal(drawerSlug)
|
||||
|
||||
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection)
|
||||
|
||||
if (!isCollapsed) {
|
||||
const data = {
|
||||
text: editor.selection ? Editor.string(editor, editor.selection) : '',
|
||||
}
|
||||
|
||||
const preferences = await getDocPreferences()
|
||||
const state = await buildStateFromSchema({
|
||||
config,
|
||||
data,
|
||||
fieldSchema,
|
||||
locale,
|
||||
operation: 'create',
|
||||
preferences,
|
||||
t,
|
||||
user,
|
||||
const docPreferences = await getDocPreferences()
|
||||
const state = await getFormState({
|
||||
apiRoute: config.routes.api,
|
||||
body: {
|
||||
id,
|
||||
data,
|
||||
docPreferences,
|
||||
operation: 'update',
|
||||
schemaPath: linkFieldsSchemaPath,
|
||||
},
|
||||
serverURL: config.serverURL,
|
||||
})
|
||||
setInitialState(state)
|
||||
}
|
||||
@@ -133,7 +115,7 @@ export const LinkButton: React.FC<{
|
||||
</ElementButton>
|
||||
<LinkDrawer
|
||||
drawerSlug={drawerSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
|
||||
handleClose={() => {
|
||||
closeModal(drawerSlug)
|
||||
}}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { Fields } from '@payloadcms/ui'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
|
||||
import { useModal } from '@faceless-ui/modal'
|
||||
import { getTranslation } from '@payloadcms/translations'
|
||||
import {
|
||||
Button,
|
||||
FormState,
|
||||
Popup,
|
||||
Translation,
|
||||
buildStateFromSchema,
|
||||
getFormState,
|
||||
reduceFieldsToValues,
|
||||
useAuth,
|
||||
useConfig,
|
||||
@@ -18,17 +16,16 @@ import {
|
||||
useLocale,
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
import { sanitizeFields } from 'payload/config'
|
||||
import { deepCopyObject } from 'payload/utilities'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { Editor, Node, Transforms } from 'slate'
|
||||
import { ReactEditor, useSlate } from 'slate-react'
|
||||
|
||||
import type { FieldProps } from '../../../../types'
|
||||
|
||||
import { useElement } from '../../../providers/ElementProvider'
|
||||
import { LinkDrawer } from '../LinkDrawer'
|
||||
import { transformExtraFields, unwrapLink } from '../utilities'
|
||||
import { unwrapLink } from '../utilities'
|
||||
import './index.scss'
|
||||
import { LinkElementType } from '../types'
|
||||
|
||||
const baseClass = 'rich-text-link'
|
||||
|
||||
@@ -36,7 +33,7 @@ const baseClass = 'rich-text-link'
|
||||
* 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}
|
||||
*/
|
||||
const insertChange = (editor, fields, customFieldSchema) => {
|
||||
const insertChange = (editor, fields) => {
|
||||
const data = reduceFieldsToValues(fields, true)
|
||||
|
||||
const [, parentPath] = Editor.above(editor)
|
||||
@@ -48,10 +45,6 @@ const insertChange = (editor, fields, customFieldSchema) => {
|
||||
url: data.url,
|
||||
}
|
||||
|
||||
if (customFieldSchema) {
|
||||
newNode.fields = data.fields
|
||||
}
|
||||
|
||||
Transforms.setNodes(editor, newNode, { at: parentPath })
|
||||
|
||||
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'block' })
|
||||
@@ -61,16 +54,14 @@ const insertChange = (editor, fields, customFieldSchema) => {
|
||||
ReactEditor.focus(editor)
|
||||
}
|
||||
|
||||
export const LinkElement: React.FC<{
|
||||
attributes: HTMLAttributes<HTMLDivElement>
|
||||
children: React.ReactNode
|
||||
editorRef: React.RefObject<HTMLDivElement>
|
||||
element: any
|
||||
fieldProps: FieldProps
|
||||
}> = (props) => {
|
||||
const { attributes, children, editorRef, element, fieldProps } = props
|
||||
export const LinkElement = () => {
|
||||
const { attributes, children, editorRef, element, fieldProps, schemaPath } =
|
||||
useElement<LinkElementType>()
|
||||
|
||||
const customFieldSchema = fieldProps?.admin?.link?.fields
|
||||
const linkFieldsSchemaPath = `${schemaPath}.link.fields`
|
||||
|
||||
const { richTextComponentMap } = fieldProps
|
||||
const fieldMap = richTextComponentMap.get(linkFieldsSchemaPath)
|
||||
|
||||
const editor = useSlate()
|
||||
const config = useConfig()
|
||||
@@ -80,20 +71,8 @@ export const LinkElement: React.FC<{
|
||||
const { closeModal, openModal, toggleModal } = useModal()
|
||||
const [renderModal, setRenderModal] = useState(false)
|
||||
const [renderPopup, setRenderPopup] = useState(false)
|
||||
const [initialState, setInitialState] = useState<Fields>({})
|
||||
const { 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 [initialState, setInitialState] = useState<FormState>({})
|
||||
const { id, getDocPreferences } = useDocumentInfo()
|
||||
|
||||
const drawerSlug = useDrawerSlug('rich-text-link')
|
||||
|
||||
@@ -114,22 +93,25 @@ export const LinkElement: React.FC<{
|
||||
url: element.url,
|
||||
}
|
||||
|
||||
const preferences = await getDocPreferences()
|
||||
const state = await buildStateFromSchema({
|
||||
config,
|
||||
data,
|
||||
fieldSchema,
|
||||
locale,
|
||||
operation: 'update',
|
||||
preferences,
|
||||
t,
|
||||
user,
|
||||
const docPreferences = await getDocPreferences()
|
||||
|
||||
const state = await getFormState({
|
||||
apiRoute: config.routes.api,
|
||||
body: {
|
||||
id,
|
||||
data,
|
||||
docPreferences,
|
||||
operation: 'update',
|
||||
schemaPath: linkFieldsSchemaPath,
|
||||
},
|
||||
serverURL: config.serverURL,
|
||||
})
|
||||
|
||||
setInitialState(state)
|
||||
}
|
||||
|
||||
awaitInitialState()
|
||||
}, [renderModal, element, fieldSchema, user, locale, t, getDocPreferences, config])
|
||||
}, [renderModal, element, user, locale, t, getDocPreferences, config])
|
||||
|
||||
return (
|
||||
<span className={baseClass} {...attributes}>
|
||||
@@ -137,13 +119,13 @@ export const LinkElement: React.FC<{
|
||||
{renderModal && (
|
||||
<LinkDrawer
|
||||
drawerSlug={drawerSlug}
|
||||
fieldSchema={fieldSchema}
|
||||
fieldMap={Array.isArray(fieldMap) ? fieldMap : []}
|
||||
handleClose={() => {
|
||||
toggleModal(drawerSlug)
|
||||
setRenderModal(false)
|
||||
}}
|
||||
handleModalSubmit={(fields) => {
|
||||
insertChange(editor, fields, customFieldSchema)
|
||||
insertChange(editor, fields)
|
||||
closeModal(drawerSlug)
|
||||
}}
|
||||
initialState={initialState}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Form,
|
||||
FormSubmit,
|
||||
RenderFields,
|
||||
fieldTypes,
|
||||
useEditDepth,
|
||||
useTranslation,
|
||||
} from '@payloadcms/ui'
|
||||
@@ -20,7 +19,7 @@ const baseClass = 'rich-text-link-edit-modal'
|
||||
|
||||
export const LinkDrawer: React.FC<Props> = ({
|
||||
drawerSlug,
|
||||
fieldSchema,
|
||||
fieldMap,
|
||||
handleModalSubmit,
|
||||
initialState,
|
||||
}) => {
|
||||
@@ -28,13 +27,8 @@ export const LinkDrawer: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Drawer className={baseClass} slug={drawerSlug} title={t('fields:editLink')}>
|
||||
<Form fields={fieldSchema} initialState={initialState} onSubmit={handleModalSubmit}>
|
||||
<RenderFields
|
||||
fieldSchema={fieldSchema}
|
||||
fieldTypes={fieldTypes}
|
||||
forceRender
|
||||
readOnly={false}
|
||||
/>
|
||||
<Form initialState={initialState} onSubmit={handleModalSubmit}>
|
||||
<RenderFields fieldMap={fieldMap} forceRender readOnly={false} />
|
||||
<LinkSubmit />
|
||||
</Form>
|
||||
</Drawer>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { Field, Fields } from 'payload/types'
|
||||
import type { FieldMap, FormState } from '@payloadcms/ui'
|
||||
|
||||
export type Props = {
|
||||
drawerSlug: string
|
||||
fieldSchema: Field[]
|
||||
fieldMap: FieldMap
|
||||
handleClose: () => void
|
||||
handleModalSubmit: (fields: Fields, data: Record<string, unknown>) => void
|
||||
initialState?: Fields
|
||||
handleModalSubmit: (fields: FormState, data: Record<string, unknown>) => void
|
||||
initialState?: FormState
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import { LinkButton } from './Button'
|
||||
import { LinkElement } from './Element'
|
||||
import { withLinks } from './utilities'
|
||||
|
||||
const link = {
|
||||
const link: RichTextCustomElement = {
|
||||
name: 'link',
|
||||
Button: LinkButton,
|
||||
Element: LinkElement,
|
||||
plugins: [withLinks],
|
||||
|
||||
9
packages/richtext-slate/src/field/elements/link/types.ts
Normal file
9
packages/richtext-slate/src/field/elements/link/types.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import OLIcon from '../../icons/OrderedList'
|
||||
import ListButton from '../ListButton'
|
||||
import './index.scss'
|
||||
import { OrderedList } from './OrderedList'
|
||||
|
||||
const OL = ({ attributes, children }) => (
|
||||
<ol className="rich-text-ol" {...attributes}>
|
||||
{children}
|
||||
</ol>
|
||||
)
|
||||
const name = 'ol'
|
||||
|
||||
const ol = {
|
||||
const ol: RichTextCustomElement = {
|
||||
name,
|
||||
Button: () => (
|
||||
<ListButton format="ol">
|
||||
<ListButton format={name}>
|
||||
<OLIcon />
|
||||
</ListButton>
|
||||
),
|
||||
Element: OL,
|
||||
Element: OrderedList,
|
||||
}
|
||||
|
||||
export default ol
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,21 @@
|
||||
import React from 'react'
|
||||
|
||||
import type { RichTextCustomElement } from '../../..'
|
||||
|
||||
import ULIcon from '../../icons/UnorderedList'
|
||||
import ListButton from '../ListButton'
|
||||
import './index.scss'
|
||||
import { UnorderedList } from './UnorderedList'
|
||||
|
||||
const UL = ({ attributes, children }) => (
|
||||
<ul className="rich-text-ul" {...attributes}>
|
||||
{children}
|
||||
</ul>
|
||||
)
|
||||
const name = 'ul'
|
||||
|
||||
const ul = {
|
||||
const ul: RichTextCustomElement = {
|
||||
name,
|
||||
Button: () => (
|
||||
<ListButton format="ul">
|
||||
<ListButton format={name}>
|
||||
<ULIcon />
|
||||
</ListButton>
|
||||
),
|
||||
Element: UL,
|
||||
Element: UnorderedList,
|
||||
}
|
||||
|
||||
export default ul
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,27 +1,45 @@
|
||||
'use client'
|
||||
import type { Element } from 'slate'
|
||||
|
||||
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
|
||||
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: '',
|
||||
schemaPath: '',
|
||||
})
|
||||
|
||||
export const ElementProvider: React.FC<{
|
||||
children: React.ReactNode
|
||||
path: string
|
||||
schemaPath: string
|
||||
}> = (props) => {
|
||||
const { children, ...rest } = props
|
||||
export const ElementProvider: React.FC<
|
||||
ElementContextType<Element> & {
|
||||
childNodes: React.ReactNode
|
||||
}
|
||||
> = (props) => {
|
||||
const { childNodes, children, ...rest } = props
|
||||
|
||||
return (
|
||||
<ElementContext.Provider
|
||||
value={{
|
||||
...rest,
|
||||
children: childNodes,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
@@ -29,7 +47,6 @@ export const ElementProvider: React.FC<{
|
||||
)
|
||||
}
|
||||
|
||||
export const useElement = () => {
|
||||
const path = React.useContext(ElementContext)
|
||||
return path
|
||||
export const useElement = <T,>(): ElementContextType<T> => {
|
||||
return React.useContext(ElementContext) as ElementContextType<T>
|
||||
}
|
||||
|
||||
16
packages/richtext-slate/src/field/types.ts
Normal file
16
packages/richtext-slate/src/field/types.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 React from 'react'
|
||||
|
||||
import type { AdapterArguments, RichTextCustomLeaf } from './types'
|
||||
import type { AdapterArguments, RichTextCustomElement, RichTextCustomLeaf } from './types'
|
||||
|
||||
import RichTextCell from './cell'
|
||||
import { richTextRelationshipPromise } from './data/richTextRelationshipPromise'
|
||||
import { richTextValidate } from './data/validation'
|
||||
import RichTextField from './field'
|
||||
import elementTypes from './field/elements'
|
||||
import { transformExtraFields } from './field/elements/link/utilities'
|
||||
import leafTypes from './field/leaves'
|
||||
|
||||
export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], AdapterArguments, any> {
|
||||
@@ -22,9 +27,12 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
|
||||
Component: RichTextField,
|
||||
toMergeIntoProps: args,
|
||||
}),
|
||||
generateComponentMap: () => {
|
||||
generateComponentMap: ({ config }) => {
|
||||
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) => {
|
||||
let leafObject: RichTextCustomLeaf
|
||||
|
||||
@@ -42,9 +50,92 @@ export function slateEditor(args: AdapterArguments): RichTextAdapter<any[], Adap
|
||||
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
|
||||
},
|
||||
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 }) => {
|
||||
return {
|
||||
items: {
|
||||
|
||||
@@ -14,7 +14,7 @@ export function nodeIsTextNode(node: ElementNode | TextNode): node is TextNode {
|
||||
type RichTextPlugin = (editor: Editor) => Editor
|
||||
|
||||
export type RichTextCustomElement = {
|
||||
Button: React.ComponentType<any>
|
||||
Button?: React.ComponentType<any>
|
||||
Element: React.ComponentType<any>
|
||||
name: string
|
||||
plugins?: RichTextPlugin[]
|
||||
|
||||
@@ -27,5 +27,5 @@
|
||||
"src/**/*.json",
|
||||
"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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user