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:
10
packages/richtext-slate/.eslintignore
Normal file
10
packages/richtext-slate/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/richtext-slate/.eslintrc.cjs
Normal file
15
packages/richtext-slate/.eslintrc.cjs
Normal 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,
|
||||
}
|
||||
10
packages/richtext-slate/.prettierignore
Normal file
10
packages/richtext-slate/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/richtext-slate/.swcrc
Normal file
15
packages/richtext-slate/.swcrc
Normal 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"
|
||||
}
|
||||
}
|
||||
44
packages/richtext-slate/package.json
Normal file
44
packages/richtext-slate/package.json
Normal 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"
|
||||
}
|
||||
16
packages/richtext-slate/src/cell/index.tsx
Normal file
16
packages/richtext-slate/src/cell/index.tsx
Normal 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
|
||||
5
packages/richtext-slate/src/data/defaultValue.ts
Normal file
5
packages/richtext-slate/src/data/defaultValue.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const defaultRichTextValue = [
|
||||
{
|
||||
children: [{ text: '' }],
|
||||
},
|
||||
]
|
||||
53
packages/richtext-slate/src/data/populate.ts
Normal file
53
packages/richtext-slate/src/data/populate.ts
Normal 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
|
||||
}
|
||||
}
|
||||
202
packages/richtext-slate/src/data/recurseNestedFields.ts
Normal file
202
packages/richtext-slate/src/data/recurseNestedFields.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
148
packages/richtext-slate/src/data/richTextRelationshipPromise.ts
Normal file
148
packages/richtext-slate/src/data/richTextRelationshipPromise.ts
Normal 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)
|
||||
}
|
||||
18
packages/richtext-slate/src/data/validation.ts
Normal file
18
packages/richtext-slate/src/data/validation.ts
Normal 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
|
||||
}
|
||||
449
packages/richtext-slate/src/field/RichText.tsx
Normal file
449
packages/richtext-slate/src/field/RichText.tsx
Normal 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)
|
||||
15
packages/richtext-slate/src/field/buttons.scss
Normal file
15
packages/richtext-slate/src/field/buttons.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
52
packages/richtext-slate/src/field/elements/Button.tsx
Normal file
52
packages/richtext-slate/src/field/elements/Button.tsx
Normal 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
|
||||
@@ -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 })
|
||||
}
|
||||
40
packages/richtext-slate/src/field/elements/ListButton.tsx
Normal file
40
packages/richtext-slate/src/field/elements/ListButton.tsx
Normal 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
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)),
|
||||
})
|
||||
}
|
||||
17
packages/richtext-slate/src/field/elements/h1/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h1/index.tsx
Normal 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
|
||||
17
packages/richtext-slate/src/field/elements/h2/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h2/index.tsx
Normal 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
|
||||
17
packages/richtext-slate/src/field/elements/h3/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h3/index.tsx
Normal 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
|
||||
17
packages/richtext-slate/src/field/elements/h4/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h4/index.tsx
Normal 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
|
||||
17
packages/richtext-slate/src/field/elements/h5/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h5/index.tsx
Normal 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
|
||||
17
packages/richtext-slate/src/field/elements/h6/index.tsx
Normal file
17
packages/richtext-slate/src/field/elements/h6/index.tsx
Normal 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
|
||||
226
packages/richtext-slate/src/field/elements/indent/index.tsx
Normal file
226
packages/richtext-slate/src/field/elements/indent/index.tsx
Normal 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
|
||||
35
packages/richtext-slate/src/field/elements/index.tsx
Normal file
35
packages/richtext-slate/src/field/elements/index.tsx
Normal 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
|
||||
30
packages/richtext-slate/src/field/elements/injectVoid.ts
Normal file
30
packages/richtext-slate/src/field/elements/injectVoid.ts
Normal 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: '' }] })
|
||||
}
|
||||
16
packages/richtext-slate/src/field/elements/isActive.tsx
Normal file
16
packages/richtext-slate/src/field/elements/isActive.tsx
Normal 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
|
||||
27
packages/richtext-slate/src/field/elements/isBlockElement.ts
Normal file
27
packages/richtext-slate/src/field/elements/isBlockElement.ts
Normal 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)
|
||||
@@ -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 === ''
|
||||
)
|
||||
}
|
||||
29
packages/richtext-slate/src/field/elements/isListActive.ts
Normal file
29
packages/richtext-slate/src/field/elements/isListActive.ts
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
25
packages/richtext-slate/src/field/elements/li/index.tsx
Normal file
25
packages/richtext-slate/src/field/elements/li/index.tsx
Normal 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,
|
||||
}
|
||||
137
packages/richtext-slate/src/field/elements/link/Button/index.tsx
Normal file
137
packages/richtext-slate/src/field/elements/link/Button/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
11
packages/richtext-slate/src/field/elements/link/index.tsx
Normal file
11
packages/richtext-slate/src/field/elements/link/index.tsx
Normal 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
|
||||
@@ -0,0 +1 @@
|
||||
export const modalSlug = 'rich-text-link-modal'
|
||||
@@ -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
|
||||
}
|
||||
1
packages/richtext-slate/src/field/elements/listTypes.tsx
Normal file
1
packages/richtext-slate/src/field/elements/listTypes.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export default ['ol', 'ul']
|
||||
7
packages/richtext-slate/src/field/elements/ol/index.scss
Normal file
7
packages/richtext-slate/src/field/elements/ol/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.rich-text-ol {
|
||||
&[data-slate-node='element'] {
|
||||
margin: base(0.625) 0;
|
||||
}
|
||||
}
|
||||
22
packages/richtext-slate/src/field/elements/ol/index.tsx
Normal file
22
packages/richtext-slate/src/field/elements/ol/index.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.relationship-rich-text-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Button from './Button'
|
||||
import Element from './Element'
|
||||
import plugin from './plugin'
|
||||
|
||||
export default {
|
||||
Button,
|
||||
Element,
|
||||
plugins: [plugin],
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
}
|
||||
38
packages/richtext-slate/src/field/elements/toggle.tsx
Normal file
38
packages/richtext-slate/src/field/elements/toggle.tsx
Normal 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
|
||||
100
packages/richtext-slate/src/field/elements/toggleList.tsx
Normal file
100
packages/richtext-slate/src/field/elements/toggleList.tsx
Normal 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
|
||||
11
packages/richtext-slate/src/field/elements/types.ts
Normal file
11
packages/richtext-slate/src/field/elements/types.ts
Normal 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
|
||||
}
|
||||
7
packages/richtext-slate/src/field/elements/ul/index.scss
Normal file
7
packages/richtext-slate/src/field/elements/ul/index.scss
Normal file
@@ -0,0 +1,7 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.rich-text-ul {
|
||||
&[data-slate-node='element'] {
|
||||
margin: base(0.625) 0;
|
||||
}
|
||||
}
|
||||
22
packages/richtext-slate/src/field/elements/ul/index.tsx
Normal file
22
packages/richtext-slate/src/field/elements/ul/index.tsx
Normal 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
|
||||
54
packages/richtext-slate/src/field/elements/unwrapList.ts
Normal file
54
packages/richtext-slate/src/field/elements/unwrapList.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.upload-rich-text-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Button from './Button'
|
||||
import Element from './Element'
|
||||
import plugin from './plugin'
|
||||
|
||||
export default {
|
||||
Button,
|
||||
Element,
|
||||
plugins: [plugin],
|
||||
}
|
||||
10
packages/richtext-slate/src/field/elements/upload/plugin.tsx
Normal file
10
packages/richtext-slate/src/field/elements/upload/plugin.tsx
Normal 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
|
||||
28
packages/richtext-slate/src/field/enablePlugins.tsx
Normal file
28
packages/richtext-slate/src/field/enablePlugins.tsx
Normal 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
|
||||
6
packages/richtext-slate/src/field/hotkeys.tsx
Normal file
6
packages/richtext-slate/src/field/hotkeys.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
'mod+`': 'code',
|
||||
'mod+b': 'bold',
|
||||
'mod+i': 'italic',
|
||||
'mod+u': 'underline',
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
17
packages/richtext-slate/src/field/icons/Blockquote/index.tsx
Normal file
17
packages/richtext-slate/src/field/icons/Blockquote/index.tsx
Normal 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
|
||||
20
packages/richtext-slate/src/field/icons/Bold/index.tsx
Normal file
20
packages/richtext-slate/src/field/icons/Bold/index.tsx
Normal 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
|
||||
19
packages/richtext-slate/src/field/icons/Code/index.tsx
Normal file
19
packages/richtext-slate/src/field/icons/Code/index.tsx
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
14
packages/richtext-slate/src/field/icons/IndentLeft/index.tsx
Normal file
14
packages/richtext-slate/src/field/icons/IndentLeft/index.tsx
Normal 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
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
17
packages/richtext-slate/src/field/icons/Italic/index.tsx
Normal file
17
packages/richtext-slate/src/field/icons/Italic/index.tsx
Normal 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
|
||||
11
packages/richtext-slate/src/field/icons/Link/index.scss
Normal file
11
packages/richtext-slate/src/field/icons/Link/index.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
22
packages/richtext-slate/src/field/icons/Link/index.tsx
Normal file
22
packages/richtext-slate/src/field/icons/Link/index.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
20
packages/richtext-slate/src/field/icons/Underline/index.tsx
Normal file
20
packages/richtext-slate/src/field/icons/Underline/index.tsx
Normal 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
|
||||
@@ -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
|
||||
11
packages/richtext-slate/src/field/icons/Upload/index.scss
Normal file
11
packages/richtext-slate/src/field/icons/Upload/index.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.icon--upload {
|
||||
height: $baseline;
|
||||
width: $baseline;
|
||||
|
||||
.fill {
|
||||
fill: var(--theme-elevation-800);
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
15
packages/richtext-slate/src/field/icons/Upload/index.tsx
Normal file
15
packages/richtext-slate/src/field/icons/Upload/index.tsx
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
15
packages/richtext-slate/src/field/icons/headings/index.tsx
Normal file
15
packages/richtext-slate/src/field/icons/headings/index.tsx
Normal 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,
|
||||
}
|
||||
193
packages/richtext-slate/src/field/index.scss
Normal file
193
packages/richtext-slate/src/field/index.scss
Normal 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
Reference in New Issue
Block a user