feat: breaking: richtext adapter (#3311)

BREAKING: requires user to install @payloacms-richtext-slate and specify a `config.editor` property

* chore: move slate stuff into packages/richtext-slate

* chore: fieldTypes stuff

* chore: fix richtext-slate tsconfig

* chore: add clean:unix command

* chore: fix up things

* chore: undo subpath imports being hoisted up

* chore: fix incorrect imports

* chore: improve AdapterArguments type

* chore: remove unused richTextToHTML and stringifyRichText files

* fix: core-dev scss imports

* chore: fix publishConfig exports for richtext-slate

* chore: adjust joi schema for richtext field

* chore: various fixes

* chore: handle afterRead population in richText adapter

* chore: handle more after-read promise stuff

* chore: fix joi validation

* chore: add richtext adapter to tests

* chore: merge adapter props with field props

* chore: index.tsx => index.ts

* chore: rename `adapter` to `editor`

* chore: fix e2e tests not running due to importing a constant from a file (`Tabs`) which imports createSlate.

This fails because createSlate imports React components.

* chore: remove unnecessary import

* chore: improve various typings

* chore: improve typings for List view Cell components

* feat: richText adapter cell component

* chore: add missing types packages for packages/richtext-slate

* chore: add new adapter interface properties to joi schema

* chore: withMergedProps utility which replaces getSlateCellComponent and getSlateFieldComponent

* feat: added config.defaultEditor property which is now required. field.editor is no longer required and overrides config.defaultEditor

* docs: mention editor and defaultEditor property in the docs

* chore: fix incorrectly formatted JSX in docs files breaking mdx parser

* chore: fix various errors

* chore: auto-generated pointer files
This commit is contained in:
Alessio Gravili
2023-09-19 11:03:31 +02:00
committed by GitHub
parent 835efdf400
commit a81401cf77
210 changed files with 2754 additions and 2319 deletions

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
/** @type {import('prettier').Config} */
module.exports = {
extends: ['@payloadcms'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
}

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": "inline",
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "commonjs"
}
}

View File

@@ -0,0 +1,44 @@
{
"name": "@payloadcms/richtext-slate",
"description": "The officially supported Slate richtext adapter for Payload",
"author": "Payload CMS, Inc.",
"dependencies": {
"@faceless-ui/modal": "2.0.1",
"i18next": "22.5.1",
"is-hotkey": "0.2.0",
"react": "18.2.0",
"react-i18next": "11.18.6",
"slate": "0.91.4",
"slate-history": "0.86.0",
"slate-hyperscript": "0.81.3",
"slate-react": "0.92.0"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/node": "20.5.7",
"@types/react": "18.2.15",
"payload": "workspace:*"
},
"exports": {
".": {
"default": "./src/index.ts",
"types": "./src/index.ts"
}
},
"license": "MIT",
"main": "./src/index.ts",
"publishConfig": {
"exports": null,
"main": "./dist/index.js",
"registry": "https://registry.npmjs.org/",
"types": "./dist/index.d.ts"
},
"repository": "https://github.com/payloadcms/payload",
"scripts": {
"build": "pnpm build:swc && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
"build:types": "tsc --emitDeclarationOnly --outDir dist"
},
"types": "./src/index.ts",
"version": "0.0.1"
}

View File

@@ -0,0 +1,16 @@
import type { CellComponentProps, RichTextField } from 'payload/types'
import React from 'react'
import type { AdapterArguments } from '../types'
const RichTextCell: React.FC<CellComponentProps<RichTextField<AdapterArguments>, any>> = ({
data,
}) => {
const flattenedText = data?.map((i) => i?.children?.map((c) => c.text)).join(' ')
const textToShow =
flattenedText?.length > 100 ? `${flattenedText.slice(0, 100)}\u2026` : flattenedText
return <span>{textToShow}</span>
}
export default RichTextCell

View File

@@ -0,0 +1,5 @@
export const defaultRichTextValue = [
{
children: [{ text: '' }],
},
]

View File

@@ -0,0 +1,53 @@
import type { PayloadRequest } from 'payload/types'
import type { Collection, Field, RichTextField } from 'payload/types'
import type { AdapterArguments } from '../types'
type Arguments = {
currentDepth?: number
data: unknown
depth: number
field: RichTextField<AdapterArguments>
key: number | string
overrideAccess?: boolean
req: PayloadRequest
showHiddenFields: boolean
}
export const populate = async ({
collection,
currentDepth,
data,
depth,
id,
key,
overrideAccess,
req,
showHiddenFields,
}: Omit<Arguments, 'field'> & {
collection: Collection
field: Field
id: string
}): Promise<void> => {
const dataRef = data as Record<string, unknown>
const doc = await req.payloadDataLoader.load(
JSON.stringify([
req.transactionID,
collection.config.slug,
id,
depth,
currentDepth + 1,
req.locale,
req.fallbackLocale,
typeof overrideAccess === 'undefined' ? false : overrideAccess,
showHiddenFields,
]),
)
if (doc) {
dataRef[key] = doc
} else {
dataRef[key] = null
}
}

View File

