diff --git a/docs/rich-text/official-features.mdx b/docs/rich-text/official-features.mdx
index bffa4c0b2e4..30971aaff0a 100644
--- a/docs/rich-text/official-features.mdx
+++ b/docs/rich-text/official-features.mdx
@@ -418,6 +418,69 @@ BlocksFeature({
})
```
+#### Code Blocks
+
+Payload exports a premade CodeBlock that you can import and use in your project. It supports syntax highlighting, dynamically selecting the language and loading in external type definitions:
+
+```ts
+import { BlocksFeature, CodeBlock } from '@payloadcms/richtext-lexical'
+
+// ...
+BlocksFeature({
+ blocks: [
+ CodeBlock({
+ defaultLanguage: 'ts',
+ languages: {
+ js: 'JavaScript',
+ plaintext: 'Plain Text',
+ ts: 'TypeScript',
+ },
+ }),
+ ],
+}),
+// ...
+```
+
+When using TypeScript, you can also pass in additional type definitions that will be available in the editor. Here's an example of how to make `payload` and `react` available in the editor:
+
+```ts
+import { BlocksFeature, CodeBlock } from '@payloadcms/richtext-lexical'
+
+// ...
+BlocksFeature({
+ blocks: [
+ CodeBlock({
+ slug: 'PayloadCode',
+ languages: {
+ ts: 'TypeScript',
+ },
+ typescript: {
+ fetchTypes: [
+ {
+ // The index.bundled.d.ts contains all the types for Payload in one file, so that Monaco doesn't need to fetch multiple files.
+ // This file may be removed in the future and is not guaranteed to be available in future versions of Payload.
+ url: 'https://unpkg.com/payload@3.59.0-internal.8435f3c/dist/index.bundled.d.ts',
+ filePath: 'file:///node_modules/payload/index.d.ts',
+ },
+ {
+ url: 'https://unpkg.com/@types/react@19.1.17/index.d.ts',
+ filePath: 'file:///node_modules/@types/react/index.d.ts',
+ },
+ ],
+ paths: {
+ payload: ['file:///node_modules/payload/index.d.ts'],
+ react: ['file:///node_modules/@types/react/index.d.ts'],
+ },
+ typeRoots: ['node_modules/@types', 'node_modules/payload'],
+ // Enable type checking. By default, only syntax checking is enabled.
+ enableSemanticValidation: true,
+ },
+ }),
+ ],
+}),
+// ...
+```
+
### TreeViewFeature
- Description: Provides a debug panel below the editor showing the editor's internal state, DOM tree, and time travel debugging.
diff --git a/packages/payload/src/fields/config/types.ts b/packages/payload/src/fields/config/types.ts
index 38855e637a2..4828e34a794 100644
--- a/packages/payload/src/fields/config/types.ts
+++ b/packages/payload/src/fields/config/types.ts
@@ -1061,6 +1061,7 @@ export type CodeField = {
Label?: CustomComponent
} & Admin['components']
editorOptions?: EditorProps['options']
+ editorProps?: Partial
language?: string
} & Admin
maxLength?: number
@@ -1070,8 +1071,9 @@ export type CodeField = {
} & Omit
export type CodeFieldClient = {
- // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
- admin?: AdminClient & Pick
+ admin?: AdminClient &
+ // @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
+ Partial>
} & Omit &
Pick
diff --git a/packages/richtext-lexical/src/exports/client/index.ts b/packages/richtext-lexical/src/exports/client/index.ts
index b450e3e68ad..e095eec242d 100644
--- a/packages/richtext-lexical/src/exports/client/index.ts
+++ b/packages/richtext-lexical/src/exports/client/index.ts
@@ -150,6 +150,9 @@ export { BlockEditButton } from '../../features/blocks/client/component/componen
export { BlockRemoveButton } from '../../features/blocks/client/component/components/BlockRemoveButton.js'
export { useBlockComponentContext } from '../../features/blocks/client/component/BlockContent.js'
export { getRestPopulateFn } from '../../features/converters/utilities/restPopulateFn.js'
+export { codeConverterClient } from '../../features/blocks/premade/CodeBlock/converterClient.js'
+export { CodeComponent } from '../../features/blocks/premade/CodeBlock/Component/Code.js'
+export { CodeBlockBlockComponent } from '../../features/blocks/premade/CodeBlock/Component/Block.js'
export { RenderLexical } from '../../field/RenderLexical/index.js'
export { buildEditorState } from '../../utilities/buildEditorState.js'
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx b/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
index 5f25764d1eb..ae2bbf26b41 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/BlockContent.tsx
@@ -1,23 +1,63 @@
'use client'
+import type { CollapsibleProps } from '@payloadcms/ui/elements/Collapsible'
import type { ClientField, FormState } from 'payload'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { RenderFields, useFormSubmitted } from '@payloadcms/ui'
import React, { createContext, useMemo } from 'react'
-type Props = {
+export type BlockCollapsibleProps = {
+ /**
+ * Replace the top-right portion of the header that renders the Edit and Remove buttons with custom content.
+ * If this property is provided, the `removeButton` and `editButton` properties are ignored.
+ */
+ Actions?: React.ReactNode
+ children?: React.ReactNode
+ /**
+ * Additional className to the collapsible wrapper
+ */
+ className?: string
+ /**
+ * Props to pass to the underlying Collapsible component. You could use this to override the `Header` entirely, for example.
+ */
+ collapsibleProps?: Partial
+ /**
+ * Whether to disable rendering the block name field in the header Label
+ * @default false
+ */
+ disableBlockName?: boolean
+ /**
+ * Whether to show the Edit button
+ * If `Actions` is provided, this property is ignored.
+ * @default true
+ */
+ editButton?: boolean
+ /**
+ * Replace the default Label component with a custom Label
+ */
+ Label?: React.ReactNode
+ /**
+ * Replace the default Pill component component that's rendered within the default Label component with a custom Pill.
+ * This property has no effect if you provide a custom Label component via the `Label` property.
+ */
+ Pill?: React.ReactNode
+ /**
+ * Whether to show the Remove button
+ * If `Actions` is provided, this property is ignored.
+ * @default true
+ */
+ removeButton?: boolean
+}
+
+export type BlockCollapsibleWithErrorProps = {
+ errorCount?: number
+ fieldHasErrors?: boolean
+} & BlockCollapsibleProps
+
+export type BlockContentProps = {
baseClass: string
BlockDrawer: React.FC
- Collapsible: React.FC<{
- children?: React.ReactNode
- editButton?: boolean
- errorCount?: number
- fieldHasErrors?: boolean
- /**
- * Override the default label with a custom label
- */
- Label?: React.ReactNode
- removeButton?: boolean
- }>
+ Collapsible: React.FC
CustomBlock: React.ReactNode
EditButton: React.FC
errorCount: number
@@ -29,24 +69,20 @@ type Props = {
}
type BlockComponentContextType = {
- BlockCollapsible?: React.FC<{
- children?: React.ReactNode
- editButton?: boolean
- /**
- * Override the default label with a custom label
- */
- Label?: React.ReactNode
- removeButton?: boolean
- }>
- EditButton?: React.FC
- initialState: false | FormState | undefined
-
- nodeKey?: string
- RemoveButton?: React.FC
-}
+ BlockCollapsible: React.FC
+} & Omit
const BlockComponentContext = createContext({
+ baseClass: 'lexical-block',
+ BlockCollapsible: () => null,
+ BlockDrawer: () => null,
+ CustomBlock: null,
+ EditButton: () => null,
+ errorCount: 0,
+ formSchema: [],
initialState: false,
+ nodeKey: '',
+ RemoveButton: () => null,
})
export const useBlockComponentContext = () => React.use(BlockComponentContext)
@@ -56,56 +92,33 @@ export const useBlockComponentContext = () => React.use(BlockComponentContext)
* scoped to the block. All format operations in here are thus scoped to the block's form, and
* not the whole document.
*/
-export const BlockContent: React.FC = (props) => {
- const {
- BlockDrawer,
- Collapsible,
- CustomBlock,
- EditButton,
- errorCount,
- formSchema,
- initialState,
- nodeKey,
- RemoveButton,
- } = props
+export const BlockContent: React.FC = (props) => {
+ const { Collapsible, ...contextProps } = props
+
+ const { BlockDrawer, CustomBlock, errorCount, formSchema } = contextProps
const hasSubmitted = useFormSubmitted()
const fieldHasErrors = hasSubmitted && errorCount > 0
+ const isEditable = useLexicalEditable()
const CollapsibleWithErrorProps = useMemo(
- () =>
- (props: {
- children?: React.ReactNode
- editButton?: boolean
-
- /**
- * Override the default label with a custom label
- */
- Label?: React.ReactNode
- removeButton?: boolean
- }) => (
-
- {props.children}
+ () => (props: BlockCollapsibleProps) => {
+ const { children, ...rest } = props
+ return (
+
+ {children}
- ),
+ )
+ },
[Collapsible, fieldHasErrors, errorCount],
)
return CustomBlock ? (
{CustomBlock}
@@ -120,6 +133,7 @@ export const BlockContent: React.FC = (props) => {
parentPath={''}
parentSchemaPath=""
permissions={true}
+ readOnly={!isEditable}
/>
)
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/components/BlockCollapsible.tsx b/packages/richtext-lexical/src/features/blocks/client/component/components/BlockCollapsible.tsx
index e381b4aba34..41e45756961 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/components/BlockCollapsible.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/components/BlockCollapsible.tsx
@@ -1,23 +1,11 @@
'use client'
import React from 'react'
-import { useBlockComponentContext } from '../BlockContent.js'
+import { type BlockCollapsibleProps, useBlockComponentContext } from '../BlockContent.js'
-export const BlockCollapsible: React.FC<{
- children?: React.ReactNode
- editButton?: boolean
-
- /**
- * Override the default label with a custom label
- */
- Label?: React.ReactNode
- removeButton?: boolean
-}> = ({ children, editButton, Label, removeButton }) => {
+export const BlockCollapsible: React.FC = (props) => {
+ const { children, ...rest } = props
const { BlockCollapsible } = useBlockComponentContext()
- return BlockCollapsible ? (
-
- {children}
-
- ) : null
+ return BlockCollapsible ? {children} : null
}
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.scss b/packages/richtext-lexical/src/features/blocks/client/component/index.scss
index ac2f9aad6cd..490c14d8f10 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/index.scss
+++ b/packages/richtext-lexical/src/features/blocks/client/component/index.scss
@@ -98,6 +98,8 @@
&__editButton.btn {
margin: 0;
+ width: 24px;
+
&:hover {
background-color: var(--theme-elevation-200);
}
diff --git a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
index 9ad11bcd9ce..f6ca5f4f43b 100644
--- a/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
+++ b/packages/richtext-lexical/src/features/blocks/client/component/index.tsx
@@ -27,6 +27,7 @@ import React, { useCallback, useEffect, useMemo, useRef } from 'react'
const baseClass = 'lexical-block'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { getTranslation } from '@payloadcms/translations'
import { $getNodeByKey } from 'lexical'
import {
@@ -40,10 +41,10 @@ import { v4 as uuid } from 'uuid'
import type { BlockFields } from '../../server/nodes/BlocksNode.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
-import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import './index.scss'
+import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import { $isBlockNode } from '../nodes/BlocksNode.js'
-import { BlockContent } from './BlockContent.js'
+import { type BlockCollapsibleWithErrorProps, BlockContent } from './BlockContent.js'
import { removeEmptyArrayValues } from './removeEmptyArrayValues.js'
type Props = {
@@ -66,8 +67,6 @@ export const BlockComponent: React.FC = (props) => {
featureClientSchemaMap,
field: parentLexicalRichTextField,
initialLexicalFormState,
- permissions,
- readOnly,
schemaPath,
},
uuid: uuidFromContext,
@@ -91,9 +90,12 @@ export const BlockComponent: React.FC = (props) => {
// is important to consider for the data path used in setDocFieldPreferences
const { getDocPreferences, setDocFieldPreferences } = useDocumentInfo()
const [editor] = useLexicalComposerContext()
+ const isEditable = useLexicalEditable()
+
+ const blockType = formData.blockType
const { getFormState } = useServerFunctions()
- const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}.fields`
+ const schemaFieldsPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${blockType}.fields`
const [initialState, setInitialState] = React.useState(() => {
return initialLexicalFormState?.[formData.id]?.formState
@@ -124,12 +126,12 @@ export const BlockComponent: React.FC = (props) => {
const [CustomLabel, setCustomLabel] = React.useState(
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
- initialState?.['_components']?.customComponents?.BlockLabel,
+ initialState?.['_components']?.customComponents?.BlockLabel ?? undefined,
)
const [CustomBlock, setCustomBlock] = React.useState(
// @ts-expect-error - vestiges of when tsconfig was not strict. Feel free to improve
- initialState?.['_components']?.customComponents?.Block,
+ initialState?.['_components']?.customComponents?.Block ?? undefined,
)
// Initial state for newly created blocks
@@ -152,6 +154,7 @@ export const BlockComponent: React.FC = (props) => {
globalSlug,
initialBlockData: formData,
operation: 'update',
+ readOnly: !isEditable,
renderAllFields: true,
schemaPath: schemaFieldsPath,
signal: abortController.signal,
@@ -175,15 +178,15 @@ export const BlockComponent: React.FC = (props) => {
const node = $getNodeByKey(nodeKey)
if (node && $isBlockNode(node)) {
const newData = newFormStateData
- newData.blockType = formData.blockType
+ newData.blockType = blockType
node.setFields(newData, true)
}
})
setInitialState(state)
- setCustomLabel(state._components?.customComponents?.BlockLabel)
- setCustomBlock(state._components?.customComponents?.Block)
+ setCustomLabel(state._components?.customComponents?.BlockLabel ?? undefined)
+ setCustomBlock(state._components?.customComponents?.Block ?? undefined)
}
}
@@ -197,6 +200,7 @@ export const BlockComponent: React.FC = (props) => {
}, [
getFormState,
schemaFieldsPath,
+ isEditable,
id,
formData,
editor,
@@ -206,13 +210,14 @@ export const BlockComponent: React.FC = (props) => {
globalSlug,
getDocPreferences,
parentDocumentFields,
+ blockType,
])
const [isCollapsed, setIsCollapsed] = React.useState(
initialLexicalFormState?.[formData.id]?.collapsed ?? false,
)
- const componentMapRenderedBlockPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${formData.blockType}`
+ const componentMapRenderedBlockPath = `${schemaPath}.lexical_internal_feature.blocks.lexical_blocks.${blockType}`
const clientSchemaMap = featureClientSchemaMap['blocks']
@@ -247,6 +252,7 @@ export const BlockComponent: React.FC = (props) => {
globalSlug,
initialBlockFormState: prevFormState,
operation: 'update',
+ readOnly: !isEditable,
renderAllFields: submit ? true : false,
schemaPath: schemaFieldsPath,
signal: controller.signal,
@@ -272,15 +278,15 @@ export const BlockComponent: React.FC = (props) => {
const node = $getNodeByKey(nodeKey)
if (node && $isBlockNode(node)) {
const newData = newFormStateData
- newData.blockType = formData.blockType
+ newData.blockType = blockType
node.setFields(newData, true)
}
})
}, 0)
if (submit) {
- setCustomLabel(newFormState._components?.customComponents?.BlockLabel)
- setCustomBlock(newFormState._components?.customComponents?.Block)
+ setCustomLabel(newFormState._components?.customComponents?.BlockLabel ?? undefined)
+ setCustomBlock(newFormState._components?.customComponents?.Block ?? undefined)
let rowErrorCount = 0
for (const formField of Object.values(newFormState)) {
@@ -301,8 +307,9 @@ export const BlockComponent: React.FC = (props) => {
getDocPreferences,
globalSlug,
schemaFieldsPath,
- formData.blockType,
+ blockType,
parentDocumentFields,
+ isEditable,
editor,
nodeKey,
],
@@ -359,7 +366,7 @@ export const BlockComponent: React.FC = (props) => {
- {editor.isEditable() && (
+ {isEditable && (
{
@@ -120,7 +118,7 @@ const Component: React.FC = (props) => {
{
e.preventDefault()
diff --git a/packages/richtext-lexical/src/features/toolbars/fixed/client/Toolbar/index.tsx b/packages/richtext-lexical/src/features/toolbars/fixed/client/Toolbar/index.tsx
index ab16cd5b40a..435f235f740 100644
--- a/packages/richtext-lexical/src/features/toolbars/fixed/client/Toolbar/index.tsx
+++ b/packages/richtext-lexical/src/features/toolbars/fixed/client/Toolbar/index.tsx
@@ -2,6 +2,7 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { useScrollInfo, useThrottledEffect, useTranslation } from '@payloadcms/ui'
import * as React from 'react'
import { useMemo } from 'react'
@@ -14,8 +15,8 @@ import type { FixedToolbarFeatureProps } from '../../server/index.js'
import { useEditorConfigContext } from '../../../../../lexical/config/client/EditorConfigProvider.js'
import { ToolbarButton } from '../../../shared/ToolbarButton/index.js'
-import { ToolbarDropdown } from '../../../shared/ToolbarDropdown/index.js'
import './index.scss'
+import { ToolbarDropdown } from '../../../shared/ToolbarDropdown/index.js'
function ButtonGroupItem({
anchorElem,
@@ -62,6 +63,7 @@ function ToolbarGroupComponent({
const {
fieldProps: { featureClientSchemaMap, schemaPath },
} = useEditorConfigContext()
+
const [dropdownLabel, setDropdownLabel] = React.useState(undefined)
const [DropdownIcon, setDropdownIcon] = React.useState(undefined)
@@ -171,6 +173,7 @@ function FixedToolbar({
parentWithFixedToolbar: EditorConfigContextType | false
}): React.ReactNode {
const currentToolbarRef = React.useRef(null)
+ const isEditable = useLexicalEditable()
const { y } = useScrollInfo()
@@ -239,7 +242,7 @@ function FixedToolbar({
}}
ref={currentToolbarRef}
>
- {editor.isEditable() && (
+ {isEditable && (
{editorConfig?.features &&
editorConfig.features?.toolbarFixed?.groups.map((group, i) => {
@@ -278,6 +281,10 @@ const getParentEditorWithFixedToolbar = (
export const FixedToolbarPlugin: PluginComponent = ({ clientProps }) => {
const [currentEditor] = useLexicalComposerContext()
const editorConfigContext = useEditorConfigContext()
+ const isEditable = useLexicalEditable()
+ if (!isEditable) {
+ return null
+ }
const { editorConfig: currentEditorConfig } = editorConfigContext
diff --git a/packages/richtext-lexical/src/features/toolbars/inline/client/Toolbar/index.tsx b/packages/richtext-lexical/src/features/toolbars/inline/client/Toolbar/index.tsx
index 599ce23a146..836642b8767 100644
--- a/packages/richtext-lexical/src/features/toolbars/inline/client/Toolbar/index.tsx
+++ b/packages/richtext-lexical/src/features/toolbars/inline/client/Toolbar/index.tsx
@@ -2,6 +2,7 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { mergeRegister } from '@lexical/utils'
import {
$getSelection,
@@ -22,8 +23,8 @@ import { useEditorConfigContext } from '../../../../../lexical/config/client/Edi
import { getDOMRangeRect } from '../../../../../lexical/utils/getDOMRangeRect.js'
import { setFloatingElemPosition } from '../../../../../lexical/utils/setFloatingElemPosition.js'
import { ToolbarButton } from '../../../shared/ToolbarButton/index.js'
-import { ToolbarDropdown } from '../../../shared/ToolbarDropdown/index.js'
import './index.scss'
+import { ToolbarDropdown } from '../../../shared/ToolbarDropdown/index.js'
function ButtonGroupItem({
anchorElem,
@@ -318,6 +319,7 @@ function useInlineToolbar(
anchorElem: HTMLElement,
): null | React.ReactElement {
const [isText, setIsText] = useState(false)
+ const isEditable = useLexicalEditable()
const updatePopup = useCallback(() => {
editor.getEditorState().read(() => {
@@ -390,7 +392,7 @@ function useInlineToolbar(
)
}, [editor, updatePopup])
- if (!isText || !editor.isEditable()) {
+ if (!isText || !isEditable) {
return null
}
diff --git a/packages/richtext-lexical/src/features/upload/client/component/index.tsx b/packages/richtext-lexical/src/features/upload/client/component/index.tsx
index 10e3371ca29..0a0433d3aa8 100644
--- a/packages/richtext-lexical/src/features/upload/client/component/index.tsx
+++ b/packages/richtext-lexical/src/features/upload/client/component/index.tsx
@@ -2,6 +2,7 @@
import type { ClientCollectionConfig, Data, FormState, JsonObject } from 'payload'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { getTranslation } from '@payloadcms/translations'
import {
Button,
@@ -26,8 +27,8 @@ import { FieldsDrawer } from '../../../../utilities/fieldsDrawer/Drawer.js'
import { useLexicalDocumentDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDocumentDrawer.js'
import { useLexicalDrawer } from '../../../../utilities/fieldsDrawer/useLexicalDrawer.js'
import { EnabledRelationshipsCondition } from '../../../relationship/client/utils/EnabledRelationshipsCondition.js'
-import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
import './index.scss'
+import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer/commands.js'
const baseClass = 'lexical-upload'
@@ -67,9 +68,9 @@ const Component: React.FC = (props) => {
const {
editorConfig,
- fieldProps: { readOnly, schemaPath },
+ fieldProps: { schemaPath },
} = useEditorConfigContext()
-
+ const isEditable = useLexicalEditable()
const { i18n, t } = useTranslation()
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [relatedCollection] = useState(() =>
@@ -163,14 +164,14 @@ const Component: React.FC = (props) => {
width={data?.width}
/>
- {editor.isEditable() && (
+ {isEditable && (
{hasExtraFields ? (
= (props) => {
{
@@ -199,7 +200,7 @@ const Component: React.FC = (props) => {
{
e.preventDefault()
diff --git a/packages/richtext-lexical/src/field/rscEntry.tsx b/packages/richtext-lexical/src/field/rscEntry.tsx
index b231a8a7061..aa345db94de 100644
--- a/packages/richtext-lexical/src/field/rscEntry.tsx
+++ b/packages/richtext-lexical/src/field/rscEntry.tsx
@@ -35,6 +35,8 @@ export const RscEntryLexicalField: React.FC<
const path = args.path ?? (args.clientField as RichTextFieldClient).name
const schemaPath = args.schemaPath ?? path
+ const disabled = args?.readOnly || field?.admin?.readOnly
+
if (!(args?.clientField as RichTextFieldClient)?.name) {
throw new Error('Initialized lexical RSC field without a field name')
}
@@ -56,6 +58,7 @@ export const RscEntryLexicalField: React.FC<
id: args.id,
clientFieldSchemaMap: args.clientFieldSchemaMap,
collectionSlug: args.collectionSlug,
+ disabled,
documentData: args.data,
field,
fieldSchemaMap: args.fieldSchemaMap,
diff --git a/packages/richtext-lexical/src/index.ts b/packages/richtext-lexical/src/index.ts
index a5ddd3354a9..2ae6e5dd319 100644
--- a/packages/richtext-lexical/src/index.ts
+++ b/packages/richtext-lexical/src/index.ts
@@ -873,7 +873,9 @@ export function lexicalEditor(args?: LexicalEditorProps): LexicalRichTextAdapter
export { AlignFeature } from './features/align/server/index.js'
export { BlockquoteFeature } from './features/blockquote/server/index.js'
+export { CodeBlock } from './features/blocks/premade/CodeBlock/index.js'
export { BlocksFeature, type BlocksFeatureProps } from './features/blocks/server/index.js'
+
export {
$createServerBlockNode,
$isServerBlockNode,
diff --git a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx
index 44a02bbbf32..44d1bb8d473 100644
--- a/packages/richtext-lexical/src/lexical/LexicalEditor.tsx
+++ b/packages/richtext-lexical/src/lexical/LexicalEditor.tsx
@@ -4,6 +4,7 @@ import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary.js'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js'
+import { useLexicalEditable } from '@lexical/react/useLexicalEditable'
import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical'
import * as React from 'react'
import { useEffect, useState } from 'react'
@@ -11,8 +12,8 @@ import { useEffect, useState } from 'react'
import type { LexicalProviderProps } from './LexicalProvider.js'
import { useEditorConfigContext } from './config/client/EditorConfigProvider.js'
-import { EditorPlugin } from './EditorPlugin.js'
import './LexicalEditor.scss'
+import { EditorPlugin } from './EditorPlugin.js'
import { DecoratorPlugin } from './plugins/DecoratorPlugin/index.js'
import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/index.js'
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
@@ -33,6 +34,7 @@ export const LexicalEditor: React.FC<
const { editorConfig, editorContainerRef, isSmallWidthViewport, onChange } = props
const editorConfigContext = useEditorConfigContext()
const [editor] = useLexicalComposerContext()
+ const isEditable = useLexicalEditable()
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
@@ -109,27 +111,29 @@ export const LexicalEditor: React.FC<
ErrorBoundary={LexicalErrorBoundary}
/>
-
+ {isEditable && }
- {
- // Ignore any onChange event triggered by focus only
- if (!tags.has('focus') || tags.size > 1) {
- if (onChange != null) {
- onChange(editorState, editor, tags)
+ {isEditable && (
+ {
+ // Ignore any onChange event triggered by focus only
+ if (!tags.has('focus') || tags.size > 1) {
+ if (onChange != null) {
+ onChange(editorState, editor, tags)
+ }
}
- }
- }}
- />
+ }}
+ />
+ )}
{floatingAnchorElem && (
- {!isSmallWidthViewport && editor.isEditable() && (
+ {!isSmallWidthViewport && isEditable && (
{editorConfig.admin?.hideDraggableBlockElement ? null : (
@@ -154,14 +158,14 @@ export const LexicalEditor: React.FC<
)
}
})}
- {editor.isEditable() && (
+ {isEditable && (
)}
)}
- {editor.isEditable() && (
+ {isEditable && (
{editorConfig?.features?.markdownTransformers?.length > 0 && }
diff --git a/packages/richtext-lexical/src/lexical/ui/icons/CodeBlock/index.tsx b/packages/richtext-lexical/src/lexical/ui/icons/CodeBlock/index.tsx
index 5c5b92925c6..46995455194 100644
--- a/packages/richtext-lexical/src/lexical/ui/icons/CodeBlock/index.tsx
+++ b/packages/richtext-lexical/src/lexical/ui/icons/CodeBlock/index.tsx
@@ -2,17 +2,11 @@
import React from 'react'
export const CodeBlockIcon: React.FC = () => (
-
diff --git a/packages/ui/src/hooks/useControllableState.ts b/packages/ui/src/hooks/useControllableState.ts
index 5d6b9b0e0d8..de81ae1cd2f 100644
--- a/packages/ui/src/hooks/useControllableState.ts
+++ b/packages/ui/src/hooks/useControllableState.ts
@@ -1,8 +1,11 @@
+'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* A hook for managing state that can be controlled by props but also overridden locally.
* Props always take precedence if they change, but local state can override them temporarily.
+ *
+ * @internal - may change or be removed without a major version bump
*/
export function useControllableState
(
propValue: T,
diff --git a/test/lexical/collections/Lexical/blockComponents/BlockComponent.tsx b/test/lexical/collections/Lexical/blockComponents/BlockComponent.tsx
index 92c60b4aab8..8a8edb0a2e1 100644
--- a/test/lexical/collections/Lexical/blockComponents/BlockComponent.tsx
+++ b/test/lexical/collections/Lexical/blockComponents/BlockComponent.tsx
@@ -1,4 +1,6 @@
'use client'
+import type { UIFieldClientComponent } from 'payload'
+
import {
BlockCollapsible,
BlockEditButton,
@@ -7,7 +9,7 @@ import {
import { useFormFields } from '@payloadcms/ui'
import React from 'react'
-export const BlockComponent: React.FC = () => {
+export const BlockComponent: UIFieldClientComponent = () => {
const key = useFormFields(([fields]) => fields.key)
return (
diff --git a/test/lexical/collections/Lexical/blockComponents/BlockComponentRSC.tsx b/test/lexical/collections/Lexical/blockComponents/BlockComponentRSC.tsx
index d139a031e21..673196ff691 100644
--- a/test/lexical/collections/Lexical/blockComponents/BlockComponentRSC.tsx
+++ b/test/lexical/collections/Lexical/blockComponents/BlockComponentRSC.tsx
@@ -1,9 +1,9 @@
-import type { BlocksFieldServerComponent } from 'payload'
+import type { UIFieldServerComponent } from 'payload'
import { BlockCollapsible } from '@payloadcms/richtext-lexical/client'
import React from 'react'
-export const BlockComponentRSC: BlocksFieldServerComponent = (props) => {
+export const BlockComponentRSC: UIFieldServerComponent = (props) => {
const { siblingData } = props
return Data: {siblingData?.key ?? ''}
diff --git a/test/lexical/collections/Lexical/blockComponents/LabelComponent.tsx b/test/lexical/collections/Lexical/blockComponents/LabelComponent.tsx
index 9364e90ce28..92e0d5c2df3 100644
--- a/test/lexical/collections/Lexical/blockComponents/LabelComponent.tsx
+++ b/test/lexical/collections/Lexical/blockComponents/LabelComponent.tsx
@@ -1,9 +1,11 @@
'use client'
+import type { UIFieldClientComponent } from 'payload'
+
import { useFormFields } from '@payloadcms/ui'
import React from 'react'
-export const LabelComponent: React.FC = () => {
+export const LabelComponent: UIFieldClientComponent = () => {
const key = useFormFields(([fields]) => fields.key)
return {(key?.value as string) ?? ''}yaya
diff --git a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
index d956554dfc6..eed01cef0d8 100644
--- a/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
+++ b/test/lexical/collections/_LexicalFullyFeatured/e2e.spec.ts
@@ -43,7 +43,7 @@ describe('Lexical Fully Featured', () => {
await lexical.editor.first().focus()
})
test('prevent extra paragraph when inserting decorator blocks like blocks or upload node', async () => {
- await lexical.slashCommand('block')
+ await lexical.slashCommand('myblock')
await expect(lexical.editor.locator('.lexical-block')).toBeVisible()
await lexical.slashCommand('relationship', true, 'Relationship')
await lexical.drawer.locator('.list-drawer__header').getByText('Create New').click()
@@ -70,7 +70,7 @@ describe('Lexical Fully Featured', () => {
await lexical.editor.first().focus()
await page.keyboard.type('Hello')
await page.keyboard.press('Enter')
- await lexical.slashCommand('block')
+ await lexical.slashCommand('myblock')
await page.locator('#field-someText').first().focus()
await page.keyboard.type('World')
await page.keyboard.press('ControlOrMeta+A')
@@ -151,4 +151,60 @@ describe('Lexical Fully Featured', () => {
await lexical.drawer.getByText('Save changes').click()
await expect(lexical.drawer).toBeHidden()
})
+
+ test('ensure code block can be created using slash commands', async ({ page }) => {
+ await lexical.slashCommand('code')
+ const codeBlock = lexical.editor.locator('.lexical-block-Code')
+ await expect(codeBlock).toHaveCount(1)
+ await expect(codeBlock).toBeVisible()
+
+ await expect(codeBlock.locator('.monaco-editor')).toBeVisible()
+
+ await expect(
+ codeBlock.locator('.payload-richtext-code-block__language-selector-button'),
+ ).toHaveAttribute('data-selected-language', 'abap')
+
+ // Does not contain payload types. However, since this is JavaScript and not TypeScript, there should be no errors.
+ await codeBlock.locator('.monaco-editor .view-line').first().click()
+ await page.keyboard.type("import { APIError } from 'payload'")
+ await expect(codeBlock.locator('.monaco-editor .view-overlays .squiggly-error')).toHaveCount(0)
+ })
+
+ test('ensure code block can be created using client-side markdown shortcuts', async ({
+ page,
+ }) => {
+ await page.keyboard.type('```ts ')
+ const codeBlock = lexical.editor.locator('.lexical-block-Code')
+ await expect(codeBlock).toHaveCount(1)
+ await expect(codeBlock).toBeVisible()
+
+ await expect(codeBlock.locator('.monaco-editor')).toBeVisible()
+ await expect(
+ codeBlock.locator('.payload-richtext-code-block__language-selector-button'),
+ ).toHaveAttribute('data-selected-language', 'ts')
+
+ // Ensure it does not contain payload types
+ await codeBlock.locator('.monaco-editor .view-line').first().click()
+ await page.keyboard.type("import { APIError } from 'payload'")
+ await expect(codeBlock.locator('.monaco-editor .view-overlays .squiggly-error')).toHaveCount(1)
+ })
+
+ test('ensure payload code block can be created using slash commands and it contains payload types', async ({
+ page,
+ }) => {
+ await lexical.slashCommand('payloadcode')
+ const codeBlock = lexical.editor.locator('.lexical-block-PayloadCode')
+ await expect(codeBlock).toHaveCount(1)
+ await expect(codeBlock).toBeVisible()
+
+ await expect(codeBlock.locator('.monaco-editor')).toBeVisible()
+ await expect(
+ codeBlock.locator('.payload-richtext-code-block__language-selector-button'),
+ ).toHaveAttribute('data-selected-language', 'ts')
+
+ // Ensure it contains payload types
+ await codeBlock.locator('.monaco-editor .view-line').first().click()
+ await page.keyboard.type("import { APIError } from 'payload'")
+ await expect(codeBlock.locator('.monaco-editor .view-overlays .squiggly-error')).toHaveCount(0)
+ })
})
diff --git a/test/lexical/collections/_LexicalFullyFeatured/index.ts b/test/lexical/collections/_LexicalFullyFeatured/index.ts
index 6a042e325a6..9805832419f 100644
--- a/test/lexical/collections/_LexicalFullyFeatured/index.ts
+++ b/test/lexical/collections/_LexicalFullyFeatured/index.ts
@@ -2,6 +2,7 @@ import type { CollectionConfig } from 'payload'
import {
BlocksFeature,
+ CodeBlock,
defaultColors,
EXPERIMENTAL_TableFeature,
FixedToolbarFeature,
@@ -37,6 +38,36 @@ export const LexicalFullyFeatured: CollectionConfig = {
}),
BlocksFeature({
blocks: [
+ CodeBlock(),
+ CodeBlock({
+ slug: 'PayloadCode',
+ defaultLanguage: 'ts',
+ languages: {
+ js: 'JavaScript',
+ ts: 'TypeScript',
+ json: 'JSON',
+ plaintext: 'Plain Text',
+ },
+ typescript: {
+ fetchTypes: [
+ {
+ url: 'https://unpkg.com/payload@3.59.0-internal.8435f3c/dist/index.bundled.d.ts',
+ filePath: 'file:///node_modules/payload/index.d.ts',
+ },
+ {
+ url: 'https://unpkg.com/@types/react@19.1.17/index.d.ts',
+ filePath: 'file:///node_modules/@types/react/index.d.ts',
+ },
+ ],
+ paths: {
+ payload: ['file:///node_modules/payload/index.d.ts'],
+ react: ['file:///node_modules/@types/react/index.d.ts'],
+ },
+ typeRoots: ['node_modules/@types', 'node_modules/payload'],
+ enableSemanticValidation: true,
+ },
+ }),
+
{
slug: 'myBlock',
fields: [
diff --git a/test/lexical/payload-types.ts b/test/lexical/payload-types.ts
index fa934ea72a8..07b07b60520 100644
--- a/test/lexical/payload-types.ts
+++ b/test/lexical/payload-types.ts
@@ -220,12 +220,12 @@ export interface LexicalLinkFeature {
* via the `definition` "lexical-heading-feature".
*/
export interface LexicalHeadingFeature {
- id: string;
+ id: number;
richText?: {
root: {
type: string;
children: {
- type: string;
+ type: any;
version: number;
[k: string]: unknown;
}[];
@@ -285,6 +285,9 @@ export interface LexicalField {
};
[k: string]: unknown;
} | null;
+ /**
+ * A simple lexical field
+ */
lexicalSimple?: {
root: {
type: string;
@@ -300,6 +303,9 @@ export interface LexicalField {
};
[k: string]: unknown;
} | null;
+ /**
+ * Should not be rendered
+ */
lexicalWithBlocks: {
root: {
type: string;
@@ -767,9 +773,9 @@ export interface Upload {
* via the `definition` "uploads2".
*/
export interface Uploads2 {
- id: string;
+ id: number;
text?: string | null;
- media?: (string | null) | Upload;
+ media?: (number | null) | Upload;
altText?: string | null;
updatedAt: string;
createdAt: string;
@@ -973,7 +979,7 @@ export interface PayloadLockedDocument {
} | null)
| ({
relationTo: 'lexical-heading-feature';
- value: string | LexicalHeadingFeature;
+ value: number | LexicalHeadingFeature;
} | null)
| ({
relationTo: 'lexical-jsx-converter';
@@ -1021,7 +1027,7 @@ export interface PayloadLockedDocument {
} | null)
| ({
relationTo: 'uploads2';
- value: string | Uploads2;
+ value: number | Uploads2;
} | null)
| ({
relationTo: 'array-fields';