436 lines
14 KiB
TypeScript
436 lines
14 KiB
TypeScript
'use client'
|
|
|
|
import type { FormFieldBase } from '@payloadcms/ui/types'
|
|
import type { BaseEditor, BaseOperation } from 'slate'
|
|
import type { HistoryEditor } from 'slate-history'
|
|
import type { ReactEditor } from 'slate-react'
|
|
|
|
import { getTranslation } from '@payloadcms/translations'
|
|
import { useEditDepth, useField, useTranslation } from '@payloadcms/ui'
|
|
import { withCondition } from '@payloadcms/ui/forms'
|
|
import isHotkey from 'is-hotkey'
|
|
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
|
import { Node, Element as SlateElement, Text, Transforms, createEditor } from 'slate'
|
|
import { withHistory } from 'slate-history'
|
|
import { Editable, Slate, withReact } from 'slate-react'
|
|
|
|
import type { ElementNode, RichTextPlugin, TextNode } from '../types.js'
|
|
import type { EnabledFeatures } from './types.js'
|
|
|
|
import { defaultRichTextValue } from '../data/defaultValue.js'
|
|
import { richTextValidate } from '../data/validation.js'
|
|
import { listTypes } from './elements/listTypes.js'
|
|
import { hotkeys } from './hotkeys.js'
|
|
import './index.scss'
|
|
import { toggleLeaf } from './leaves/toggle.js'
|
|
import { withEnterBreakOut } from './plugins/withEnterBreakOut.js'
|
|
import { withHTML } from './plugins/withHTML.js'
|
|
import { ElementButtonProvider } from './providers/ElementButtonProvider.js'
|
|
import { ElementProvider } from './providers/ElementProvider.js'
|
|
import { LeafButtonProvider } from './providers/LeafButtonProvider.js'
|
|
import { LeafProvider } from './providers/LeafProvider.js'
|
|
|
|
const baseClass = 'rich-text'
|
|
|
|
declare module 'slate' {
|
|
interface CustomTypes {
|
|
Editor: BaseEditor & ReactEditor & HistoryEditor
|
|
Element: ElementNode
|
|
Text: TextNode
|
|
}
|
|
}
|
|
|
|
const RichTextField: React.FC<
|
|
FormFieldBase & {
|
|
elements: EnabledFeatures['elements']
|
|
leaves: EnabledFeatures['leaves']
|
|
name: string
|
|
plugins: RichTextPlugin[]
|
|
richTextComponentMap: Map<string, React.ReactNode>
|
|
}
|
|
> = (props) => {
|
|
const {
|
|
name,
|
|
Description,
|
|
Error,
|
|
Label,
|
|
className,
|
|
elements,
|
|
leaves,
|
|
path: pathFromProps,
|
|
placeholder,
|
|
plugins,
|
|
readOnly,
|
|
required,
|
|
style,
|
|
validate = richTextValidate,
|
|
width,
|
|
} = props
|
|
|
|
const { i18n } = useTranslation()
|
|
const editorRef = useRef(null)
|
|
const toolbarRef = useRef(null)
|
|
|
|
const drawerDepth = useEditDepth()
|
|
const drawerIsOpen = drawerDepth > 1
|
|
|
|
const memoizedValidate = useCallback(
|
|
(value, validationOptions) => {
|
|
if (typeof validate === 'function') {
|
|
return validate(value, { ...validationOptions, required })
|
|
}
|
|
},
|
|
[validate, required],
|
|
)
|
|
|
|
const { initialValue, path, schemaPath, setValue, showError, value } = useField({
|
|
path: pathFromProps || name,
|
|
validate: memoizedValidate,
|
|
})
|
|
|
|
const editor = useMemo(() => {
|
|
let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor())))
|
|
|
|
CreatedEditor = withHTML(CreatedEditor)
|
|
|
|
if (plugins.length) {
|
|
CreatedEditor = plugins.reduce((editorWithPlugins, plugin) => {
|
|
return plugin(editorWithPlugins)
|
|
}, CreatedEditor)
|
|
}
|
|
|
|
return CreatedEditor
|
|
}, [plugins])
|
|
|
|
const renderElement = useCallback(
|
|
({ attributes, children, element }) => {
|
|
// return <div {...attributes}>{children}</div>
|
|
|
|
const matchedElement = elements[element.type]
|
|
const Element = matchedElement?.Element
|
|
|
|
let attr = { ...attributes }
|
|
|
|
// 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 } }
|
|
}
|
|
}
|
|
|
|
if (Element) {
|
|
const el = (
|
|
<ElementProvider
|
|
attributes={attr}
|
|
childNodes={children}
|
|
editorRef={editorRef}
|
|
element={element}
|
|
fieldProps={props}
|
|
path={path}
|
|
schemaPath={schemaPath}
|
|
>
|
|
{Element}
|
|
</ElementProvider>
|
|
)
|
|
|
|
return el
|
|
}
|
|
|
|
return <div {...attr}>{children}</div>
|
|
},
|
|
[elements, path, props, schemaPath],
|
|
)
|
|
|
|
const renderLeaf = useCallback(
|
|
({ attributes, children, leaf }) => {
|
|
const matchedLeaves = Object.entries(leaves).filter(([leafName]) => leaf[leafName])
|
|
|
|
if (matchedLeaves.length > 0) {
|
|
return matchedLeaves.reduce(
|
|
(result, [, leafConfig], i) => {
|
|
if (leafConfig?.Leaf) {
|
|
const Leaf = leafConfig.Leaf
|
|
|
|
return (
|
|
<LeafProvider
|
|
attributes={attributes}
|
|
editorRef={editorRef}
|
|
fieldProps={props}
|
|
key={i}
|
|
leaf={leaf}
|
|
path={path}
|
|
result={result}
|
|
schemaPath={schemaPath}
|
|
>
|
|
{Leaf}
|
|
</LeafProvider>
|
|
)
|
|
}
|
|
|
|
return result
|
|
},
|
|
<span {...attributes}>{children}</span>,
|
|
)
|
|
}
|
|
|
|
return <span {...attributes}>{children}</span>
|
|
},
|
|
[path, props, schemaPath, leaves],
|
|
)
|
|
|
|
// All slate changes fire the onChange event
|
|
// including selection changes
|
|
// so we will filter the set_selection operations out
|
|
// and only fire setValue when onChange is because of value
|
|
const handleChange = useCallback(
|
|
(val: unknown) => {
|
|
const ops = editor?.operations.filter((o: BaseOperation) => {
|
|
if (o) {
|
|
return o.type !== 'set_selection'
|
|
}
|
|
return false
|
|
})
|
|
|
|
if (ops && Array.isArray(ops) && ops.length > 0) {
|
|
if (!readOnly && val !== defaultRichTextValue && val !== value) {
|
|
setValue(val)
|
|
}
|
|
}
|
|
},
|
|
[editor?.operations, readOnly, setValue, value],
|
|
)
|
|
|
|
useEffect(() => {
|
|
function setClickableState(clickState: 'disabled' | 'enabled') {
|
|
const selectors = 'button, a, [role="button"]'
|
|
const toolbarButtons: (HTMLAnchorElement | HTMLButtonElement)[] =
|
|
toolbarRef.current?.querySelectorAll(selectors)
|
|
|
|
;(toolbarButtons || []).forEach((child) => {
|
|
const isButton = child.tagName === 'BUTTON'
|
|
const isDisabling = clickState === 'disabled'
|
|
child.setAttribute('tabIndex', isDisabling ? '-1' : '0')
|
|
if (isButton) child.setAttribute('disabled', isDisabling ? 'disabled' : null)
|
|
})
|
|
}
|
|
|
|
if (readOnly) {
|
|
setClickableState('disabled')
|
|
}
|
|
|
|
return () => {
|
|
if (readOnly) {
|
|
setClickableState('enabled')
|
|
}
|
|
}
|
|
}, [readOnly])
|
|
|
|
// useEffect(() => {
|
|
// // If there is a change to the initial value, we need to reset Slate history
|
|
// // and clear selection because the old selection may no longer be valid
|
|
// // as returned JSON may be modified in hooks and have a different shape
|
|
// if (editor.selection) {
|
|
// console.log('deselecting');
|
|
// ReactEditor.deselect(editor);
|
|
// }
|
|
// }, [path, editor]);
|
|
|
|
const classes = [
|
|
baseClass,
|
|
'field-type',
|
|
className,
|
|
showError && 'error',
|
|
readOnly && `${baseClass}--read-only`,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
|
|
let valueToRender = value
|
|
|
|
if (typeof valueToRender === 'string') {
|
|
try {
|
|
const parsedJSON = JSON.parse(valueToRender)
|
|
valueToRender = parsedJSON
|
|
} catch (err) {
|
|
valueToRender = null
|
|
}
|
|
}
|
|
|
|
if (!valueToRender) valueToRender = defaultRichTextValue
|
|
|
|
return (
|
|
<div
|
|
className={classes}
|
|
style={{
|
|
...style,
|
|
width,
|
|
}}
|
|
>
|
|
<div className={`${baseClass}__wrap`}>
|
|
{Error}
|
|
{Label}
|
|
<Slate
|
|
editor={editor}
|
|
key={JSON.stringify({ initialValue, path })} // makes sure slate is completely re-rendered when initialValue changes, bypassing the slate-internal value memoization. That way, external changes to the form will update the editor
|
|
onChange={handleChange}
|
|
value={valueToRender as any[]}
|
|
>
|
|
<div className={`${baseClass}__wrapper`}>
|
|
{Object.keys(elements)?.length + Object.keys(leaves)?.length > 0 && (
|
|
<div
|
|
className={[`${baseClass}__toolbar`, drawerIsOpen && `${baseClass}__drawerIsOpen`]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
ref={toolbarRef}
|
|
>
|
|
<div className={`${baseClass}__toolbar-wrap`}>
|
|
{Object.values(elements).map((element) => {
|
|
const Button = element?.Button
|
|
|
|
if (Button) {
|
|
return (
|
|
<ElementButtonProvider
|
|
fieldProps={props}
|
|
key={element.name}
|
|
path={path}
|
|
schemaPath={schemaPath}
|
|
>
|
|
{Button}
|
|
</ElementButtonProvider>
|
|
)
|
|
}
|
|
|
|
return null
|
|
})}
|
|
{Object.values(leaves).map((leaf) => {
|
|
const Button = leaf?.Button
|
|
|
|
if (Button) {
|
|
return (
|
|
<LeafButtonProvider
|
|
fieldProps={props}
|
|
key={leaf.name}
|
|
path={path}
|
|
schemaPath={schemaPath}
|
|
>
|
|
{Button}
|
|
</LeafButtonProvider>
|
|
)
|
|
}
|
|
|
|
return null
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className={`${baseClass}__editor`} ref={editorRef}>
|
|
<Editable
|
|
className={`${baseClass}__input`}
|
|
id={`field-${path.replace(/\./g, '__')}`}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
if (event.shiftKey) {
|
|
event.preventDefault()
|
|
editor.insertText('\n')
|
|
} else {
|
|
const selectedElement = Node.descendant(
|
|
editor,
|
|
editor.selection.anchor.path.slice(0, -1),
|
|
)
|
|
|
|
if (SlateElement.isElement(selectedElement)) {
|
|
// Allow hard enter to "break out" of certain elements
|
|
if (editor.shouldBreakOutOnEnter(selectedElement)) {
|
|
event.preventDefault()
|
|
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path)
|
|
|
|
if (
|
|
Text.isText(selectedLeaf) &&
|
|
String(selectedLeaf.text).length === editor.selection.anchor.offset
|
|
) {
|
|
Transforms.insertNodes(editor, { children: [{ text: '' }] })
|
|
} else {
|
|
Transforms.splitNodes(editor)
|
|
Transforms.setNodes(editor, {})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.key === 'Backspace') {
|
|
const selectedElement = Node.descendant(
|
|
editor,
|
|
editor.selection.anchor.path.slice(0, -1),
|
|
)
|
|
|
|
if (SlateElement.isElement(selectedElement) && selectedElement.type === 'li') {
|
|
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path)
|
|
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === 0) {
|
|
event.preventDefault()
|
|
Transforms.unwrapNodes(editor, {
|
|
match: (n) => SlateElement.isElement(n) && listTypes.includes(n.type),
|
|
mode: 'lowest',
|
|
split: true,
|
|
})
|
|
|
|
Transforms.setNodes(editor, { type: undefined })
|
|
}
|
|
} else if (editor.isVoid(selectedElement)) {
|
|
Transforms.removeNodes(editor)
|
|
}
|
|
}
|
|
|
|
Object.keys(hotkeys).forEach((hotkey) => {
|
|
if (isHotkey(hotkey, event as any)) {
|
|
event.preventDefault()
|
|
const mark = hotkeys[hotkey]
|
|
toggleLeaf(editor, mark)
|
|
}
|
|
})
|
|
}}
|
|
placeholder={getTranslation(placeholder, i18n)}
|
|
readOnly={readOnly}
|
|
renderElement={renderElement}
|
|
renderLeaf={renderLeaf}
|
|
spellCheck
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Slate>
|
|
{Description}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const RichText = withCondition(RichTextField)
|