feat(richtext-lexical): inline blocks (#7102)

This commit is contained in:
Alessio Gravili
2024-07-16 20:42:36 -04:00
committed by GitHub
parent 1ea2e323bc
commit 676dfa3ecf
41 changed files with 1116 additions and 330 deletions

View File

@@ -449,6 +449,9 @@ export const blocks = baseField.keys({
joi.object({
slug: joi.string().required(),
admin: joi.object().keys({
components: joi.object().keys({
Label: componentSchema,
}),
custom: joi.object().pattern(joi.string(), joi.any()),
}),
custom: joi.object().pattern(joi.string(), joi.any()),

View File

@@ -727,6 +727,15 @@ export type RadioField = {
export type Block = {
admin?: {
components?: {
Label?: React.FC<{
blockKind: 'block' | 'lexicalBlock' | 'lexicalInlineBlock' | string
/**
* May contain the formData
*/
formData: Record<string, any>
}>
}
/** Extension point to add your custom data. Available in server and client. */
custom?: Record<string, any>
}

View File

@@ -1,5 +1,5 @@
import type { FormFieldBase } from '@payloadcms/ui'
import type { FieldMap, ReducedBlock } from '@payloadcms/ui/utilities/buildComponentMap'
import type { FieldMap } from '@payloadcms/ui/utilities/buildComponentMap'
import type { CollapsedPreferences, Data, FormState } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
@@ -20,6 +20,7 @@ import { $getNodeByKey } from 'lexical'
import React, { useCallback } from 'react'
import type { SanitizedClientEditorConfig } from '../../../lexical/config/types.js'
import type { ClientBlock } from '../feature.client.js'
import type { BlockFields, BlockNode } from '../nodes/BlocksNode.js'
import { FormSavePlugin } from './FormSavePlugin.js'
@@ -35,7 +36,7 @@ type Props = {
formSchema: FieldMap
nodeKey: string
path: string
reducedBlock: ReducedBlock
reducedBlock: ClientBlock
schemaPath: string
}
@@ -51,7 +52,7 @@ export const BlockContent: React.FC<Props> = (props) => {
formData,
formSchema,
nodeKey,
reducedBlock: { labels },
reducedBlock: { LabelComponent, labels },
schemaPath,
} = props
@@ -198,34 +199,38 @@ export const BlockContent: React.FC<Props> = (props) => {
className={classNames}
collapsibleStyle={fieldHasErrors ? 'error' : 'default'}
header={
<div className={`${baseClass}__block-header`}>
<div>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${formData?.blockType}`}
pillStyle="white"
>
{typeof labels.singular === 'string'
? getTranslation(labels.singular, i18n)
: '[Singular Label]'}
</Pill>
<SectionTitle path="blockName" readOnly={field?.readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
LabelComponent ? (
<LabelComponent blockKind={'lexicalBlock'} formData={formData} />
) : (
<div className={`${baseClass}__block-header`}>
<div>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${formData?.blockType}`}
pillStyle="white"
>
{typeof labels.singular === 'string'
? getTranslation(labels.singular, i18n)
: '[Singular Label]'}
</Pill>
<SectionTitle path="blockName" readOnly={field?.readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div>
{editor.isEditable() && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeBlock()
}}
round
tooltip="Remove Block"
/>
)}
</div>
{editor.isEditable() && (
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeBlock()
}}
round
tooltip="Remove Block"
/>
)}
</div>
)
}
isCollapsed={isCollapsed}
key={0}

View File

@@ -18,7 +18,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { type BlockFields } from '../nodes/BlocksNode.js'
const baseClass = 'lexical-block'
import type { ReducedBlock } from '@payloadcms/ui/utilities/buildComponentMap'
import type { FormState } from 'payload'
import { getTranslation } from '@payloadcms/translations'
@@ -26,7 +25,7 @@ import { getFormState } from '@payloadcms/ui/shared'
import { v4 as uuid } from 'uuid'
import type { ClientComponentProps } from '../../typesClient.js'
import type { BlocksFeatureClientProps } from '../feature.client.js'
import type { BlocksFeatureClientProps, ClientBlock } from '../feature.client.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { BlockContent } from './BlockContent.js'
@@ -34,13 +33,8 @@ import './index.scss'
type Props = {
children?: React.ReactNode
formData: BlockFields
nodeKey?: string
/**
* This transformedFormData already comes wrapped in blockFieldWrapperName
*/
transformedFormData: BlockFields
}
export const BlockComponent: React.FC<Props> = (props) => {
@@ -59,7 +53,7 @@ export const BlockComponent: React.FC<Props> = (props) => {
const componentMapRenderedFieldsPath = `lexical_internal_feature.blocks.fields.${formData?.blockType}`
const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.${formData?.blockType}`
const reducedBlock: ReducedBlock = (
const reducedBlock: ClientBlock = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.sanitizedClientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedBlocks?.find((block) => block.slug === formData?.blockType)
@@ -127,6 +121,8 @@ export const BlockComponent: React.FC<Props> = (props) => {
const classNames = [`${baseClass}__row`, `${baseClass}__row--no-errors`].filter(Boolean).join(' ')
const LabelComponent = reducedBlock?.LabelComponent
// Memoized Form JSX
const formContent = useMemo(() => {
return reducedBlock && initialState !== false ? (
@@ -155,19 +151,23 @@ export const BlockComponent: React.FC<Props> = (props) => {
className={classNames}
collapsibleStyle="default"
header={
<div className={`${baseClass}__block-header`}>
<div>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${formData?.blockType}`}
pillStyle="white"
>
{typeof reducedBlock.labels.singular === 'string'
? getTranslation(reducedBlock.labels.singular, i18n)
: '[Singular Label]'}
</Pill>
<SectionTitle path="blockName" readOnly={parentLexicalRichTextField?.readOnly} />
LabelComponent ? (
<LabelComponent blockKind={'lexicalBlock'} formData={formData} />
) : (
<div className={`${baseClass}__block-header`}>
<div>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${formData?.blockType}`}
pillStyle="white"
>
{reducedBlock && typeof reducedBlock.labels.singular === 'string'
? getTranslation(reducedBlock.labels.singular, i18n)
: '[Singular Label]'}
</Pill>
<SectionTitle path="blockName" readOnly={parentLexicalRichTextField?.readOnly} />
</div>
</div>
</div>
)
}
key={0}
>

View File

@@ -0,0 +1,72 @@
@import '../../../scss/styles.scss';
.inline-block-container {
display: inline-block;
}
.inline-block {
@extend %body;
@include shadow-sm;
padding: calc(var(--base) * 0.125) calc(var(--base) * 0.125) calc(var(--base) * 0.125) calc(var(--base) * 0.3);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
border-radius: $style-radius-m;
max-width: calc(var(--base) * 15);
font-family: var(--font-body);
margin-right: $style-stroke-width-m;
margin-left: $style-stroke-width-m;
&:hover {
border: 1px solid var(--theme-elevation-150);
}
&__wrap {
flex-grow: 1;
overflow: hidden;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
&__editButton.btn {
margin: 0;
}
&__editButton {
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: calc(var(--base) * 0.15);
svg {
width: 20px;
height: 20px;
}
}
&__removeButton.btn {
margin: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
}

View File

@@ -0,0 +1,158 @@
'use client'
import React, { useCallback, useEffect, useRef } from 'react'
const baseClass = 'inline-block'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import { Button, useTranslation } from '@payloadcms/ui'
import {
$getNodeByKey,
$getSelection,
$isNodeSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import type { ClientComponentProps } from '../../typesClient.js'
import type { BlocksFeatureClientProps, ClientBlock } from '../feature.client.js'
import type { InlineBlockFields } from '../nodes/InlineBlocksNode.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { $isInlineBlockNode } from '../nodes/InlineBlocksNode.js'
import { OPEN_INLINE_BLOCK_DRAWER_COMMAND } from '../plugin/commands.js'
import './index.scss'
type Props = {
readonly formData: InlineBlockFields
readonly nodeKey?: string
}
export const InlineBlockComponent: React.FC<Props> = (props) => {
const { formData, nodeKey } = props
const [editor] = useLexicalComposerContext()
const { i18n, t } = useTranslation<object, string>()
const { editorConfig, field } = useEditorConfigContext()
const inlineBlockElemElemRef = useRef<HTMLDivElement | null>(null)
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const reducedBlock: ClientBlock = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.sanitizedClientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedInlineBlocks?.find((block) => block.slug === formData?.blockType)
const removeInlineBlock = useCallback(() => {
editor.update(() => {
$getNodeByKey(nodeKey).remove()
})
}, [editor, nodeKey])
const $onDelete = useCallback(
(event: KeyboardEvent) => {
if (isSelected && $isNodeSelection($getSelection())) {
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isInlineBlockNode(node)) {
node.remove()
return true
}
}
return false
},
[isSelected, nodeKey],
)
const onClick = useCallback(
(payload: MouseEvent) => {
const event = payload
// Check if inlineBlockElemElemRef.target or anything WITHIN inlineBlockElemElemRef.target was clicked
if (
event.target === inlineBlockElemElemRef.current ||
inlineBlockElemElemRef.current?.contains(event.target as Node)
) {
if (event.shiftKey) {
setSelected(!isSelected)
} else {
if (!isSelected) {
clearSelection()
setSelected(true)
}
}
return true
}
return false
},
[isSelected, setSelected, clearSelection],
)
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(CLICK_COMMAND, onClick, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_DELETE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
editor.registerCommand(KEY_BACKSPACE_COMMAND, $onDelete, COMMAND_PRIORITY_LOW),
)
}, [clearSelection, editor, isSelected, nodeKey, $onDelete, setSelected, onClick])
const LabelComponent = reducedBlock?.LabelComponent
const blockDisplayName = reducedBlock.labels.singular
? getTranslation(reducedBlock.labels.singular, i18n)
: reducedBlock.slug
return (
<div
className={[
baseClass,
baseClass + '-' + formData.blockType,
isSelected && `${baseClass}--selected`,
]
.filter(Boolean)
.join(' ')}
ref={inlineBlockElemElemRef}
>
{LabelComponent ? (
<LabelComponent blockKind={'lexicalInlineBlock'} formData={formData} />
) : (
<div>{getTranslation(reducedBlock.labels.singular, i18n)}</div>
)}
{editor.isEditable() && (
<div className={`${baseClass}__actions`}>
<Button
buttonStyle="icon-label"
className={`${baseClass}__editButton`}
disabled={field?.readOnly}
el="div"
icon="edit"
onClick={() => {
editor.dispatchCommand(OPEN_INLINE_BLOCK_DRAWER_COMMAND, {
fields: formData,
nodeKey,
})
}}
round
size="small"
tooltip={t('lexical:blocks:inlineBlocks:edit', { label: blockDisplayName })}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeInlineBlock()
}}
round
size="small"
tooltip={t('lexical:blocks:inlineBlocks:remove', { label: blockDisplayName })}
/>
</div>
)}
</div>
)
}

View File

@@ -1,116 +0,0 @@
'use client'
import type { LexicalCommand, LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import {
BlocksDrawer,
formatDrawerSlug,
useEditDepth,
useModal,
useTranslation,
} from '@payloadcms/ui'
import { $getNodeByKey, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical'
import React, { useCallback, useEffect, useState } from 'react'
import type { ClientComponentProps } from '../../typesClient.js'
import type { BlocksFeatureClientProps } from '../feature.client.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { $createBlockNode } from '../nodes/BlocksNode.js'
import { INSERT_BLOCK_COMMAND } from '../plugin/commands.js'
const baseClass = 'lexical-blocks-drawer'
export const INSERT_BLOCK_WITH_DRAWER_COMMAND: LexicalCommand<{
replace: { nodeKey: string } | false
}> = createCommand('INSERT_BLOCK_WITH_DRAWER_COMMAND')
const insertBlock = ({
blockType,
editor,
replaceNodeKey,
}: {
blockType: string
editor: LexicalEditor
replaceNodeKey: null | string
}) => {
if (!replaceNodeKey) {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType,
})
} else {
editor.update(() => {
const node = $getNodeByKey(replaceNodeKey)
if (node) {
node.replace(
$createBlockNode({
id: null,
blockName: '',
blockType,
}),
)
}
})
}
}
export const BlocksDrawerComponent: React.FC = () => {
const [editor] = useLexicalComposerContext()
const { editorConfig, uuid } = useEditorConfigContext()
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const editDepth = useEditDepth()
const { t } = useTranslation()
const { openModal } = useModal()
const labels = {
plural: t('fields:blocks') || 'Blocks',
singular: t('fields:block') || 'Block',
}
const addRow = useCallback(
(rowIndex: number, blockType: string) => {
insertBlock({
blockType,
editor,
replaceNodeKey,
})
},
[editor, replaceNodeKey],
)
const drawerSlug = formatDrawerSlug({
slug: `lexical-rich-text-blocks-` + uuid,
depth: editDepth,
})
const reducedBlocks = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.sanitizedClientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedBlocks
useEffect(() => {
return editor.registerCommand<{
replace: { nodeKey: string } | false
}>(
INSERT_BLOCK_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openModal(drawerSlug)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, drawerSlug, openModal])
return (
<BlocksDrawer
addRow={addRow}
addRowIndex={0}
blocks={reducedBlocks}
drawerSlug={drawerSlug}
labels={labels}
/>
)
}

View File

@@ -1,21 +1,31 @@
'use client'
import type { ReducedBlock } from '@payloadcms/ui/utilities/buildComponentMap'
import type { Block } from 'payload'
import { getTranslation } from '@payloadcms/translations'
import type { ToolbarGroup } from '../toolbars/types.js'
import { BlockIcon } from '../../lexical/ui/icons/Block/index.js'
import { InlineBlocksIcon } from '../../lexical/ui/icons/InlineBlocks/index.js'
import { createClientFeature } from '../../utilities/createClientFeature.js'
import { BlockNode } from './nodes/BlocksNode.js'
import { INSERT_BLOCK_COMMAND } from './plugin/commands.js'
import { InlineBlockNode } from './nodes/InlineBlocksNode.js'
import { INSERT_BLOCK_COMMAND, OPEN_INLINE_BLOCK_DRAWER_COMMAND } from './plugin/commands.js'
import { BlocksPlugin } from './plugin/index.js'
export type ClientBlock = {
LabelComponent?: Block['admin']['components']['Label']
} & ReducedBlock
export type BlocksFeatureClientProps = {
reducedBlocks: ReducedBlock[]
reducedBlocks: ClientBlock[]
reducedInlineBlocks: ClientBlock[]
}
export const BlocksFeatureClient = createClientFeature<BlocksFeatureClientProps>(({ props }) => ({
nodes: [BlockNode],
nodes: [BlockNode, InlineBlockNode],
plugins: [
{
Component: BlocksPlugin,
@@ -25,65 +35,130 @@ export const BlocksFeatureClient = createClientFeature<BlocksFeatureClientProps>
sanitizedClientFeatureProps: props,
slashMenu: {
groups: [
{
items: props.reducedBlocks.map((block) => {
return {
Icon: BlockIcon,
key: 'block-' + block.slug,
keywords: ['block', 'blocks', block.slug],
label: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
props.reducedBlocks?.length
? {
items: props.reducedBlocks.map((block) => {
return {
Icon: BlockIcon,
key: 'block-' + block.slug,
keywords: ['block', 'blocks', block.slug],
label: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
return getTranslation(block.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
return getTranslation(block.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
}
}),
key: 'blocks',
label: ({ i18n }) => {
return i18n.t('lexical:blocks:label')
},
}
}),
key: 'blocks',
label: ({ i18n }) => {
return i18n.t('lexical:blocks:label')
},
},
],
: null,
props.reducedInlineBlocks?.length
? {
items: props.reducedInlineBlocks.map((inlineBlock) => {
return {
Icon: InlineBlocksIcon,
key: 'inlineBlocks-' + inlineBlock.slug,
keywords: ['inlineBlock', 'inline block', inlineBlock.slug],
label: ({ i18n }) => {
if (!inlineBlock.labels.singular) {
return inlineBlock.slug
}
return getTranslation(inlineBlock.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(OPEN_INLINE_BLOCK_DRAWER_COMMAND, {
fields: {
id: null,
blockName: '',
blockType: inlineBlock.slug,
},
})
},
}
}),
key: 'inlineBlocks',
label: ({ i18n }) => {
return i18n.t('lexical:blocks:inlineBlocks:label')
},
}
: null,
].filter(Boolean),
},
toolbarFixed: {
groups: [
{
type: 'dropdown',
ChildComponent: BlockIcon,
items: props.reducedBlocks.map((block, index) => {
return {
props.reducedBlocks?.length
? {
type: 'dropdown',
ChildComponent: BlockIcon,
isActive: undefined, // At this point, we would be inside a sub-richtext-editor. And at this point this will be run against the focused sub-editor, not the parent editor which has the actual block. Thus, no point in running this
key: 'block-' + block.slug,
label: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
items: props.reducedBlocks.map((block, index) => {
return {
ChildComponent: BlockIcon,
isActive: undefined, // At this point, we would be inside a sub-richtext-editor. And at this point this will be run against the focused sub-editor, not the parent editor which has the actual block. Thus, no point in running this
key: 'block-' + block.slug,
label: ({ i18n }) => {
if (!block.labels.singular) {
return block.slug
}
return getTranslation(block.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
order: index,
return getTranslation(block.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
id: null,
blockName: '',
blockType: block.slug,
})
},
order: index,
}
}),
key: 'blocks',
order: 20,
}
}),
key: 'blocks',
order: 20,
},
],
: null,
props.reducedInlineBlocks?.length
? {
type: 'dropdown',
ChildComponent: InlineBlocksIcon,
items: props.reducedInlineBlocks.map((inlineBlock, index) => {
return {
ChildComponent: InlineBlocksIcon,
isActive: undefined,
key: 'inlineBlock-' + inlineBlock.slug,
label: ({ i18n }) => {
if (!inlineBlock.labels.singular) {
return inlineBlock.slug
}
return getTranslation(inlineBlock.labels.singular, i18n)
},
onSelect: ({ editor }) => {
editor.dispatchCommand(OPEN_INLINE_BLOCK_DRAWER_COMMAND, {
fields: {
blockType: inlineBlock.slug,
},
})
},
order: index,
}
}),
key: 'inlineBlocks',
order: 25,
}
: null,
].filter(Boolean) as ToolbarGroup[],
},
}))

View File

@@ -11,10 +11,12 @@ import { createNode } from '../typeUtilities.js'
import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js'
import { i18n } from './i18n.js'
import { BlockNode } from './nodes/BlocksNode.js'
import { InlineBlockNode } from './nodes/InlineBlocksNode.js'
import { blockValidationHOC } from './validate.js'
export type BlocksFeatureProps = {
blocks: Block[]
blocks?: Block[]
inlineBlocks?: Block[]
}
export const BlocksFeature = createServerFeature<
@@ -23,34 +25,65 @@ export const BlocksFeature = createServerFeature<
BlocksFeatureClientProps
>({
feature: async ({ config: _config, isRoot, props }) => {
if (props?.blocks?.length) {
if (props?.blocks?.length || props?.inlineBlocks?.length) {
const validRelationships = _config.collections.map((c) => c.slug) || []
for (const block of props.blocks) {
block.fields = block.fields.concat(baseBlockFields)
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
if (props?.blocks?.length) {
for (const block of props.blocks) {
block.fields = block.fields.concat(baseBlockFields)
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
block.fields = await sanitizeFields({
config: _config as unknown as Config,
fields: block.fields,
requireFieldLevelRichTextEditor: isRoot,
validRelationships,
})
block.fields = await sanitizeFields({
config: _config as unknown as Config,
fields: block.fields,
requireFieldLevelRichTextEditor: isRoot,
validRelationships,
})
}
}
if (props?.inlineBlocks?.length) {
for (const block of props.inlineBlocks) {
block.fields = block.fields.concat(baseBlockFields)
block.labels = !block.labels ? formatLabels(block.slug) : block.labels
block.fields = await sanitizeFields({
config: _config as unknown as Config,
fields: block.fields,
requireFieldLevelRichTextEditor: isRoot,
validRelationships,
})
}
}
}
// Build clientProps
const clientProps: BlocksFeatureClientProps = {
reducedBlocks: [],
reducedInlineBlocks: [],
}
for (const block of props.blocks) {
clientProps.reducedBlocks.push({
slug: block.slug,
fieldMap: [],
imageAltText: block.imageAltText,
imageURL: block.imageURL,
labels: block.labels,
})
if (props?.blocks?.length) {
for (const block of props.blocks) {
clientProps.reducedBlocks.push({
slug: block.slug,
LabelComponent: block?.admin?.components?.Label,
fieldMap: [],
imageAltText: block.imageAltText,
imageURL: block.imageURL,
labels: block.labels,
})
}
}
if (props?.inlineBlocks?.length) {
for (const block of props.inlineBlocks) {
clientProps.reducedInlineBlocks.push({
slug: block.slug,
LabelComponent: block?.admin?.components?.Label,
fieldMap: [],
imageAltText: block.imageAltText,
imageURL: block.imageURL,
labels: block.labels,
})
}
}
return {
@@ -63,8 +96,16 @@ export const BlocksFeature = createServerFeature<
*/
const schemaMap = new Map<string, Field[]>()
for (const block of props.blocks) {
schemaMap.set(block.slug, block.fields || [])
if (props?.blocks?.length) {
for (const block of props.blocks) {
schemaMap.set(block.slug, block.fields || [])
}
}
if (props?.inlineBlocks?.length) {
for (const block of props.inlineBlocks) {
schemaMap.set(block.slug, block.fields || [])
}
}
return schemaMap
@@ -77,23 +118,32 @@ export const BlocksFeature = createServerFeature<
field,
interfaceNameDefinitions,
}) => {
if (!props?.blocks?.length) {
if (!props?.blocks?.length && !props?.inlineBlocks?.length) {
return currentSchema
}
const blocksField: BlockField = {
name: field?.name + '_lexical_blocks',
type: 'blocks',
blocks: props.blocks,
const fields: BlockField[] = []
if (props?.blocks?.length) {
fields.push({
name: field?.name + '_lexical_blocks',
type: 'blocks',
blocks: props.blocks,
})
}
if (props?.inlineBlocks?.length) {
fields.push({
name: field?.name + '_lexical_inline_blocks',
type: 'blocks',
blocks: props.inlineBlocks,
})
}
if (fields.length) {
// This is only done so that interfaceNameDefinitions sets those block's interfaceNames.
// we don't actually use the JSON Schema itself in the generated types yet.
fieldsToJSONSchema(collectionIDFieldTypes, fields, interfaceNameDefinitions, config)
}
// This is only done so that interfaceNameDefinitions sets those block's interfaceNames.
// we don't actually use the JSON Schema itself in the generated types yet.
fieldsToJSONSchema(
collectionIDFieldTypes,
[blocksField],
interfaceNameDefinitions,
config,
)
return currentSchema
},
@@ -110,9 +160,23 @@ export const BlocksFeature = createServerFeature<
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [blockPopulationPromiseHOC(props)],
graphQLPopulationPromises: [blockPopulationPromiseHOC(props.blocks)],
node: BlockNode,
validations: [blockValidationHOC(props)],
validations: [blockValidationHOC(props.blocks)],
}),
createNode({
getSubFields: ({ node }) => {
const blockType = node.fields.blockType
const block = props.inlineBlocks.find((block) => block.slug === blockType)
return block?.fields
},
getSubFieldsData: ({ node }) => {
return node?.fields
},
graphQLPopulationPromises: [blockPopulationPromiseHOC(props.inlineBlocks)],
node: InlineBlockNode,
validations: [blockValidationHOC(props.inlineBlocks)],
}),
],
sanitizedServerFeatureProps: props,

View File

@@ -1,12 +1,14 @@
import type { Block } from 'payload'
import type { PopulationPromise } from '../typesServer.js'
import type { BlocksFeatureProps } from './feature.server.js'
import type { SerializedBlockNode } from './nodes/BlocksNode.js'
import type { SerializedInlineBlockNode } from './nodes/InlineBlocksNode.js'
import { recursivelyPopulateFieldsForGraphQL } from '../../populateGraphQL/recursivelyPopulateFieldsForGraphQL.js'
export const blockPopulationPromiseHOC = (
props: BlocksFeatureProps,
): PopulationPromise<SerializedBlockNode> => {
blocks: Block[],
): PopulationPromise<SerializedBlockNode | SerializedInlineBlockNode> => {
const blockPopulationPromise: PopulationPromise<SerializedBlockNode> = ({
context,
currentDepth,
@@ -25,7 +27,7 @@ export const blockPopulationPromiseHOC = (
const blockFieldData = node.fields
// find block used in this node
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
const block = blocks.find((block) => block.slug === blockFieldData.blockType)
if (!block || !block?.fields?.length || !blockFieldData) {
return
}

View File

@@ -2,99 +2,291 @@ import type { GenericLanguages } from '@payloadcms/translations'
export const i18n: Partial<GenericLanguages> = {
ar: {
inlineBlocks: {
create: 'أنشئ {{label}}',
edit: 'تحرير {{التسمية}}',
label: 'الكتل الداخلية',
remove: 'إزالة {{التسمية}}',
},
label: 'كتل',
},
az: {
inlineBlocks: {
create: 'Yarat {{label}}',
edit: '{{label}} redaktə et',
label: 'Sıralı Bloklar',
remove: '{{label}} silin',
},
label: 'Bloklar',
},
bg: {
inlineBlocks: {
create: 'Създайте {{етикет}}',
edit: 'Редактирай {{етикет}}',
label: 'Вградени блокове',
remove: 'Премахнете {{етикет}}',
},
label: 'Блокове',
},
cs: {
inlineBlocks: {
create: 'Vytvořte {{štítek}}',
edit: 'Upravit {{label}}',
label: 'Inline bloky',
remove: 'Odstraňte {{label}}',
},
label: 'Bloky',
},
de: {
inlineBlocks: {
create: 'Erstelle {{label}}',
edit: 'Bearbeite {{label}}',
label: 'Inline-Blöcke',
remove: 'Entferne {{label}}',
},
label: 'Blöcke',
},
en: {
inlineBlocks: {
create: 'Create {{label}}',
edit: 'Edit {{label}}',
label: 'Inline Blocks',
remove: 'Remove {{label}}',
},
label: 'Blocks',
},
es: {
inlineBlocks: {
create: 'Crear {{etiqueta}}',
edit: 'Editar {{etiqueta}}',
label: 'Bloques en línea',
remove: 'Eliminar {{label}}',
},
label: 'Bloques',
},
fa: {
inlineBlocks: {
create: 'ایجاد {{برچسب}}',
edit: 'ویرایش {{برچسب}}',
label: 'بلوک‌های درون خطی',
remove: 'حذف {{برچسب}}',
},
label: 'بلوک ها',
},
fr: {
inlineBlocks: {
create: 'Créer {{label}}',
edit: 'Modifier {{label}}',
label: 'Blocs en ligne',
remove: 'Supprimer {{label}}',
},
label: 'Blocs',
},
he: {
inlineBlocks: {
create: 'צור {{תווית}}',
edit: 'ערוך {{תווית}}',
label: 'בלוקים משורשרים',
remove: 'הסר {{תווית}}',
},
label: 'חסימות',
},
hr: {
inlineBlocks: {
create: 'Stvori {{oznaka}}',
edit: 'Uredi {{label}}',
label: 'Unutrašnji blokovi',
remove: 'Ukloni {{oznaka}}',
},
label: 'Blokovi',
},
hu: {
inlineBlocks: {
create: 'Hozzon létre {{címke}}',
edit: 'Szerkesztés {{címke}}',
label: 'Beágyazott blokkok',
remove: 'Távolítsa el a {{label}}',
},
label: 'Blokkok',
},
it: {
inlineBlocks: {
create: 'Crea {{etichetta}}',
edit: 'Modifica {{label}}',
label: 'Blocchi in linea',
remove: 'Rimuovi {{label}}',
},
label: 'Blocchi',
},
ja: {
inlineBlocks: {
create: '{{label}}を作成する',
edit: '{{label}}を編集する',
label: 'インラインブロック',
remove: '{{ラベル}}を削除します',
},
label: 'ブロック',
},
ko: {
inlineBlocks: {
create: '{{label}} 생성하기',
edit: '{{label}} 수정하기',
label: '인라인 블록',
remove: '{{label}} 제거하세요',
},
label: '블록',
},
my: {
inlineBlocks: {
create: 'Cipta {{label}}',
edit: 'Sunting {{label}}',
label: 'Inline Blocks [SKIPPED]',
remove: 'Buang {{label}}',
},
label: 'တံတားများ',
},
nb: {
inlineBlocks: {
create: 'Opprett {{label}}',
edit: 'Rediger {{label}}',
label: 'In-line blokker',
remove: 'Fjern {{label}}',
},
label: 'Blokker',
},
nl: {
inlineBlocks: {
create: 'Maak {{label}}',
edit: 'Bewerk {{label}}',
label: 'Inline Blocks',
remove: 'Verwijder {{label}}',
},
label: 'Blokken',
},
pl: {
inlineBlocks: {
create: 'Utwórz {{label}}',
edit: 'Edytuj {{etykieta}}',
label: 'Blokowanie w linii',
remove: 'Usuń {{etykieta}}',
},
label: 'Bloki',
},
pt: {
inlineBlocks: {
create: 'Crie {{label}}',
edit: 'Editar {{label}}',
label: 'Blocos em linha',
remove: 'Remova {{label}}',
},
label: 'Blocos',
},
ro: {
inlineBlocks: {
create: 'Creează {{eticheta}}',
edit: 'Editați {{eticheta}}',
label: 'Blocuri in linie',
remove: 'Ștergeți {{etichetă}}',
},
label: 'Blocuri',
},
rs: {
inlineBlocks: {
create: 'Kreiraj {{label}}',
edit: 'Izmeni {{label}}',
label: 'Umetnuti blokovi',
remove: 'Ukloni {{label}}',
},
label: 'Blokovi',
},
'rs-latin': {
inlineBlocks: {
create: 'Kreiraj {{label}}',
edit: 'Izmeni {{label}}',
label: 'Unutar blokovi',
remove: 'Ukloni {{oznaka}}',
},
label: 'Blokovi',
},
ru: {
inlineBlocks: {
create: 'Создать {{label}}',
edit: 'Изменить {{метка}}',
label: 'Встроенные блоки',
remove: 'Удалить {{метка}}',
},
label: 'Блоки',
},
sk: {
inlineBlocks: {
create: 'Vytvorte {{označenie}}',
edit: 'Upraviť {{label}}',
label: 'Inline bloky',
remove: 'Odstráňte {{label}}',
},
label: 'Bloky',
},
sv: {
inlineBlocks: {
create: 'Skapa {{label}}',
edit: 'Redigera {{etikett}}',
label: 'Inline-blockar',
remove: 'Ta bort {{etikett}}',
},
label: 'Block',
},
th: {
inlineBlocks: {
create: 'สร้าง {{label}}',
edit: 'แก้ไข {{label}}',
label: 'บล็อกแบบอินไลน์',
remove: 'ลบ {{label}}',
},
label: 'บล็อค',
},
tr: {
inlineBlocks: {
create: '{{Etiketi}} oluşturun',
edit: "{{Etiket}}'i düzenleyin",
label: 'Satır İçi Bloklar',
remove: '{{Etiketi}} kaldırın',
},
label: 'Bloklar',
},
uk: {
inlineBlocks: {
create: 'Створити {{label}}',
edit: 'Редагувати {{label}}',
label: 'Вбудовані блоки',
remove: 'Видалити {{мітку}}',
},
label: 'Блоки',
},
vi: {
inlineBlocks: {
create: 'Tạo {{label}}',
edit: 'Chỉnh sửa {{nhãn}}',
label: 'Khối nội tuyến',
remove: 'Xóa {{nhãn}}',
},
label: 'Khối',
},
zh: {
inlineBlocks: {
create: '创建{{label}}',
edit: '编辑 {{label}}',
label: '内联块',
remove: '删除{{label}}',
},
label: '块',
},
'zh-TW': {
inlineBlocks: {
create: '創建 {{label}}',
edit: '編輯 {{label}}',
label: '內聯區塊',
remove: '移除 {{label}}',
},
label: '區塊',
},
}

View File

@@ -88,7 +88,6 @@ export class BlockNode extends DecoratorBlockNode {
return false
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
// @ts-expect-error
return <BlockComponent formData={this.getFields()} nodeKey={this.getKey()} />
}

View File

@@ -0,0 +1,139 @@
import type {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
LexicalEditor,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical'
import ObjectID from 'bson-objectid'
import { DecoratorNode } from 'lexical'
import React, { type JSX } from 'react'
export type InlineBlockFields = {
/** Block form data */
[key: string]: any
//blockName: string
blockType: string
id: string
}
const InlineBlockComponent = React.lazy(() =>
import('../componentInline/index.js').then((module) => ({
default: module.InlineBlockComponent,
})),
)
export type SerializedInlineBlockNode = Spread<
{
children?: never // required so that our typed editor state doesn't automatically add children
fields: InlineBlockFields
type: 'inlineBlock'
},
SerializedLexicalNode
>
export class InlineBlockNode extends DecoratorNode<React.ReactElement> {
__fields: InlineBlockFields
constructor({ fields, key }: { fields: InlineBlockFields; key?: NodeKey }) {
super(key)
this.__fields = fields
}
static clone(node: InlineBlockNode): InlineBlockNode {
return new InlineBlockNode({
fields: node.__fields,
key: node.__key,
})
}
static getType(): string {
return 'inlineBlock'
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {}
}
static importJSON(serializedNode: SerializedInlineBlockNode): InlineBlockNode {
const node = $createInlineBlockNode(serializedNode.fields)
return node
}
static isInline(): false {
return false
}
canIndent() {
return true
}
createDOM() {
const element = document.createElement('span')
element.classList.add('inline-block-container')
return element
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
return <InlineBlockComponent formData={this.getFields()} nodeKey={this.getKey()} />
}
exportDOM(): DOMExportOutput {
const element = document.createElement('span')
element.classList.add('inline-block-container')
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
exportJSON(): SerializedInlineBlockNode {
return {
type: 'inlineBlock',
fields: this.getFields(),
version: 1,
}
}
getFields(): InlineBlockFields {
return this.getLatest().__fields
}
getTextContent(): string {
return `Block Field`
}
isInline() {
return true
}
setFields(fields: InlineBlockFields): void {
const fieldsCopy = JSON.parse(JSON.stringify(fields)) as InlineBlockFields
const writable = this.getWritable()
writable.__fields = fieldsCopy
}
updateDOM(): boolean {
return false
}
}
export function $createInlineBlockNode(fields: Exclude<InlineBlockFields, 'id'>): InlineBlockNode {
return new InlineBlockNode({
fields: {
...fields,
id: fields?.id || new ObjectID.default().toHexString(),
},
})
}
export function $isInlineBlockNode(
node: InlineBlockNode | LexicalNode | null | undefined,
): node is InlineBlockNode {
return node instanceof InlineBlockNode
}

View File

@@ -6,3 +6,11 @@ import type { InsertBlockPayload } from './index.js'
export const INSERT_BLOCK_COMMAND: LexicalCommand<InsertBlockPayload> =
createCommand('INSERT_BLOCK_COMMAND')
export const INSERT_INLINE_BLOCK_COMMAND: LexicalCommand<Partial<InsertBlockPayload>> =
createCommand('INSERT_INLINE_BLOCK_COMMAND')
export const OPEN_INLINE_BLOCK_DRAWER_COMMAND: LexicalCommand<{
fields: Partial<InsertBlockPayload>
nodeKey?: string
}> = createCommand('OPEN_INLINE_BLOCK_DRAWER_COMMAND')

View File

@@ -1,27 +1,55 @@
'use client'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
import { $insertNodeToNearestRoot, $wrapNodeInElement, mergeRegister } from '@lexical/utils'
import { getTranslation } from '@payloadcms/translations'
import { useModal, useTranslation } from '@payloadcms/ui'
import {
$createParagraphNode,
$getNodeByKey,
$getPreviousSelection,
$getSelection,
$insertNodes,
$isParagraphNode,
$isRangeSelection,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
type RangeSelection,
} from 'lexical'
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import type { PluginComponent } from '../../typesClient.js'
import type { BlocksFeatureClientProps } from '../feature.client.js'
import type { ClientComponentProps, PluginComponent } from '../../typesClient.js'
import type { BlocksFeatureClientProps, ClientBlock } from '../feature.client.js'
import type { BlockFields } from '../nodes/BlocksNode.js'
import type { InlineBlockNode } from '../nodes/InlineBlocksNode.js'
import { BlocksDrawerComponent } from '../drawer/index.js'
import { useEditorConfigContext } from '../../../lexical/config/client/EditorConfigProvider.js'
import { FieldsDrawer } from '../../../utilities/fieldsDrawer/Drawer.js'
import { $createBlockNode, BlockNode } from '../nodes/BlocksNode.js'
import { INSERT_BLOCK_COMMAND } from './commands.js'
import { $createInlineBlockNode } from '../nodes/InlineBlocksNode.js'
import {
INSERT_BLOCK_COMMAND,
INSERT_INLINE_BLOCK_COMMAND,
OPEN_INLINE_BLOCK_DRAWER_COMMAND,
} from './commands.js'
export type InsertBlockPayload = Exclude<BlockFields, 'id'>
const drawerSlug = 'lexical-inlineBlocks-create'
export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
const [editor] = useLexicalComposerContext()
const { closeModal, toggleModal } = useModal()
const [blockFields, setBlockFields] = useState<BlockFields>(null)
const [blockType, setBlockType] = useState<string>('' as any)
const [targetNodeKey, setTargetNodeKey] = useState<null | string>(null)
const { i18n, t } = useTranslation<string, any>()
const { editorConfig } = useEditorConfigContext()
const reducedBlock: ClientBlock = (
editorConfig?.resolvedFeatureMap?.get('blocks')
?.sanitizedClientFeatureProps as ClientComponentProps<BlocksFeatureClientProps>
)?.reducedInlineBlocks?.find((block) => block.slug === blockFields?.blockType)
useEffect(() => {
if (!editor.hasNodes([BlockNode])) {
@@ -62,8 +90,86 @@ export const BlocksPlugin: PluginComponent<BlocksFeatureClientProps> = () => {
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
editor.registerCommand(
INSERT_INLINE_BLOCK_COMMAND,
(fields) => {
if (targetNodeKey) {
const node: InlineBlockNode = $getNodeByKey(targetNodeKey)
return <BlocksDrawerComponent />
if (!node) {
return false
}
node.setFields(fields as BlockFields)
setTargetNodeKey(null)
return true
}
const inlineBlockNode = $createInlineBlockNode(fields as BlockFields)
$insertNodes([inlineBlockNode])
if ($isRootOrShadowRoot(inlineBlockNode.getParentOrThrow())) {
$wrapNodeInElement(inlineBlockNode, $createParagraphNode).selectEnd()
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
editor.registerCommand(
OPEN_INLINE_BLOCK_DRAWER_COMMAND,
({ fields, nodeKey }) => {
setBlockFields((fields as BlockFields) ?? null)
setTargetNodeKey(nodeKey ?? null)
setBlockType((fields as BlockFields)?.blockType ?? ('' as any))
if (nodeKey) {
toggleModal(drawerSlug)
return true
}
let rangeSelection: RangeSelection | null = null
editor.getEditorState().read(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
rangeSelection = selection
}
})
if (rangeSelection) {
//setLastSelection(rangeSelection)
toggleModal(drawerSlug)
}
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor, targetNodeKey, toggleModal])
const blockDisplayName = reducedBlock?.labels?.singular
? getTranslation(reducedBlock?.labels?.singular, i18n)
: reducedBlock?.slug
return (
<FieldsDrawer
data={blockFields}
drawerSlug={drawerSlug}
drawerTitle={t(`lexical:blocks:inlineBlocks:${blockFields?.id ? 'edit' : 'create'}`, {
label: blockDisplayName ?? t('lexical:blocks:inlineBlocks:label'),
})}
featureKey="blocks"
handleDrawerSubmit={(_fields, data) => {
closeModal(drawerSlug)
if (!data) {
return
}
data.blockType = blockType
editor.dispatchCommand(INSERT_INLINE_BLOCK_COMMAND, data)
}}
schemaPathSuffix={blockFields?.blockType}
/>
)
}

View File

@@ -1,12 +1,14 @@
import type { Block } from 'payload'
import { buildStateFromSchema } from '@payloadcms/ui/forms/buildStateFromSchema'
import type { NodeValidation } from '../typesServer.js'
import type { BlocksFeatureProps } from './feature.server.js'
import type { BlockFields, SerializedBlockNode } from './nodes/BlocksNode.js'
import type { SerializedInlineBlockNode } from './nodes/InlineBlocksNode.js'
export const blockValidationHOC = (
props: BlocksFeatureProps,
): NodeValidation<SerializedBlockNode> => {
blocks: Block[],
): NodeValidation<SerializedBlockNode | SerializedInlineBlockNode> => {
return async ({ node, validation }) => {
const blockFieldData = node.fields ?? ({} as BlockFields)
@@ -15,7 +17,7 @@ export const blockValidationHOC = (
} = validation
// find block
const block = props.blocks.find((block) => block.slug === blockFieldData.blockType)
const block = blocks.find((block) => block.slug === blockFieldData.blockType)
// validate block
if (!block) {

View File

@@ -1,3 +1,5 @@
@import '../../../../scss/styles.scss';
.test-recorder-output {
margin: 20px auto 20px auto;
width: 100%;
@@ -11,7 +13,7 @@
display: block;
font-size: 10px;
padding: 6px 6px;
border-radius: 4px;
border-radius: $style-radius-m;
border: none;
cursor: pointer;
outline: none;

View File

@@ -15,7 +15,7 @@
border: 0;
padding: 2px;
position: relative;
border-radius: 4px;
border-radius: $style-radius-m;
color: var(--theme-elevation-800);
display: inline-block;
cursor: pointer;
@@ -38,7 +38,7 @@ html[data-theme='light'] {
background: var(--color-base-0);
min-width: 160px;
color: var(--color-base-800);
border-radius: 4px;
border-radius: $style-radius-m;
min-height: 40px;
overflow-y: auto;

View File

@@ -77,7 +77,7 @@
&__tableCellEditing {
box-shadow: 0 0 5px rgba(0, 0, 0, 0.4);
border-radius: 3px;
border-radius: $style-radius-m;
}
&__tableAddColumns {

View File

@@ -17,7 +17,7 @@ html[data-theme='light'] {
top: 0;
left: 0;
opacity: 0;
border-radius: 6.25px;
border-radius: $style-radius-m;
transition: opacity 0.2s;
height: 37.5px;
will-change: transform;
@@ -69,7 +69,7 @@ html[data-theme='light'] {
height: 30px;
cursor: pointer;
color: var(--color-base-600);
border-radius: 4px;
border-radius: $style-radius-m;
&:hover:not([disabled]) {
background-color: var(--color-base-100);

View File

@@ -31,7 +31,6 @@ const initialParams = {
}
type Props = {
children?: React.ReactNode
className?: string
data: RelationshipData
format?: ElementFormatType
@@ -40,7 +39,6 @@ type Props = {
const Component: React.FC<Props> = (props) => {
const {
children,
data: { relationTo, value: id },
nodeKey,
} = props
@@ -189,7 +187,6 @@ const Component: React.FC<Props> = (props) => {
)}
{id && <DocumentDrawer onSave={updateRelationship} />}
{children}
</div>
)
}

View File

@@ -8,6 +8,7 @@
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
border-radius: $style-radius-m;
max-width: calc(var(--base) * 15);
font-family: var(--font-body);
margin: 0 0 1.5em;
@@ -16,10 +17,6 @@
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: calc(var(--base) * 0.625) 0;
}
&__label {
margin-bottom: calc(var(--base) * 0.25);
}

View File

@@ -62,6 +62,8 @@ html[data-theme='dark'] {
z-index: 2;
top: var(--doc-controls-height);
border: $style-stroke-width-s solid var(--theme-elevation-150);
// Make it so border itself is round too and not cut off at the corners
border-collapse: unset;
transform: translateY(1px); // aligns with top bar pixel line when stuck
&__group {

View File

@@ -17,7 +17,7 @@ html[data-theme='light'] {
left: 0;
z-index: 2;
opacity: 0;
border-radius: 6.25px;
border-radius: $style-radius-m;
transition: opacity 0.2s;
height: 37.5px;
will-change: transform;

View File

@@ -9,7 +9,7 @@
width: 30px;
border: 0;
background: none;
border-radius: 4px;
border-radius: $style-radius-m;
cursor: pointer;
padding: 0;

View File

@@ -8,7 +8,7 @@
height: 30px;
border: 0;
background: none;
border-radius: 4px;
border-radius: $style-radius-m;
cursor: pointer;
position: relative;
padding: 0 10px;
@@ -63,7 +63,7 @@
&-items {
position: absolute;
background: var(--color-base-0);
border-radius: 4px;
border-radius: $style-radius-m;
min-width: 132.5px;
max-width: 200px;
z-index: 100;
@@ -88,7 +88,7 @@
padding-right: 6.25px;
width: 100%;
height: 30px;
border-radius: 4px;
border-radius: $style-radius-m;
box-sizing: border-box;
display: flex;
align-items: center;

View File

@@ -7,6 +7,7 @@
display: flex;
align-items: center;
background: var(--theme-input-bg);
border-radius: $style-radius-m;
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
@@ -20,10 +21,6 @@
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: calc(var(--base) * 0.625) 0;
}
&__card {
@include soft-shadow-bottom;
display: flex;
@@ -41,6 +38,7 @@
position: relative;
overflow: hidden;
flex-shrink: 0;
border-top-left-radius: $style-radius-m;
img,
svg {

View File

@@ -812,16 +812,17 @@ export { AlignFeature } from './features/align/feature.server.js'
export { BlockquoteFeature } from './features/blockquote/feature.server.js'
export { BlocksFeature, type BlocksFeatureProps } from './features/blocks/feature.server.js'
export { type BlockFields, BlockNode } from './features/blocks/nodes/BlocksNode.js'
export { LinebreakHTMLConverter } from './features/converters/html/converter/converters/linebreak.js'
export { LinebreakHTMLConverter } from './features/converters/html/converter/converters/linebreak.js'
export { ParagraphHTMLConverter } from './features/converters/html/converter/converters/paragraph.js'
export { TextHTMLConverter } from './features/converters/html/converter/converters/text.js'
export { defaultHTMLConverters } from './features/converters/html/converter/defaultConverters.js'
export {
convertLexicalNodesToHTML,
convertLexicalToHTML,
} from './features/converters/html/converter/index.js'
export type { HTMLConverter } from './features/converters/html/converter/types.js'
export {
HTMLConverterFeature,
@@ -833,16 +834,16 @@ export { TreeViewFeature } from './features/debug/treeView/feature.server.js'
export { EXPERIMENTAL_TableFeature } from './features/experimental_table/feature.server.js'
export { BoldFeature } from './features/format/bold/feature.server.js'
export { InlineCodeFeature } from './features/format/inlineCode/feature.server.js'
export { ItalicFeature } from './features/format/italic/feature.server.js'
export { StrikethroughFeature } from './features/format/strikethrough/feature.server.js'
export { SubscriptFeature } from './features/format/subscript/feature.server.js'
export { SuperscriptFeature } from './features/format/superscript/feature.server.js'
export { UnderlineFeature } from './features/format/underline/feature.server.js'
export { HeadingFeature, type HeadingFeatureProps } from './features/heading/feature.server.js'
export { HorizontalRuleFeature } from './features/horizontalRule/feature.server.js'
export { IndentFeature } from './features/indent/feature.server.js'
export { LinkFeature, type LinkFeatureServerProps } from './features/link/feature.server.js'
export { AutoLinkNode } from './features/link/nodes/AutoLinkNode.js'

View File

@@ -10,7 +10,7 @@ html[data-theme='light'] {
background: var(--color-base-0);
width: 200px;
color: var(--color-base-800);
border-radius: 4px;
border-radius: $style-radius-m;
list-style: none;
font-family: var(--font-body);
max-height: 300px;

View File

@@ -1,6 +1,8 @@
@import '../../../../scss/styles.scss';
.add-block-menu {
all: unset; // reset all default button styles
border-radius: 4px;
border-radius: $style-radius-m;
padding: 0;
cursor: pointer;
opacity: 0;

View File

@@ -1,7 +1,7 @@
@import '../../../../scss/styles.scss';
.draggable-block-menu {
border-radius: 4px;
border-radius: $style-radius-m;
padding: 0;
cursor: grab;
opacity: 0;
@@ -50,7 +50,7 @@
pointer-events: none;
background: var(--theme-elevation-200);
//border: 1px solid var(--theme-elevation-650);
border-radius: 4px;
border-radius: $style-radius-m;
height: 50px;
position: absolute;
left: 0;

View File

@@ -220,7 +220,7 @@ function useDraggableBlockMenu(
blockHandleHorizontalOffset +
(editorConfig?.admin?.hideGutter
? menuRef?.current?.getBoundingClientRect()?.width ?? 0
: -menuRef?.current?.getBoundingClientRect()?.width ?? 0),
: -(menuRef?.current?.getBoundingClientRect()?.width ?? 0)),
targetLineElem,
targetBlockElem,
lastTargetBlock,

View File

@@ -1,3 +1,5 @@
@import '../../scss/styles.scss';
.LexicalEditorTheme {
&__ltr {
text-align: left;
@@ -229,17 +231,17 @@
&__listItemUnchecked:focus:before,
&__listItemChecked:focus:before {
box-shadow: 0 0 0 2px #a6cdfe;
border-radius: 2px;
border-radius: $style-radius-m;
}
&__listItemUnchecked:before {
border: 1px solid #999;
border-radius: 2px;
border-radius: $style-radius-m;
}
&__listItemChecked:before {
border: 1px solid rgb(61, 135, 245);
border-radius: 2px;
border-radius: $style-radius-m;
background-color: #3d87f5;
background-repeat: no-repeat;
}

View File

@@ -0,0 +1,21 @@
import React from 'react'
export const InlineBlocksIcon: React.FC = () => (
<svg
aria-hidden="true"
className="icon"
fill="none"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
clipRule="evenodd"
d="M5.33333 6.5C5.11232 6.5 4.90036 6.5878 4.74408 6.74408C4.5878 6.90036 4.5 7.11232 4.5 7.33333V12.1667C4.5 12.3877 4.5878 12.5996 4.74408 12.7559C4.90036 12.9122 5.11232 13 5.33333 13H14.6667C14.8877 13 15.0996 12.9122 15.2559 12.7559C15.4122 12.5996 15.5 12.3877 15.5 12.1667V11.6667C15.5 11.3905 15.7239 11.1667 16 11.1667C16.2761 11.1667 16.5 11.3905 16.5 11.6667V12.1667C16.5 12.6529 16.3068 13.1192 15.963 13.463C15.6192 13.8068 15.1529 14 14.6667 14H5.33333C4.8471 14 4.38079 13.8068 4.03697 13.463C3.69315 13.1192 3.5 12.6529 3.5 12.1667V7.33333C3.5 6.8471 3.69315 6.38079 4.03697 6.03697C4.38079 5.69315 4.8471 5.5 5.33333 5.5H10.3333C10.6095 5.5 10.8333 5.72386 10.8333 6C10.8333 6.27614 10.6095 6.5 10.3333 6.5H5.33333ZM13 6.5C12.7239 6.5 12.5 6.27614 12.5 6C12.5 5.72386 12.7239 5.5 13 5.5H16C16.2761 5.5 16.5 5.72386 16.5 6V9C16.5 9.27614 16.2761 9.5 16 9.5C15.7239 9.5 15.5 9.27614 15.5 9V7.20711L13.3536 9.35355C13.1583 9.54882 12.8417 9.54882 12.6464 9.35355C12.4512 9.15829 12.4512 8.84171 12.6464 8.64645L14.7929 6.5H13ZM6.16699 8.33325C6.16699 8.05711 6.39085 7.83325 6.66699 7.83325H11.0003C11.2765 7.83325 11.5003 8.05711 11.5003 8.33325C11.5003 8.60939 11.2765 8.83325 11.0003 8.83325H6.66699C6.39085 8.83325 6.16699 8.60939 6.16699 8.33325ZM6.16699 10.9999C6.16699 10.7238 6.39085 10.4999 6.66699 10.4999H13.3337C13.6098 10.4999 13.8337 10.7238 13.8337 10.9999C13.8337 11.2761 13.6098 11.4999 13.3337 11.4999H6.66699C6.39085 11.4999 6.16699 11.2761 6.16699 10.9999Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
)

View File

@@ -79,6 +79,8 @@ export const BlockRow: React.FC<BlockFieldProps> = ({
.filter(Boolean)
.join(' ')
const LabelComponent = block?.LabelComponent
return (
<div
id={`${parentPath.split('.').join('-')}-row-${rowIndex}`}
@@ -119,19 +121,23 @@ export const BlockRow: React.FC<BlockFieldProps> = ({
: undefined
}
header={
<div className={`${baseClass}__block-header`}>
<span className={`${baseClass}__block-number`}>
{String(rowIndex + 1).padStart(2, '0')}
</span>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${row.blockType}`}
pillStyle="white"
>
{getTranslation(block.labels.singular, i18n)}
</Pill>
<SectionTitle path={`${path}.blockName`} readOnly={readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div>
LabelComponent ? (
<LabelComponent blockKind={'block'} formData={row} />
) : (
<div className={`${baseClass}__block-header`}>
<span className={`${baseClass}__block-number`}>
{String(rowIndex + 1).padStart(2, '0')}
</span>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${row.blockType}`}
pillStyle="white"
>
{getTranslation(block.labels.singular, i18n)}
</Pill>
<SectionTitle path={`${path}.blockName`} readOnly={readOnly} />
{fieldHasErrors && <ErrorPill count={errorCount} i18n={i18n} withMessage />}
</div>
)
}
isCollapsed={row.collapsed}
key={row.id}

View File

@@ -315,6 +315,7 @@ export const mapFields = (args: {
const reducedBlock: ReducedBlock = {
slug: block.slug,
LabelComponent: block.admin?.components?.Label,
custom: block.admin?.custom,
fieldMap: blockFieldMap,
imageAltText: block.imageAltText,

View File

@@ -1,4 +1,5 @@
import type {
Block,
BlockField,
CellComponentProps,
FieldTypes,
@@ -35,6 +36,7 @@ export type MappedTab = {
}
export type ReducedBlock = {
LabelComponent: Block['admin']['components']['Label']
custom?: Record<any, string>
fieldMap: FieldMap
imageAltText?: string

View File

@@ -0,0 +1,9 @@
'use client'
import type React from 'react'
export const EmbedComponent: React.FC<any> = (props) => {
const { data } = props
return <span>{data.key}</span>
}

View File

@@ -0,0 +1,10 @@
'use client'
import type { Block } from 'payload'
import React from 'react'
export const LabelComponent: Block['admin']['components']['Label'] = (props) => {
const { formData } = props
return <div>{formData?.key}</div>
}

View File

@@ -19,6 +19,7 @@ import {
} from '@payloadcms/richtext-lexical'
import { lexicalFieldsSlug } from '../../slugs.js'
import { LabelComponent } from './LabelComponent.js'
import {
ConditionalLayoutBlock,
RadioButtonsBlock,
@@ -44,14 +45,14 @@ const editorConfig: ServerEditorConfig = {
...defaultFields,
{
name: 'rel',
label: 'Rel Attribute',
type: 'select',
hasMany: true,
options: ['noopener', 'noreferrer', 'nofollow'],
admin: {
description:
'The rel attribute defines the relationship between a linked resource and the current document. This is a custom link field.',
},
hasMany: true,
label: 'Rel Attribute',
options: ['noopener', 'noreferrer', 'nofollow'],
},
],
}),
@@ -81,6 +82,23 @@ const editorConfig: ServerEditorConfig = {
ConditionalLayoutBlock,
TabBlock,
],
inlineBlocks: [
{
slug: 'myInlineBlock',
admin: {
components: {
Label: LabelComponent,
},
},
fields: [
{
name: 'key',
type: 'select',
options: ['value1', 'value2', 'value3'],
},
],
},
],
}),
EXPERIMENTAL_TableFeature(),
],
@@ -88,13 +106,13 @@ const editorConfig: ServerEditorConfig = {
export const LexicalFields: CollectionConfig = {
slug: lexicalFieldsSlug,
admin: {
useAsTitle: 'title',
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
},
access: {
read: () => true,
},
admin: {
listSearchableFields: ['title', 'richTextLexicalCustomFields'],
useAsTitle: 'title',
},
fields: [
{
name: 'title',
@@ -128,13 +146,13 @@ export const LexicalFields: CollectionConfig = {
{
name: 'lexicalWithBlocks',
type: 'richText',
required: true,
editor: lexicalEditor({
admin: {
hideGutter: false,
},
features: editorConfig.features,
}),
required: true,
},
{
name: 'lexicalWithBlocks_markdown',

View File

@@ -37,7 +37,7 @@
],
"paths": {
"@payload-config": [
"./test/_community/config.ts"
"./test/fields/config.ts"
],
"@payloadcms/live-preview": [
"./packages/live-preview/src"