@@ -0,0 +1,202 @@
import type { Field, PayloadRequest } from 'payload/types'
import { fieldAffectsData, fieldHasSubFields, fieldIsArrayType } from 'payload/types'
import { populate } from './populate'
import { recurseRichText } from './richTextRelationshipPromise'
type NestedRichTextFieldsArgs = {
currentDepth?: number
data: unknown
depth: number
fields: Field[]
overrideAccess: boolean
promises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
}
export const recurseNestedFields = ({
currentDepth = 0,
data,
depth,
fields,
overrideAccess = false,
promises,
req,
showHiddenFields,
}: NestedRichTextFieldsArgs): void => {
fields.forEach((field) => {
if (field.type === 'relationship' || field.type === 'upload') {
if (field.type === 'relationship') {
if (field.hasMany && Array.isArray(data[field.name])) {
if (Array.isArray(field.relationTo)) {
data[field.name].forEach(({ relationTo, value }, i) => {
const collection = req.payload.collections[relationTo]
if (collection) {
promises.push(
populate({
collection,
currentDepth,
data: data[field.name],
depth,
field,
id: value,
key: i,
overrideAccess,
req,
showHiddenFields,
}),
)
}
})
} else {
data[field.name].forEach((id, i) => {
const collection = req.payload.collections[field.relationTo as string]
if (collection) {
promises.push(
populate({
collection,
currentDepth,
data: data[field.name],
depth,
field,
id,
key: i,
overrideAccess,
req,
showHiddenFields,
}),
)
}
})
}
} else if (
Array.isArray(field.relationTo) &&
data[field.name]?.value &&
data[field.name]?.relationTo
) {
const collection = req.payload.collections[data[field.name].relationTo]
promises.push(
populate({
collection,
currentDepth,
data: data[field.name],
depth,
field,
id: data[field.name].value,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
}
if (typeof data[field.name] !== 'undefined' && typeof field.relationTo === 'string') {
const collection = req.payload.collections[field.relationTo]
promises.push(
populate({
collection,
currentDepth,
data,
depth,
field,
id: data[field.name],
key: field.name,
overrideAccess,
req,
showHiddenFields,
}),
)
}
} else if (fieldHasSubFields(field) && !fieldIsArrayType(field)) {
if (fieldAffectsData(field) && typeof data[field.name] === 'object') {
recurseNestedFields({
currentDepth,
data: data[field.name],
depth,
fields: field.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
} else {
recurseNestedFields({
currentDepth,
data,
depth,
fields: field.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
} else if (field.type === 'tabs') {
field.tabs.forEach((tab) => {
recurseNestedFields({
currentDepth,
data,
depth,
fields: tab.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
})
} else if (Array.isArray(data[field.name])) {
if (field.type === 'blocks') {
data[field.name].forEach((row, i) => {
const block = field.blocks.find(({ slug }) => slug === row?.blockType)
if (block) {
recurseNestedFields({
currentDepth,
data: data[field.name][i],
depth,
fields: block.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
})
}
if (field.type === 'array') {
data[field.name].forEach((_, i) => {
recurseNestedFields({
currentDepth,
data: data[field.name][i],
depth,
fields: field.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
})
}
}
if (field.type === 'richText' && Array.isArray(data[field.name])) {
data[field.name].forEach((node) => {
if (Array.isArray(node.children)) {
recurseRichText({
children: node.children,
currentDepth,
depth,
field,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
})
}
})
}

View File

@@ -0,0 +1,148 @@
import type { PayloadRequest, RichTextAdapter, RichTextField } from 'payload/types'
import type { AdapterArguments } from '../types'
import { populate } from './populate'
import { recurseNestedFields } from './recurseNestedFields'
export type Args = Parameters<RichTextAdapter<AdapterArguments>['afterReadPromise']>[0]
type RecurseRichTextArgs = {
children: unknown[]
currentDepth: number
depth: number
field: RichTextField<AdapterArguments>
overrideAccess: boolean
promises: Promise<void>[]
req: PayloadRequest
showHiddenFields: boolean
}
export const recurseRichText = ({
children,
currentDepth = 0,
depth,
field,
overrideAccess = false,
promises,
req,
showHiddenFields,
}: RecurseRichTextArgs): void => {
if (depth <= 0 || currentDepth > depth) {
return
}
if (Array.isArray(children)) {
;(children as any[]).forEach((element) => {
if ((element.type === 'relationship' || element.type === 'upload') && element?.value?.id) {
const collection = req.payload.collections[element?.relationTo]
if (collection) {
promises.push(
populate({
collection,
currentDepth,
data: element,
depth,
field,
id: element.value.id,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
if (
element.type === 'upload' &&
Array.isArray(field.admin?.upload?.collections?.[element?.relationTo]?.fields)
) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
fields: field.admin.upload.collections[element.relationTo].fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
}
if (element.type === 'link') {
if (element?.doc?.value && element?.doc?.relationTo) {
const collection = req.payload.collections[element?.doc?.relationTo]
if (collection) {
promises.push(
populate({
collection,
currentDepth,
data: element.doc,
depth,
field,
id: element.doc.value,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
}
if (Array.isArray(field.admin?.link?.fields)) {
recurseNestedFields({
currentDepth,
data: element.fields || {},
depth,
fields: field.admin?.link?.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
}
if (element?.children) {
recurseRichText({
children: element.children,
currentDepth,
depth,
field,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
})
}
}
export const richTextRelationshipPromise = async ({
currentDepth,
depth,
field,
overrideAccess,
req,
showHiddenFields,
siblingDoc,
}: Args): Promise<void> => {
const promises = []
recurseRichText({
children: siblingDoc[field.name] as unknown[],
currentDepth,
depth,
field,
overrideAccess,
promises,
req,
showHiddenFields,
})
await Promise.all(promises)
}

View File

@@ -0,0 +1,18 @@
import type { RichTextField, Validate } from 'payload/types'
import type { AdapterArguments } from '../types'
import { defaultRichTextValue } from './defaultValue'
export const richText: Validate<unknown, unknown, RichTextField<AdapterArguments>> = (
value,
{ required, t },
) => {
if (required) {
const stringifiedDefaultValue = JSON.stringify(defaultRichTextValue)
if (value && JSON.stringify(value) !== stringifiedDefaultValue) return true
return t('validation:required')
}
return true
}

View File

@@ -0,0 +1,449 @@
import type { BaseEditor, BaseOperation } from 'slate'
import type { HistoryEditor } from 'slate-history'
import type { ReactEditor } from 'slate-react'
import isHotkey from 'is-hotkey'
import { Error, FieldDescription, Label, useField, withCondition } from 'payload/components/forms'
import { useEditDepth } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
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, FieldProps, RichTextElement, RichTextLeaf, TextNode } from '../types'
import { defaultRichTextValue } from '../data/defaultValue'
import { richText } from '../data/validation'
import elementTypes from './elements'
import listTypes from './elements/listTypes'
import enablePlugins from './enablePlugins'
import hotkeys from './hotkeys'
import './index.scss'
import leafTypes from './leaves'
import toggleLeaf from './leaves/toggle'
import mergeCustomFunctions from './mergeCustomFunctions'
import withEnterBreakOut from './plugins/withEnterBreakOut'
import withHTML from './plugins/withHTML'
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' {
interface CustomTypes {
Editor: BaseEditor & ReactEditor & HistoryEditor
Element: ElementNode
Text: TextNode
}
}
const RichText: React.FC<FieldProps> = (props) => {
const {
admin: {
className,
condition,
description,
hideGutter,
placeholder,
readOnly,
style,
width,
} = {
className: undefined,
condition: undefined,
description: undefined,
hideGutter: undefined,
placeholder: undefined,
readOnly: undefined,
style: undefined,
width: undefined,
},
admin,
defaultValue: defaultValueFromProps,
label,
name,
path: pathFromProps,
required,
validate = richText,
} = props
const elements: RichTextElement[] = admin?.elements || defaultElements
const leaves: RichTextLeaf[] = admin?.leaves || defaultLeaves
const path = pathFromProps || name
const { i18n } = useTranslation()
const [loaded, setLoaded] = useState(false)
const [enabledElements, setEnabledElements] = useState({})
const [enabledLeaves, setEnabledLeaves] = useState({})
const editorRef = useRef(null)
const toolbarRef = useRef(null)
const drawerDepth = useEditDepth()
const drawerIsOpen = drawerDepth > 1
const renderElement = useCallback(
({ attributes, children, element }) => {
const matchedElement = enabledElements[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 = (
<Element
attributes={attr}
editorRef={editorRef}
element={element}
fieldProps={props}
path={path}
>
{children}
</Element>
)
return el
}
return <div {...attr}>{children}</div>
},
[enabledElements, path, props],
)
const renderLeaf = useCallback(
({ attributes, children, leaf }) => {
const matchedLeaves = Object.entries(enabledLeaves).filter(([leafName]) => leaf[leafName])
if (matchedLeaves.length > 0) {
return matchedLeaves.reduce(
(result, [leafName], i) => {
if (enabledLeaves[leafName]?.Leaf) {
const Leaf = enabledLeaves[leafName]?.Leaf
return (
<Leaf editorRef={editorRef} fieldProps={props} key={i} leaf={leaf} path={path}>
{result}
</Leaf>
)
}
return result
},
<span {...attributes}>{children}</span>,
)
}
return <span {...attributes}>{children}</span>
},
[enabledLeaves, path, props],
)
const memoizedValidate = useCallback(
(value, validationOptions) => {
return validate(value, { ...validationOptions, required })
},
[validate, required],
)
const fieldType = useField({
condition,
path,
validate: memoizedValidate,
})
const { errorMessage, initialValue, setValue, showError, value } = fieldType
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && `${baseClass}--read-only`,
!hideGutter && `${baseClass}--gutter`,
]
.filter(Boolean)
.join(' ')
const editor = useMemo(() => {
let CreatedEditor = withEnterBreakOut(withHistory(withReact(createEditor())))
CreatedEditor = withHTML(CreatedEditor)
CreatedEditor = enablePlugins(CreatedEditor, elements)
CreatedEditor = enablePlugins(CreatedEditor, leaves)
return CreatedEditor
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [elements, leaves, path])
// All slate changes fire the onChange event
// including selection changes
// so we will filter the set_selection operations out
// and only fire setValue when onChange is because of value
const handleChange = useCallback(
(val: unknown) => {
const ops = editor.operations.filter((o: BaseOperation) => {
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(() => {
if (!loaded) {
const mergedElements = mergeCustomFunctions(elements, elementTypes)
const mergedLeaves = mergeCustomFunctions(leaves, leafTypes)
setEnabledElements(mergedElements)
setEnabledLeaves(mergedLeaves)
setLoaded(true)
}
}, [loaded, elements, leaves])
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 (loaded && readOnly) {
setClickableState('disabled')
}
return () => {
if (loaded && readOnly) {
setClickableState('enabled')
}
}
}, [loaded, 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]);
if (!loaded) {
return null
}
let valueToRender = value
if (typeof valueToRender === 'string') {
try {
const parsedJSON = JSON.parse(valueToRender)
valueToRender = parsedJSON
} catch (err) {
valueToRender = null
}
}
if (!valueToRender) valueToRender = defaultValueFromProps || defaultRichTextValue
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<div className={`${baseClass}__wrap`}>
<Error message={errorMessage} showError={showError} />
<Label htmlFor={`field-${path.replace(/\./g, '__')}`} label={label} required={required} />
<Slate
editor={editor}
key={JSON.stringify({ initialValue, path })}
onChange={handleChange}
value={valueToRender as any[]}
>
<div className={`${baseClass}__wrapper`}>
<div
className={[`${baseClass}__toolbar`, drawerIsOpen && `${baseClass}__drawerIsOpen`]
.filter(Boolean)
.join(' ')}
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
if (Button) {
return <Button fieldProps={props} key={i} path={path} />
}
return null
})}
{leaves.map((leaf, i) => {
let leafName: string
if (typeof leaf === 'object' && leaf?.name) leafName = leaf.name
if (typeof leaf === 'string') leafName = leaf
const leafType = enabledLeaves[leafName]
const Button = leafType?.Button
if (Button) {
return <Button fieldProps={props} key={i} path={path} />
}
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>
<FieldDescription description={description} value={value} />
</div>
</div>
)
}
export default withCondition(RichText)

View File

@@ -0,0 +1,15 @@
@import 'payload/scss';
.rich-text__button {
position: relative;
cursor: pointer;
svg {
width: base(0.75);
height: base(0.75);
}
&--disabled {
opacity: 0.4;
}
}

View File

@@ -0,0 +1,52 @@
import type { ElementType } from 'react'
import { Tooltip } from 'payload/components'
import React, { useCallback, useState } from 'react'
import { useSlate } from 'slate-react'
import type { ButtonProps } from './types'
import '../buttons.scss'
import isElementActive from './isActive'
import toggleElement from './toggle'
export const baseClass = 'rich-text__button'
const ElementButton: React.FC<ButtonProps> = (props) => {
const { children, className, el = 'button', format, onClick, tooltip, type = 'type' } = props
const editor = useSlate()
const [showTooltip, setShowTooltip] = useState(false)
const defaultOnClick = useCallback(
(event) => {
event.preventDefault()
setShowTooltip(false)
toggleElement(editor, format, type)
},
[editor, format, type],
)
const Tag: ElementType = el
return (
<Tag
{...(el === 'button' && { type: 'button' })}
className={[
baseClass,
className,
isElementActive(editor, format, type) && `${baseClass}__button--active`,
]
.filter(Boolean)
.join(' ')}
onClick={onClick || defaultOnClick}
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
>
{tooltip && <Tooltip show={showTooltip}>{tooltip}</Tooltip>}
{children}
</Tag>
)
}
export default ElementButton

View File

@@ -0,0 +1,34 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import { useConfig } from 'payload/components/utilities'
import * as React from 'react'
type options = { uploads: boolean }
type FilteredCollectionsT = (
collections: SanitizedCollectionConfig[],
options?: options,
) => SanitizedCollectionConfig[]
const filterRichTextCollections: FilteredCollectionsT = (collections, options) => {
return collections.filter(({ admin: { enableRichTextRelationship }, upload }) => {
if (options?.uploads) {
return enableRichTextRelationship && Boolean(upload) === true
}
return upload ? false : enableRichTextRelationship
})
}
export const EnabledRelationshipsCondition: React.FC<any> = (props) => {
const { children, uploads = false, ...rest } = props
const { collections } = useConfig()
const [enabledCollectionSlugs] = React.useState(() =>
filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug),
)
if (!enabledCollectionSlugs.length) {
return null
}
return React.cloneElement(children, { ...rest, enabledCollectionSlugs })
}

View File

@@ -0,0 +1,40 @@
import React, { useCallback } from 'react'
import { useSlate } from 'slate-react'
import type { ButtonProps } from './types'
import '../buttons.scss'
import isListActive from './isListActive'
import toggleList from './toggleList'
export const baseClass = 'rich-text__button'
const ListButton: React.FC<ButtonProps> = ({ children, className, format, onClick }) => {
const editor = useSlate()
const defaultOnClick = useCallback(
(event) => {
event.preventDefault()
toggleList(editor, format)
},
[editor, format],
)
return (
<button
className={[
baseClass,
className,
isListActive(editor, format) && `${baseClass}__button--active`,
]
.filter(Boolean)
.join(' ')}
onClick={onClick || defaultOnClick}
type="button"
>
{children}
</button>
)
}
export default ListButton

View File

@@ -0,0 +1,7 @@
import type { Node } from 'slate'
import { Element } from 'slate'
export const areAllChildrenElements = (node: Node): boolean => {
return Array.isArray(node.children) && node.children.every((child) => Element.isElement(child))
}

View File

@@ -0,0 +1,9 @@
@import 'payload/scss';
.rich-text-blockquote {
&[data-slate-node='element'] {
margin: base(0.625) 0;
padding-left: base(0.625);
border-left: 1px solid var(--theme-elevation-200);
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import BlockquoteIcon from '../../icons/Blockquote'
import ElementButton from '../Button'
import './index.scss'
const Blockquote = ({ attributes, children }) => (
<blockquote className="rich-text-blockquote" {...attributes}>
{children}
</blockquote>
)
const blockquote = {
Button: () => (
<ElementButton format="blockquote">
<BlockquoteIcon />
</ElementButton>
),
Element: Blockquote,
}
export default blockquote

View File

@@ -0,0 +1,24 @@
import type { NodeEntry, NodeMatch } from 'slate'
import { Editor, Node } from 'slate'
import type { ElementNode } from '../../types'
import { isBlockElement } from './isBlockElement'
export const getCommonBlock = (editor: Editor, match?: NodeMatch<Node>): NodeEntry<Node> => {
const range = Editor.unhangRange(editor, editor.selection, { voids: true })
const [common, path] = Node.common(editor, range.anchor.path, range.focus.path)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
if (isBlockElement(editor, common) || Editor.isEditor(common)) {
return [common, path]
}
return Editor.above(editor, {
at: path,
match: match || ((n: ElementNode) => isBlockElement(editor, n) || Editor.isEditor(n)),
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,226 @@
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

@@ -0,0 +1,35 @@
import blockquote from './blockquote'
import h1 from './h1'
import h2 from './h2'
import h3 from './h3'
import h4 from './h4'
import h5 from './h5'
import h6 from './h6'
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 ul from './ul'
import upload from './upload'
const elements = {
blockquote,
h1,
h2,
h3,
h4,
h5,
h6,
indent,
li,
link,
ol,
relationship,
textAlign,
ul,
upload,
}
export default elements

View File

@@ -0,0 +1,30 @@
import type { Element } from 'slate'
import { Editor, Transforms } from 'slate'
import type { ElementNode } from '../../types'
import { isLastSelectedElementEmpty } from './isLastSelectedElementEmpty'
export const injectVoidElement = (editor: Editor, element: Element): void => {
const lastSelectedElementIsEmpty = isLastSelectedElementEmpty(editor)
const previous = Editor.previous<ElementNode>(editor)
if (lastSelectedElementIsEmpty) {
// If previous node is void
if (previous?.[0] && Editor.isVoid(editor, previous[0])) {
// Insert a blank element between void nodes
// so user can place cursor between void nodes
Transforms.insertNodes(editor, { children: [{ text: '' }] })
Transforms.setNodes(editor, element)
// Otherwise just set the empty node equal to new void
} else {
Transforms.setNodes(editor, element)
}
} else {
Transforms.insertNodes(editor, element)
}
// Add an empty node after the new void
Transforms.insertNodes(editor, { children: [{ text: '' }] })
}

View File

@@ -0,0 +1,16 @@
import { Editor, Element } from 'slate'
const isElementActive = (editor: Editor, format: string, blockType = 'type'): boolean => {
if (!editor.selection) return false
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, editor.selection),
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && n[blockType] === format,
}),
)
return !!match
}
export default isElementActive

View File

@@ -0,0 +1,27 @@
import { Editor, Element } from 'slate'
/**
* Returns true, if the provided node is an Element (optionally of a specific type)
*/
const isElement = (node: any, specificType?: string | string[]): node is Element => {
if (Editor.isEditor(node) || !Element.isElement(node)) {
return false
}
if (undefined === specificType) {
return true
}
if (typeof specificType === 'string') {
return node.type === specificType
}
return specificType.includes(node.type)
}
/**
* Returns true, if the provided node is a Block Element.
* Note: Using Editor.isBlock() is not sufficient, as since slate 0.90 it returns `true` for Text nodes and the editor as well.
*
* Related Issue: https://github.com/ianstormtaylor/slate/issues/5287
*/
export const isBlockElement = (editor: Editor, node: any): node is Element =>
Editor.isBlock(editor, node) && isElement(node)

View File

@@ -0,0 +1,24 @@
import { Editor, Element } from 'slate'
import { nodeIsTextNode } from '../../types'
export const isLastSelectedElementEmpty = (editor: Editor): boolean => {
if (!editor.selection) return false
const currentlySelectedNodes = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, editor.selection),
match: (n) => !Editor.isEditor(n) && Element.isElement(n) && (!n.type || n.type === 'p'),
}),
)
const lastSelectedNode = currentlySelectedNodes?.[currentlySelectedNodes?.length - 1]
return (
lastSelectedNode &&
Element.isElement(lastSelectedNode[0]) &&
(!lastSelectedNode[0].type || lastSelectedNode[0].type === 'p') &&
nodeIsTextNode(lastSelectedNode[0].children?.[0]) &&
lastSelectedNode[0].children?.[0].text === ''
)
}

View File

@@ -0,0 +1,29 @@
import { Editor, Element } from 'slate'
import { getCommonBlock } from './getCommonBlock'
const isListActive = (editor: Editor, format: string): boolean => {
if (!editor.selection) return false
const [topmostSelectedNode, topmostSelectedNodePath] = getCommonBlock(editor)
if (Editor.isEditor(topmostSelectedNode)) return false
const [match] = Array.from(
Editor.nodes(editor, {
at: topmostSelectedNodePath,
match: (node, path) => {
return (
!Editor.isEditor(node) &&
Element.isElement(node) &&
node.type === format &&
path.length >= topmostSelectedNodePath.length - 2
)
},
mode: 'lowest',
}),
)
return !!match
}
export default isListActive

View File

@@ -0,0 +1,19 @@
import type { Ancestor, NodeEntry } from 'slate'
import { Editor, Element } from 'slate'
export const isWithinListItem = (editor: Editor): boolean => {
let parentLI: NodeEntry<Ancestor>
try {
parentLI = Editor.parent(editor, editor.selection)
} catch (e) {
// swallow error, Slate
}
if (Element.isElement(parentLI?.[0]) && parentLI?.[0]?.type === 'li') {
return true
}
return false
}

View File

@@ -0,0 +1,25 @@
import React from 'react'
import listTypes from '../listTypes'
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>
)
}
export default {
Element: LI,
}

View File

@@ -0,0 +1,137 @@
import type { Fields } from 'payload/types'
import { useModal } from '@faceless-ui/modal'
import { useDrawerSlug } from 'payload/components/elements'
import { reduceFieldsToValues } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
useConfig,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import React, { Fragment, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Editor, Range, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react'
import type { FieldProps } from '../../../../types'
import LinkIcon from '../../../icons/Link'
import ElementButton from '../../Button'
import isElementActive from '../../isActive'
import { LinkDrawer } from '../LinkDrawer'
import { transformExtraFields, unwrapLink } from '../utilities'
/**
* This function is called when an new link is created - not when an existing link is edited.
*/
const insertLink = (editor, fields) => {
const isCollapsed = editor.selection && Range.isCollapsed(editor.selection)
const data = reduceFieldsToValues(fields, true)
const newLink = {
children: [],
doc: data.doc,
fields: data.fields, // Any custom user-added fields are part of data.fields
linkType: data.linkType,
newTab: data.newTab,
type: 'link',
url: data.url,
}
if (isCollapsed || !editor.selection) {
// If selection anchor and focus are the same,
// Just inject a new node with children already set
Transforms.insertNodes(editor, {
...newLink,
children: [{ text: String(data.text) }],
})
} else if (editor.selection) {
// Otherwise we need to wrap the selected node in a link,
// Delete its old text,
// Move the selection one position forward into the link,
// And insert the text back into the new link
Transforms.wrapNodes(editor, newLink, { split: true })
Transforms.delete(editor, { at: editor.selection.focus.path, unit: 'word' })
Transforms.move(editor, { distance: 1, unit: 'offset' })
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path })
}
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>({})
const { i18n, t } = useTranslation(['upload', 'general'])
const editor = useSlate()
const config = useConfig()
const [fieldSchema] = useState(() => {
const fields = transformExtraFields(customFieldSchema, config, i18n)
return fields
})
const { closeModal, openModal } = useModal()
const drawerSlug = useDrawerSlug('rich-text-link')
const { getDocPreferences } = useDocumentInfo()
return (
<Fragment>
<ElementButton
className="link"
format="link"
onClick={async () => {
if (isElementActive(editor, 'link')) {
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({
data,
fieldSchema,
locale,
operation: 'create',
preferences,
t,
user,
})
setInitialState(state)
}
}
}}
tooltip={t('fields:addLink')}
>
<LinkIcon />
</ElementButton>
<LinkDrawer
drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
handleClose={() => {
closeModal(drawerSlug)
}}
handleModalSubmit={(fields) => {
insertLink(editor, fields)
closeModal(drawerSlug)
}}
initialState={initialState}
/>
</Fragment>
)
}

View File

@@ -0,0 +1,71 @@
@import 'payload/scss';
.rich-text-link {
position: relative;
text-decoration: underline;
.popup {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
.popup__scroll,
.popup__wrap {
overflow: visible;
}
.popup__scroll {
white-space: pre;
padding-right: base(0.5);
}
}
.icon--x line {
stroke-width: 2px;
}
&__popup {
@extend %body;
font-family: var(--font-body);
display: flex;
button {
@extend %btn-reset;
font-weight: 600;
cursor: pointer;
margin: 0 0 0 base(0.25);
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
}
&__link-label {
max-width: base(8);
overflow: hidden;
text-overflow: ellipsis;
margin-right: base(0.25);
}
}
.rich-text-link__popup-toggler {
position: relative;
border: 0;
background-color: transparent;
padding: 0;
text-decoration: underline;
cursor: text;
&:focus,
&:focus-within {
outline: none;
}
&--open {
z-index: var(--z-popup);
}
}

View File

@@ -0,0 +1,220 @@
import type { Fields } from 'payload/types'
import type { HTMLAttributes } from 'react'
import { useModal } from '@faceless-ui/modal'
import { Button, Popup } from 'payload/components'
import { useDrawerSlug } from 'payload/components/elements'
import { reduceFieldsToValues } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
useConfig,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { deepCopyObject, getTranslation } from 'payload/utilities'
import React, { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Editor, Node, Transforms } from 'slate'
import { ReactEditor, useSlate } from 'slate-react'
import type { FieldProps } from '../../../../types'
import { LinkDrawer } from '../LinkDrawer'
import { transformExtraFields, unwrapLink } from '../utilities'
import './index.scss'
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 data = reduceFieldsToValues(fields, true)
const [, parentPath] = Editor.above(editor)
const newNode: Record<string, unknown> = {
doc: data.doc,
linkType: data.linkType,
newTab: data.newTab,
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' })
Transforms.move(editor, { distance: 1, unit: 'offset' })
Transforms.insertText(editor, String(data.text), { at: editor.selection.focus.path })
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
const customFieldSchema = fieldProps?.admin?.link?.fields
const editor = useSlate()
const config = useConfig()
const { user } = useAuth()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation('fields')
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 fields = transformExtraFields(customFieldSchema, config, i18n)
return fields
})
const drawerSlug = useDrawerSlug('rich-text-link')
const handleTogglePopup = useCallback((render) => {
if (!render) {
setRenderPopup(render)
}
}, [])
useEffect(() => {
const awaitInitialState = async () => {
const data = {
doc: element.doc,
fields: deepCopyObject(element.fields),
linkType: element.linkType,
newTab: element.newTab,
text: Node.string(element),
url: element.url,
}
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
data,
fieldSchema,
locale,
operation: 'update',
preferences,
t,
user,
})
setInitialState(state)
}
awaitInitialState()
}, [renderModal, element, fieldSchema, user, locale, t, getDocPreferences])
return (
<span className={baseClass} {...attributes}>
<span contentEditable={false} style={{ userSelect: 'none' }}>
{renderModal && (
<LinkDrawer
drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
handleClose={() => {
toggleModal(drawerSlug)
setRenderModal(false)
}}
handleModalSubmit={(fields) => {
insertChange(editor, fields, customFieldSchema)
closeModal(drawerSlug)
}}
initialState={initialState}
/>
)}
<Popup
boundingRef={editorRef}
buttonType="none"
forceOpen={renderPopup}
horizontalAlign="left"
onToggleOpen={handleTogglePopup}
render={() => (
<div className={`${baseClass}__popup`}>
{element.linkType === 'internal' && element.doc?.relationTo && element.doc?.value && (
<Trans
i18nKey="fields:linkedTo"
values={{
label: getTranslation(
config.collections.find(({ slug }) => slug === element.doc.relationTo)?.labels
?.singular,
i18n,
),
}}
>
<a
className={`${baseClass}__link-label`}
href={`${config.routes.admin}/collections/${element.doc.relationTo}/${element.doc.value}`}
rel="noreferrer"
target="_blank"
>
label
</a>
</Trans>
)}
{(element.linkType === 'custom' || !element.linkType) && (
<a
className={`${baseClass}__link-label`}
href={element.url}
rel="noreferrer"
target="_blank"
>
{element.url}
</a>
)}
<Button
buttonStyle="icon-label"
className={`${baseClass}__link-edit`}
icon="edit"
onClick={(e) => {
e.preventDefault()
setRenderPopup(false)
openModal(drawerSlug)
setRenderModal(true)
}}
round
tooltip={t('general:edit')}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__link-close`}
icon="x"
onClick={(e) => {
e.preventDefault()
unwrapLink(editor)
}}
round
tooltip={t('general:remove')}
/>
</div>
)}
size="small"
verticalAlign="bottom"
/>
</span>
<span
className={[`${baseClass}__popup-toggler`].filter(Boolean).join(' ')}
onClick={() => setRenderPopup(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') setRenderPopup(true)
}}
role="button"
tabIndex={0}
>
{children}
</span>
</span>
)
}

View File

@@ -0,0 +1,72 @@
import type { Config } from 'payload/config'
import type { Field } from 'payload/types'
import { extractTranslations } from 'payload/utilities'
const translations = extractTranslations([
'fields:textToDisplay',
'fields:linkType',
'fields:chooseBetweenCustomTextOrDocument',
'fields:customURL',
'fields:internalLink',
'fields:enterURL',
'fields:chooseDocumentToLink',
'fields:openInNewTab',
])
export const getBaseFields = (config: Config): Field[] => [
{
label: translations['fields:textToDisplay'],
name: 'text',
required: true,
type: 'text',
},
{
admin: {
description: translations['fields:chooseBetweenCustomTextOrDocument'],
},
defaultValue: 'custom',
label: translations['fields:linkType'],
name: 'linkType',
options: [
{
label: translations['fields:customURL'],
value: 'custom',
},
{
label: translations['fields:internalLink'],
value: 'internal',
},
],
required: true,
type: 'radio',
},
{
admin: {
condition: ({ linkType }) => linkType !== 'internal',
},
label: translations['fields:enterURL'],
name: 'url',
required: true,
type: 'text',
},
{
admin: {
condition: ({ linkType }) => {
return linkType === 'internal'
},
},
label: translations['fields:chooseDocumentToLink'],
name: 'doc',
relationTo: config.collections
.filter(({ admin: { enableRichTextLink } }) => enableRichTextLink)
.map(({ slug }) => slug),
required: true,
type: 'relationship',
},
{
label: translations['fields:openInNewTab'],
name: 'newTab',
type: 'checkbox',
},
]

View File

@@ -0,0 +1,50 @@
@import 'payload/scss';
.rich-text-link-edit-modal {
&__template {
position: relative;
z-index: 1;
padding-top: base(1);
padding-bottom: base(2);
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-text {
margin: 0;
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2.75);
height: base(2.75);
position: relative;
left: base(-0.825);
top: base(-0.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}

View File

@@ -0,0 +1,52 @@
import { Drawer } from 'payload/components/elements'
import { Form, FormSubmit, RenderFields } from 'payload/components/forms'
import { useHotkey } from 'payload/components/hooks'
import { useEditDepth } from 'payload/components/utilities'
import { fieldTypes } from 'payload/config'
import React, { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Props } from './types'
import './index.scss'
const baseClass = 'rich-text-link-edit-modal'
export const LinkDrawer: React.FC<Props> = ({
drawerSlug,
fieldSchema,
handleModalSubmit,
initialState,
}) => {
const { t } = useTranslation('fields')
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('editLink')}>
<Form initialState={initialState} onSubmit={handleModalSubmit}>
<RenderFields
fieldSchema={fieldSchema}
fieldTypes={fieldTypes}
forceRender
readOnly={false}
/>
<LinkSubmit />
</Form>
</Drawer>
)
}
const LinkSubmit: React.FC = () => {
const { t } = useTranslation('fields')
const ref = useRef<HTMLButtonElement>(null)
const editDepth = useEditDepth()
useHotkey({ cmdCtrlKey: true, editDepth, keyCodes: ['s'] }, (e) => {
e.preventDefault()
e.stopPropagation()
if (ref?.current) {
ref.current.click()
}
})
return <FormSubmit ref={ref}>{t('general:submit')}</FormSubmit>
}

View File

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

View File

@@ -0,0 +1,11 @@
import { LinkButton } from './Button'
import { LinkElement } from './Element'
import { withLinks } from './utilities'
const link = {
Button: LinkButton,
Element: LinkElement,
plugins: [withLinks],
}
export default link

View File

@@ -0,0 +1 @@
export const modalSlug = 'rich-text-link-modal'

View File

@@ -0,0 +1,99 @@
import type { i18n } from 'i18next'
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import type { Editor } from 'slate'
import { Element, Range, Transforms } from 'slate'
import { getBaseFields } from './LinkDrawer/baseFields'
export const unwrapLink = (editor: Editor): void => {
Transforms.unwrapNodes(editor, { match: (n) => Element.isElement(n) && n.type === 'link' })
}
export const wrapLink = (editor: Editor): void => {
const { selection } = editor
const isCollapsed = selection && Range.isCollapsed(selection)
const link = {
children: isCollapsed ? [{ text: '' }] : [],
newTab: false,
type: 'link',
url: undefined,
}
if (isCollapsed) {
Transforms.insertNodes(editor, link)
} else {
Transforms.wrapNodes(editor, link, { split: true })
Transforms.collapse(editor, { edge: 'end' })
}
}
export const withLinks = (incomingEditor: Editor): Editor => {
const editor = incomingEditor
const { isInline } = editor
editor.isInline = (element) => {
if (element.type === 'link') {
return true
}
return isInline(element)
}
return editor
}
/**
* This function is run to enrich the basefields which every link has with potential, custom user-added fields.
*/
export function transformExtraFields(
customFieldSchema:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: i18n }) => Field[])
| Field[],
config: SanitizedConfig,
i18n: i18n,
): Field[] {
const baseFields: Field[] = getBaseFields(config)
const fields =
typeof customFieldSchema === 'function'
? customFieldSchema({ config, defaultFields: baseFields, i18n })
: baseFields
// Wrap fields which are not part of the base schema in a group named 'fields' - otherwise they will be rendered but not saved
const extraFields = []
fields.forEach((field) => {
if ('name' in field) {
if (
!baseFields.find((baseField) => !('name' in baseField) || baseField.name === field.name)
) {
if (field.name !== 'fields' && field.type !== 'group') {
extraFields.push(field)
// Remove from fields from now, as they need to be part of the fields group below
fields.splice(fields.indexOf(field), 1)
}
}
}
})
if (Array.isArray(customFieldSchema) || fields.length > 0) {
fields.push({
admin: {
style: {
borderBottom: 0,
borderTop: 0,
margin: 0,
padding: 0,
},
},
fields: Array.isArray(customFieldSchema)
? customFieldSchema.concat(extraFields)
: extraFields,
name: 'fields',
type: 'group',
})
}
return fields
}

View File

@@ -0,0 +1 @@
export default ['ol', 'ul']

View File

@@ -0,0 +1,7 @@
@import 'payload/scss';
.rich-text-ol {
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
}

View File

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

View File

@@ -0,0 +1,53 @@
import { RelationshipComponent } from 'payload/components/fields/Relationship'
import { SelectComponent } from 'payload/components/fields/Select'
import { useFormFields } from 'payload/components/forms'
import { useAuth, useConfig } from 'payload/components/utilities'
import React, { Fragment, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const createOptions = (collections, permissions) =>
collections.reduce((options, collection) => {
if (
permissions?.collections?.[collection.slug]?.read?.permission &&
collection?.admin?.enableRichTextRelationship
) {
return [
...options,
{
label: collection.labels.plural,
value: collection.slug,
},
]
}
return options
}, [])
const RelationshipFields = () => {
const { collections } = useConfig()
const { permissions } = useAuth()
const { t } = useTranslation('fields')
const [options, setOptions] = useState(() => createOptions(collections, permissions))
const relationTo = useFormFields<string>(([fields]) => fields.relationTo?.value as string)
useEffect(() => {
setOptions(createOptions(collections, permissions))
}, [collections, permissions])
return (
<Fragment>
<SelectComponent label={t('relationTo')} name="relationTo" options={options} required />
{relationTo && (
<RelationshipComponent
label={t('relatedDocument')}
name="value"
relationTo={relationTo}
required
/>
)}
</Fragment>
)
}
export default RelationshipFields

View File

@@ -0,0 +1,7 @@
@import 'payload/scss';
.relationship-rich-text-button {
display: flex;
align-items: center;
height: 100%;
}

View File

@@ -0,0 +1,89 @@
import { useListDrawer } from 'payload/components/elements'
import React, { Fragment, useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactEditor, useSlate } from 'slate-react'
import RelationshipIcon from '../../../icons/Relationship'
import ElementButton from '../../Button'
import { EnabledRelationshipsCondition } from '../../EnabledRelationshipsCondition'
import { injectVoidElement } from '../../injectVoid'
import './index.scss'
const baseClass = 'relationship-rich-text-button'
const insertRelationship = (editor, { relationTo, value }) => {
const text = { text: ' ' }
const relationship = {
children: [text],
relationTo,
type: 'relationship',
value,
}
injectVoidElement(editor, relationship)
ReactEditor.focus(editor)
}
type Props = {
enabledCollectionSlugs: string[]
path: string
}
const RelationshipButton: React.FC<Props> = ({ enabledCollectionSlugs }) => {
const { t } = useTranslation('fields')
const editor = useSlate()
const [selectedCollectionSlug, setSelectedCollectionSlug] = useState(
() => enabledCollectionSlugs[0],
)
const [ListDrawer, ListDrawerToggler, { closeDrawer, isDrawerOpen }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: selectedCollectionSlug,
})
const onSelect = useCallback(
({ collectionConfig, docID }) => {
insertRelationship(editor, {
relationTo: collectionConfig.slug,
value: {
id: docID,
},
})
closeDrawer()
},
[editor, closeDrawer],
)
useEffect(() => {
// always reset back to first option
// TODO: this is not working, see the ListDrawer component
setSelectedCollectionSlug(enabledCollectionSlugs[0])
}, [isDrawerOpen, enabledCollectionSlugs])
return (
<Fragment>
<ListDrawerToggler>
<ElementButton
className={baseClass}
el="div"
format="relationship"
onClick={() => {
// do nothing
}}
tooltip={t('addRelationship')}
>
<RelationshipIcon />
</ElementButton>
</ListDrawerToggler>
<ListDrawer onSelect={onSelect} />
</Fragment>
)
}
export default (props: Props): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props}>
<RelationshipButton {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1,93 @@
@import 'payload/scss';
.rich-text-relationship {
@extend %body;
@include shadow-sm;
padding: base(0.75);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
max-width: base(15);
font-family: var(--font-body);
&:hover {
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
&__label {
margin-bottom: base(0.25);
}
&__title {
margin: 0;
}
&__label,
&__title {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1 !important;
}
&__title {
font-weight: bold;
}
&__wrap {
flex-grow: 1;
overflow: hidden;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
.rich-text-relationship__doc-drawer-toggler {
text-decoration: underline;
pointer-events: all;
line-height: inherit;
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
& > *:not(:last-child) {
margin-right: base(0.25);
}
}
&__removeButton {
margin: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__doc-drawer-toggler,
&__list-drawer-toggler {
& > * {
margin: 0;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
}

View File

@@ -0,0 +1,195 @@
import type { HTMLAttributes } from 'react'
import { Button } from 'payload/components'
import { useDocumentDrawer, useListDrawer } from 'payload/components/elements'
import { usePayloadAPI } from 'payload/components/hooks'
import { useConfig } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Transforms } from 'slate'
import { ReactEditor, useFocused, useSelected, useSlateStatic } from 'slate-react'
import type { FieldProps } from '../../../../types'
import { EnabledRelationshipsCondition } from '../../EnabledRelationshipsCondition'
import './index.scss'
const baseClass = 'rich-text-relationship'
const initialParams = {
depth: 0,
}
type Props = {
attributes: HTMLAttributes<HTMLDivElement>
children: React.ReactNode
element: any
fieldProps: FieldProps
}
const Element: React.FC<Props> = (props) => {
const {
attributes,
children,
element,
element: { relationTo, value },
fieldProps,
} = props
const {
collections,
routes: { api },
serverURL,
} = useConfig()
const [enabledCollectionSlugs] = useState(() =>
collections
.filter(({ admin: { enableRichTextRelationship } }) => enableRichTextRelationship)
.map(({ slug }) => slug),
)
const [relatedCollection, setRelatedCollection] = useState(() =>
collections.find((coll) => coll.slug === relationTo),
)
const selected = useSelected()
const focused = useFocused()
const { i18n, t } = useTranslation(['fields', 'general'])
const editor = useSlateStatic()
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value?.id}`,
{ initialParams },
)
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
id: value?.id,
collectionSlug: relatedCollection.slug,
})
const [ListDrawer, ListDrawerToggler, { closeDrawer: closeListDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: relatedCollection.slug,
})
const removeRelationship = useCallback(() => {
const elementPath = ReactEditor.findPath(editor, element)
Transforms.removeNodes(editor, { at: elementPath })
}, [editor, element])
const updateRelationship = React.useCallback(
({ doc }) => {
const elementPath = ReactEditor.findPath(editor, element)
Transforms.setNodes(
editor,
{
children: [{ text: ' ' }],
relationTo: relatedCollection.slug,
type: 'relationship',
value: { id: doc.id },
},
{ at: elementPath },
)
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
closeDrawer()
dispatchCacheBust()
},
[editor, element, relatedCollection, cacheBust, setParams, closeDrawer],
)
const swapRelationship = React.useCallback(
({ collectionConfig, docID }) => {
const elementPath = ReactEditor.findPath(editor, element)
Transforms.setNodes(
editor,
{
children: [{ text: ' ' }],
relationTo: collectionConfig.slug,
type: 'relationship',
value: { id: docID },
},
{ at: elementPath },
)
setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug))
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
closeListDrawer()
dispatchCacheBust()
},
[closeListDrawer, editor, element, cacheBust, setParams, collections],
)
return (
<div
className={[baseClass, selected && focused && `${baseClass}--selected`]
.filter(Boolean)
.join(' ')}
contentEditable={false}
{...attributes}
>
<div className={`${baseClass}__wrap`}>
<p className={`${baseClass}__label`}>
{t('labelRelationship', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
</p>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<p className={`${baseClass}__title`}>
{data[relatedCollection?.admin?.useAsTitle || 'id']}
</p>
</DocumentDrawerToggler>
</div>
<div className={`${baseClass}__actions`}>
<ListDrawerToggler
className={`${baseClass}__list-drawer-toggler`}
disabled={fieldProps?.admin?.readOnly}
>
<Button
buttonStyle="icon-label"
disabled={fieldProps?.admin?.readOnly}
el="div"
icon="swap"
onClick={() => {
// do nothing
}}
round
tooltip={t('swapRelationship')}
/>
</ListDrawerToggler>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={fieldProps?.admin?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeRelationship()
}}
round
tooltip={t('fields:removeRelationship')}
/>
</div>
{value?.id && <DocumentDrawer onSave={updateRelationship} />}
<ListDrawer onSelect={swapRelationship} />
{children}
</div>
)
}
export default (props: Props): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props}>
<Element {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1,9 @@
import Button from './Button'
import Element from './Element'
import plugin from './plugin'
export default {
Button,
Element,
plugins: [plugin],
}

View File

@@ -0,0 +1,10 @@
const withRelationship = (incomingEditor) => {
const editor = incomingEditor
const { isVoid } = editor
editor.isVoid = (element) => (element.type === 'relationship' ? true : isVoid(element))
return editor
}
export default withRelationship

View File

@@ -0,0 +1,25 @@
import React from 'react'
import AlignCenterIcon from '../../icons/AlignCenter'
import AlignLeftIcon from '../../icons/AlignLeft'
import AlignRightIcon from '../../icons/AlignRight'
import ElementButton from '../Button'
export default {
Button: () => {
return (
<React.Fragment>
<ElementButton format="left" type="textAlign">
<AlignLeftIcon />
</ElementButton>
<ElementButton format="center" type="textAlign">
<AlignCenterIcon />
</ElementButton>
<ElementButton format="right" type="textAlign">
<AlignRightIcon />
</ElementButton>
</React.Fragment>
)
},
name: 'alignment',
}

View File

@@ -0,0 +1,38 @@
import { Editor, Transforms } from 'slate'
import { ReactEditor } from 'slate-react'
import isElementActive from './isActive'
import { isWithinListItem } from './isWithinListItem'
const toggleElement = (editor: Editor, format: string, blockType = 'type'): void => {
const isActive = isElementActive(editor, format, blockType)
const formatByBlockType = {
[blockType]: format,
}
const isWithinLI = isWithinListItem(editor)
if (isActive) {
formatByBlockType[blockType] = undefined
}
if (!isActive && isWithinLI && blockType !== 'textAlign') {
const block = { children: [], type: 'li' }
Transforms.wrapNodes(editor, block, {
at: Editor.unhangRange(editor, editor.selection),
})
}
Transforms.setNodes(
editor,
{ [blockType]: formatByBlockType[blockType] },
{
at: Editor.unhangRange(editor, editor.selection),
},
)
ReactEditor.focus(editor)
}
export default toggleElement

View File

@@ -0,0 +1,100 @@
import { Editor, Element, Node, Text, Transforms } from 'slate'
import { ReactEditor } from 'slate-react'
import { getCommonBlock } from './getCommonBlock'
import isListActive from './isListActive'
import listTypes from './listTypes'
import { unwrapList } from './unwrapList'
const toggleList = (editor: Editor, format: string): void => {
let currentListFormat: string
if (isListActive(editor, 'ol')) currentListFormat = 'ol'
if (isListActive(editor, 'ul')) currentListFormat = 'ul'
// If the format is currently active,
// remove the list
if (currentListFormat === format) {
const selectedLeaf = Node.descendant(editor, editor.selection.anchor.path)
// If on an empty bullet, leave the above list alone
// and unwrap only the active bullet
if (Text.isText(selectedLeaf) && String(selectedLeaf.text).length === 0) {
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
mode: 'lowest',
split: true,
})
Transforms.setNodes(editor, { type: undefined })
} else {
// Otherwise, we need to unset li on all lis in the parent list
// and unwrap the parent list itself
const [, listPath] = getCommonBlock(editor, (n) => Element.isElement(n) && n.type === format)
unwrapList(editor, listPath)
}
// Otherwise, if a list is active and we are changing it,
// change it
} else if (currentListFormat && currentListFormat !== format) {
Transforms.setNodes(
editor,
{
type: format,
},
{
match: (node) => Element.isElement(node) && listTypes.includes(node.type),
mode: 'lowest',
},
)
// Otherwise we can assume that we should just activate the list
} else {
Transforms.wrapNodes(editor, { children: [], type: format })
const [, parentNodePath] = getCommonBlock(
editor,
(node) => Element.isElement(node) && node.type === format,
)
// Only set li on nodes that don't have type
Transforms.setNodes(
editor,
{ type: 'li' },
{
match: (node, path) => {
const match =
Element.isElement(node) &&
typeof node.type === 'undefined' &&
path.length === parentNodePath.length + 1
return match
},
voids: true,
},
)
// Wrap nodes that do have a type with an li
// so as to not lose their existing formatting
const nodesToWrap = Array.from(
Editor.nodes(editor, {
match: (node, path) => {
const match =
Element.isElement(node) &&
typeof node.type !== 'undefined' &&
node.type !== 'li' &&
path.length === parentNodePath.length + 1
return match
},
}),
)
nodesToWrap.forEach(([, path]) => {
Transforms.wrapNodes(editor, { children: [], type: 'li' }, { at: path })
})
}
ReactEditor.focus(editor)
}
export default toggleList

View File

@@ -0,0 +1,11 @@
import type { ElementType } from 'react'
export type ButtonProps = {
children?: React.ReactNode
className?: string
el?: ElementType
format: string
onClick?: (e: React.MouseEvent) => void
tooltip?: string
type?: string
}

View File

@@ -0,0 +1,7 @@
@import 'payload/scss';
.rich-text-ul {
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
}

View File

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

View File

@@ -0,0 +1,54 @@
import type { Path } from 'slate'
import { Editor, Element, Transforms } from 'slate'
import { areAllChildrenElements } from './areAllChildrenElements'
import listTypes from './listTypes'
export const unwrapList = (editor: Editor, atPath: Path): void => {
// Remove type for any nodes that have text children -
// this means that the node should remain
Transforms.setNodes(
editor,
{ type: undefined },
{
at: atPath,
match: (node, path) => {
const childrenAreAllElements = areAllChildrenElements(node)
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
!childrenAreAllElements &&
node.type === 'li' &&
path.length === atPath.length + 1
return matches
},
},
)
// For nodes have all element children, unwrap it instead
// because the li is a duplicative wrapper
Transforms.unwrapNodes(editor, {
at: atPath,
match: (node, path) => {
const childrenAreAllElements = areAllChildrenElements(node)
const matches =
!Editor.isEditor(node) &&
Element.isElement(node) &&
childrenAreAllElements &&
node.type === 'li' &&
path.length === atPath.length + 1
return matches
},
})
// Finally, unwrap the UL itself
Transforms.unwrapNodes(editor, {
match: (n) => Element.isElement(n) && listTypes.includes(n.type),
mode: 'lowest',
})
}

View File

@@ -0,0 +1,7 @@
@import 'payload/scss';
.upload-rich-text-button {
display: flex;
align-items: center;
height: 100%;
}

View File

@@ -0,0 +1,82 @@
import { useListDrawer } from 'payload/components/elements'
import React, { Fragment, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { ReactEditor, useSlate } from 'slate-react'
import UploadIcon from '../../../icons/Upload'
import ElementButton from '../../Button'
import { EnabledRelationshipsCondition } from '../../EnabledRelationshipsCondition'
import { injectVoidElement } from '../../injectVoid'
import './index.scss'
const baseClass = 'upload-rich-text-button'
const insertUpload = (editor, { relationTo, value }) => {
const text = { text: ' ' }
const upload = {
children: [text],
relationTo,
type: 'upload',
value,
}
injectVoidElement(editor, upload)
ReactEditor.focus(editor)
}
type ButtonProps = {
enabledCollectionSlugs: string[]
path: string
}
const UploadButton: React.FC<ButtonProps> = ({ enabledCollectionSlugs }) => {
const { t } = useTranslation(['upload', 'general'])
const editor = useSlate()
const [ListDrawer, ListDrawerToggler, { closeDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
uploads: true,
})
const onSelect = useCallback(
({ collectionConfig, docID }) => {
insertUpload(editor, {
relationTo: collectionConfig.slug,
value: {
id: docID,
},
})
closeDrawer()
},
[editor, closeDrawer],
)
return (
<Fragment>
<ListDrawerToggler>
<ElementButton
className={baseClass}
el="div"
format="upload"
onClick={() => {
// do nothing
}}
tooltip={t('fields:addUpload')}
>
<UploadIcon />
</ElementButton>
</ListDrawerToggler>
<ListDrawer onSelect={onSelect} />
</Fragment>
)
}
export default (props: ButtonProps): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<UploadButton {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1,84 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import { useModal } from '@faceless-ui/modal'
import { Drawer } from 'payload/components/elements'
import { Form, FormSubmit, RenderFields } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { fieldTypes } from 'payload/config'
import { deepCopyObject, getTranslation } from 'payload/utilities'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Transforms } from 'slate'
import { ReactEditor, useSlateStatic } from 'slate-react'
import type { ElementProps } from '..'
export const UploadDrawer: React.FC<
ElementProps & {
drawerSlug: string
relatedCollection: SanitizedCollectionConfig
}
> = (props) => {
const editor = useSlateStatic()
const { drawerSlug, element, fieldProps, relatedCollection } = props
const { i18n, t } = useTranslation()
const { code: locale } = useLocale()
const { user } = useAuth()
const { closeModal } = useModal()
const { getDocPreferences } = useDocumentInfo()
const [initialState, setInitialState] = useState({})
const fieldSchema = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields
const handleUpdateEditData = useCallback(
(_, data) => {
const newNode = {
fields: data,
}
const elementPath = ReactEditor.findPath(editor, element)
Transforms.setNodes(editor, newNode, { at: elementPath })
closeModal(drawerSlug)
},
[closeModal, editor, element, drawerSlug],
)
useEffect(() => {
const awaitInitialState = async () => {
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
data: deepCopyObject(element?.fields || {}),
fieldSchema,
locale,
operation: 'update',
preferences,
t,
user,
})
setInitialState(state)
}
awaitInitialState()
}, [fieldSchema, element.fields, user, locale, t, getDocPreferences])
return (
<Drawer
slug={drawerSlug}
title={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
<Form initialState={initialState} onSubmit={handleUpdateEditData}>
<RenderFields fieldSchema={fieldSchema} fieldTypes={fieldTypes} readOnly={false} />
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</Drawer>
)
}

View File

@@ -0,0 +1,147 @@
@import 'payload/scss';
.rich-text-upload {
@extend %body;
@include shadow-sm;
max-width: base(15);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
&:hover {
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
&__card {
@include soft-shadow-bottom;
display: flex;
flex-direction: column;
width: 100%;
}
&__topRow {
display: flex;
}
&__thumbnail {
width: base(3.25);
height: auto;
position: relative;
overflow: hidden;
flex-shrink: 0;
img,
svg {
position: absolute;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-elevation-800);
}
}
&__topRowRightPanel {
flex-grow: 1;
display: flex;
align-items: center;
padding: base(0.75);
justify-content: space-between;
max-width: calc(100% - #{base(3.25)});
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
.rich-text-upload__doc-drawer-toggler {
pointer-events: all;
}
& > *:not(:last-child) {
margin-right: base(0.25);
}
}
&__removeButton {
margin: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__upload-drawer-toggler {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
outline: none;
line-height: inherit;
}
&__doc-drawer-toggler {
text-decoration: underline;
}
&__doc-drawer-toggler,
&__list-drawer-toggler,
&__upload-drawer-toggler {
& > * {
margin: 0;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__collectionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottomRow {
padding: base(0.5);
border-top: 1px solid var(--theme-elevation-100);
}
h5 {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__wrap {
padding: base(0.5) base(0.5) base(0.5) base(1);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
@include small-break {
&__topRowRightPanel {
padding: base(0.75) base(0.5);
}
}
}

View File

@@ -0,0 +1,226 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import type { HTMLAttributes } from 'react'
import { Button } from 'payload/components'
import {
DrawerToggler,
useDocumentDrawer,
useDrawerSlug,
useListDrawer,
} from 'payload/components/elements'
import { FileGraphic } from 'payload/components/graphics'
import { usePayloadAPI, useThumbnail } from 'payload/components/hooks'
import { useConfig } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Transforms } from 'slate'
import { ReactEditor, useFocused, useSelected, useSlateStatic } from 'slate-react'
import type { FieldProps } from '../../../../types'
import { EnabledRelationshipsCondition } from '../../EnabledRelationshipsCondition'
import { UploadDrawer } from './UploadDrawer'
import './index.scss'
const baseClass = 'rich-text-upload'
const initialParams = {
depth: 0,
}
export type ElementProps = {
attributes: HTMLAttributes<HTMLDivElement>
children: React.ReactNode
element: any
enabledCollectionSlugs: string[]
fieldProps: FieldProps
}
const Element: React.FC<ElementProps> = (props) => {
const {
attributes,
children,
element: { relationTo, value },
element,
enabledCollectionSlugs,
fieldProps,
} = props
const {
collections,
routes: { api },
serverURL,
} = useConfig()
const { i18n, t } = useTranslation('fields')
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [relatedCollection, setRelatedCollection] = useState<SanitizedCollectionConfig>(() =>
collections.find((coll) => coll.slug === relationTo),
)
const drawerSlug = useDrawerSlug('upload-drawer')
const [ListDrawer, ListDrawerToggler, { closeDrawer: closeListDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: relatedCollection.slug,
})
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
id: value?.id,
collectionSlug: relatedCollection.slug,
})
const editor = useSlateStatic()
const selected = useSelected()
const focused = useFocused()
// Get the referenced document
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value?.id}`,
{ initialParams },
)
const thumbnailSRC = useThumbnail(relatedCollection, data)
const removeUpload = useCallback(() => {
const elementPath = ReactEditor.findPath(editor, element)
Transforms.removeNodes(editor, { at: elementPath })
}, [editor, element])
const updateUpload = useCallback(
(json) => {
const { doc } = json
const newNode = {
fields: doc,
}
const elementPath = ReactEditor.findPath(editor, element)
Transforms.setNodes(editor, newNode, { at: elementPath })
// setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug));
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
dispatchCacheBust()
closeDrawer()
},
[editor, element, setParams, cacheBust, closeDrawer],
)
const swapUpload = React.useCallback(
({ collectionConfig, docID }) => {
const newNode = {
children: [{ text: ' ' }],
relationTo: collectionConfig.slug,
type: 'upload',
value: { id: docID },
}
const elementPath = ReactEditor.findPath(editor, element)
setRelatedCollection(collections.find((coll) => coll.slug === collectionConfig.slug))
Transforms.setNodes(editor, newNode, { at: elementPath })
dispatchCacheBust()
closeListDrawer()
},
[closeListDrawer, editor, element, collections],
)
const customFields = fieldProps?.admin?.upload?.collections?.[relatedCollection.slug]?.fields
return (
<div
className={[baseClass, selected && focused && `${baseClass}--selected`]
.filter(Boolean)
.join(' ')}
contentEditable={false}
{...attributes}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC ? <img alt={data?.filename} src={thumbnailSRC} /> : <FileGraphic />}
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
<div className={`${baseClass}__actions`}>
{customFields?.length > 0 && (
<DrawerToggler
className={`${baseClass}__upload-drawer-toggler`}
disabled={fieldProps?.admin?.readOnly}
slug={drawerSlug}
>
<Button
buttonStyle="icon-label"
el="div"
icon="edit"
onClick={(e) => {
e.preventDefault()
}}
round
tooltip={t('fields:editRelationship')}
/>
</DrawerToggler>
)}
<ListDrawerToggler
className={`${baseClass}__list-drawer-toggler`}
disabled={fieldProps?.admin?.readOnly}
>
<Button
buttonStyle="icon-label"
disabled={fieldProps?.admin?.readOnly}
el="div"
icon="swap"
onClick={() => {
// do nothing
}}
round
tooltip={t('swapUpload')}
/>
</ListDrawerToggler>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={fieldProps?.admin?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeUpload()
}}
round
tooltip={t('removeUpload')}
/>
</div>
</div>
</div>
<div className={`${baseClass}__bottomRow`}>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<strong>{data?.filename}</strong>
</DocumentDrawerToggler>
</div>
</div>
{children}
{value?.id && <DocumentDrawer onSave={updateUpload} />}
<ListDrawer onSelect={swapUpload} />
<UploadDrawer drawerSlug={drawerSlug} relatedCollection={relatedCollection} {...props} />
</div>
)
}
export default (props: ElementProps): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<Element {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1,9 @@
import Button from './Button'
import Element from './Element'
import plugin from './plugin'
export default {
Button,
Element,
plugins: [plugin],
}

View File

@@ -0,0 +1,10 @@
const withRelationship = (incomingEditor) => {
const editor = incomingEditor
const { isVoid } = editor
editor.isVoid = (element) => (element.type === 'upload' ? true : isVoid(element))
return editor
}
export default withRelationship

View File

@@ -0,0 +1,28 @@
import elementTypes from './elements'
import leafTypes from './leaves'
const addPluginReducer = (EditorWithPlugins, plugin) => {
if (typeof plugin === 'function') return plugin(EditorWithPlugins)
return EditorWithPlugins
}
const enablePlugins = (CreatedEditor, functions) =>
functions.reduce((CreatedEditorWithPlugins, func) => {
if (typeof func === 'object' && Array.isArray(func.plugins)) {
return func.plugins.reduce(addPluginReducer, CreatedEditorWithPlugins)
}
if (typeof func === 'string') {
if (elementTypes[func] && elementTypes[func].plugins) {
return elementTypes[func].plugins.reduce(addPluginReducer, CreatedEditorWithPlugins)
}
if (leafTypes[func] && leafTypes[func].plugins) {
return leafTypes[func].plugins.reduce(addPluginReducer, CreatedEditorWithPlugins)
}
}
return CreatedEditorWithPlugins
}, CreatedEditor)
export default enablePlugins

View File

@@ -0,0 +1,6 @@
export default {
'mod+`': 'code',
'mod+b': 'bold',
'mod+i': 'italic',
'mod+u': 'underline',
}

View File

@@ -0,0 +1,9 @@
import React from 'react'
const AlignCenterIcon: React.FC = () => (
<svg fill="currentColor" height="1em" viewBox="0 0 1024 1024" width="1em">
<path d="M264 230h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H264c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm496 424c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H264c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496zm144 140H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0-424H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" />
</svg>
)
export default AlignCenterIcon

View File

@@ -0,0 +1,9 @@
import React from 'react'
const AlignLeftIcon: React.FC = () => (
<svg fill="currentColor" height="1em" viewBox="0 0 1024 1024" width="1em">
<path d="M120 230h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm0 424h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm784 140H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0-424H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" />
</svg>
)
export default AlignLeftIcon

View File

@@ -0,0 +1,9 @@
import React from 'react'
const AlignRightIcon: React.FC = () => (
<svg fill="currentColor" height="1em" viewBox="0 0 1024 1024" width="1em">
<path d="M904 158H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 424H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h496c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 212H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0-424H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8z" />
</svg>
)
export default AlignRightIcon

View File

@@ -0,0 +1,17 @@
import React from 'react'
const BlockquoteIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic blockquote-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path className="fill" d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
<path d="M0 0h24v24H0z" fill="none" />
</svg>
)
export default BlockquoteIcon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const BoldIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic bold-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill"
d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"
/>
<path d="M0 0h24v24H0z" fill="none" />
</svg>
)
export default BoldIcon

View File

@@ -0,0 +1,19 @@
import React from 'react'
const CodeIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic inline-code-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill"
d="M7.375 16.781l1.25-1.562L4.601 12l4.024-3.219-1.25-1.562-5 4a1 1 0 000 1.562l5 4zm9.25-9.562l-1.25 1.562L19.399 12l-4.024 3.219 1.25 1.562 5-4a1 1 0 000-1.562l-5-4zM14.976 3.216l-4 18-1.953-.434 4-18z"
/>
</svg>
)
export default CodeIcon

View File

@@ -0,0 +1,16 @@
@import 'payload/scss';
.icon--indent-left {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: var(--theme-elevation-800);
stroke-width: $style-stroke-width-m;
}
.fill {
fill: var(--theme-elevation-800);
}
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import './index.scss'
const IndentLeft: React.FC = () => (
<svg className="icon icon--indent-left" viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg">
<path className="fill" d="M16.005 9.61502L21.005 13.1864L21.005 6.04361L16.005 9.61502Z" />
<rect className="fill" height="2.15625" width="9.0675" x="5" y="5.68199" />
<rect className="fill" height="2.15625" width="9.0675" x="5" y="11.4738" />
<rect className="fill" height="2.15625" width="16.005" x="5" y="17.2656" />
</svg>
)
export default IndentLeft

View File

@@ -0,0 +1,16 @@
@import 'payload/scss';
.icon--indent-right {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: var(--theme-elevation-800);
stroke-width: $style-stroke-width-m;
}
.fill {
fill: var(--theme-elevation-800);
}
}

View File

@@ -0,0 +1,14 @@
import React from 'react'
import './index.scss'
const IndentRight: React.FC = () => (
<svg className="icon icon--indent-right" viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg">
<path className="fill" d="M10 9.61502L5 6.04361L5 13.1864L10 9.61502Z" />
<rect className="fill" height="2.15625" width="9.0675" x="11.9375" y="5.68199" />
<rect className="fill" height="2.15625" width="9.0675" x="11.9375" y="11.4738" />
<rect className="fill" height="2.15625" width="16.005" x="5" y="17.2656" />
</svg>
)
export default IndentRight

View File

@@ -0,0 +1,17 @@
import React from 'react'
const ItalicIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic italic-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path className="fill" d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
)
export default ItalicIcon

View File

@@ -0,0 +1,11 @@
@import 'payload/scss';
.icon--link {
width: $baseline;
height: $baseline;
.stroke {
stroke: var(--theme-elevation-800);
stroke-width: $style-stroke-width;
}
}

View File

@@ -0,0 +1,22 @@
import React from 'react'
import './index.scss'
const LinkIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic link icon icon--link"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"
/>
</svg>
)
export default LinkIcon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const OrderedListIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic ordered-list-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill"
d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"
/>
<path d="M0 0h24v24H0z" fill="none" />
</svg>
)
export default OrderedListIcon

View File

@@ -0,0 +1,12 @@
@import 'payload/scss';
.icon--relationship {
height: $baseline;
width: $baseline;
.stroke {
fill: none;
stroke: var(--theme-elevation-800);
stroke-width: $style-stroke-width-m;
}
}

View File

@@ -0,0 +1,17 @@
import React from 'react'
import './index.scss'
const Relationship: React.FC = () => (
<svg className="icon icon--relationship" viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg">
<path
className="stroke"
d="M19.0597 14.9691L19.0597 19.0946L6.01681 19.0946L6.01681 6.03028L10.0948 6.03028"
strokeWidth="2"
/>
<path className="stroke" d="M19.0597 11.0039L19.0597 6.00387L14.0597 6.00387" strokeWidth="2" />
<line className="stroke" strokeWidth="2" x1="18.7061" x2="13.0493" y1="6.40767" y2="12.0645" />
</svg>
)
export default Relationship

View File

@@ -0,0 +1,17 @@
import React from 'react'
const StrikethroughIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic strikethrough-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path className="fill" d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
</svg>
)
export default StrikethroughIcon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const UnderlineIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic underline-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z"
/>
</svg>
)
export default UnderlineIcon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const UnorderedListIcon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic unordered-list-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
className="fill"
d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"
/>
<path d="M0 0h24v24H0V0z" fill="none" />
</svg>
)
export default UnorderedListIcon

View File

@@ -0,0 +1,11 @@
@import 'payload/scss';
.icon--upload {
height: $baseline;
width: $baseline;
.fill {
fill: var(--theme-elevation-800);
stroke: none;
}
}

View File

@@ -0,0 +1,15 @@
import React from 'react'
import './index.scss'
const Upload: React.FC = () => (
<svg className="icon icon--upload" viewBox="0 0 25 25" xmlns="http://www.w3.org/2000/svg">
<path
className="fill"
d="M20.06,5.12h-15v15h15Zm-2,2v7L15.37,11l-3.27,4.1-2-1.58-3,3.74V7.12Z"
/>
<circle className="fill" cx="9.69" cy="9.47" r="0.97" />
</svg>
)
export default Upload

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H1Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h1-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2V9h-2V7h4v10z"
/>
</svg>
)
export default H1Icon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H2Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h2-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 8a2 2 0 01-2 2h-2v2h4v2H9v-4a2 2 0 012-2h2V9H9V7h4a2 2 0 012 2v2z"
/>
</svg>
)
export default H2Icon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H3Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h3-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M.01 0h24v24h-24z" fill="none" />
<path
className="fill"
d="M19.01 3h-14c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 7.5c0 .83-.67 1.5-1.5 1.5.83 0 1.5.67 1.5 1.5V15a2 2 0 01-2 2h-4v-2h4v-2h-2v-2h2V9h-4V7h4a2 2 0 012 2v1.5z"
/>
</svg>
)
export default H3Icon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H4Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h4-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 14h-2v-4H9V7h2v4h2V7h2v10z"
/>
</svg>
)
export default H4Icon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H5Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h5-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 6h-4v2h2a2 2 0 012 2v2a2 2 0 01-2 2H9v-2h4v-2H9V7h6v2z"
/>
</svg>
)
export default H5Icon

View File

@@ -0,0 +1,20 @@
import React from 'react'
const H5Icon: React.FC = () => (
<svg
aria-hidden="true"
className="graphic h6-icon"
fill="currentColor"
focusable="false"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" />
<path
className="fill"
d="M11 15h2v-2h-2v2zm8-12H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 6h-4v2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V9a2 2 0 012-2h4v2z"
/>
</svg>
)
export default H5Icon

View File

@@ -0,0 +1,15 @@
import H1 from './H1'
import H2 from './H2'
import H3 from './H3'
import H4 from './H4'
import H5 from './H5'
import H6 from './H6'
export default {
H1,
H2,
H3,
H4,
H5,
H6,
}

View File

@@ -0,0 +1,193 @@
@import 'payload/scss';
.rich-text {
margin-bottom: base(2);
display: flex;
isolation: isolate;
&__toolbar {
@include blur-bg(var(--theme-elevation-0));
margin-bottom: $baseline;
border: $style-stroke-width-s solid var(--theme-elevation-150);
position: sticky;
z-index: 1;
top: base(4);
}
&__toolbar-wrap {
padding: base(0.25);
display: flex;
flex-wrap: wrap;
align-items: stretch;
position: relative;
z-index: 1;
&:after {
content: ' ';
opacity: 0.8;
position: absolute;
top: calc(100% + 1px);
background: linear-gradient(var(--theme-elevation-0), transparent);
display: block;
left: -1px;
right: -1px;
height: base(1);
}
}
&__editor {
font-family: var(--font-serif);
font-size: base(0.625);
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-body);
line-height: 1.125;
}
h1[data-slate-node='element'] {
font-size: base(1.5);
margin: base(1) 0 base(0.5);
}
h2[data-slate-node='element'] {
font-size: base(1.25);
margin: base(1) 0 base(0.5);
}
h3[data-slate-node='element'] {
font-size: base(1.125);
margin: base(0.75) 0 base(0.5);
}
h4[data-slate-node='element'] {
font-size: base(1);
margin: base(0.5) 0 base(0.5);
}
h5[data-slate-node='element'] {
font-size: base(0.875);
margin: base(0.25) 0 base(0.25);
}
h6[data-slate-node='element'] {
font-size: base(0.75);
margin: base(0.25) 0 base(0.25);
}
}
&--gutter {
.rich-text__editor {
padding-left: $baseline;
border-left: 1px solid var(--theme-elevation-100);
}
}
&__input {
min-height: base(10);
}
&__wrap {
width: 100%;
position: relative;
}
&__wrapper {
width: 100%;
}
&--read-only {
.rich-text__editor {
background: var(--theme-elevation-200);
color: var(--theme-elevation-450);
padding: base(0.5);
.popup button {
display: none;
}
}
.rich-text__toolbar {
pointer-events: none;
position: relative;
top: 0;
&::after {
content: ' ';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--theme-elevation-200);
opacity: 0.85;
z-index: 2;
backdrop-filter: unset;
}
}
}
&__button {
@extend %btn-reset;
padding: base(0.25);
svg {
@include color-svg(var(--theme-elevation-800));
width: base(0.75);
height: base(0.75);
}
&:hover {
background-color: var(--theme-elevation-100);
}
&__button--active,
&__button--active:hover {
background-color: var(--theme-elevation-150);
}
}
&__drawerIsOpen {
top: base(1);
}
@include mid-break {
&__toolbar {
top: base(3);
}
&__drawerIsOpen {
top: base(1);
}
}
}
[data-slate-node='element'] {
margin-bottom: base(0.25);
}
html[data-theme='light'] {
.rich-text {
&.error {
.rich-text__editor,
.rich-text__toolbar {
@include lightInputError;
}
}
}
}
html[data-theme='dark'] {
.rich-text {
&.error {
.rich-text__editor,
.rich-text__toolbar {
@include darkInputError;
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More