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) => { + ) +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.scss b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.scss new file mode 100644 index 00000000000..d184e6ef1e4 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.scss @@ -0,0 +1,29 @@ +@import '~@payloadcms/ui/scss'; + +.code-block-floating-collapse-button { + all: unset; + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + gap: base(0.5); + + position: absolute; + left: 50%; + transform: translateX(-50%); + height: 24px; + bottom: -12px; + + padding: base(0.2) base(0.4); + border-radius: $style-radius-s; + + background: var(--theme-elevation-150); + color: var(--theme-elevation-600); + + &:hover { + background: var(--theme-elevation-200); + color: var(--theme-elevation-800); + } +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.tsx b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.tsx new file mode 100644 index 00000000000..7da0a894849 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/FloatingCollapse/index.tsx @@ -0,0 +1,24 @@ +import React from 'react' + +import './index.scss' + +const baseClass = 'code-block-floating-collapse-button' +import { useCollapsible, useTranslation } from '@payloadcms/ui' + +import { CollapseIcon } from '../../../../../../lexical/ui/icons/Collapse/index.js' + +export const FloatingCollapse: React.FC = () => { + const { isCollapsed, toggle } = useCollapsible() + const { t } = useTranslation() + + if (!isCollapsed) { + return null + } + + return ( + + ) +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/defaultLanguages.ts b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/defaultLanguages.ts new file mode 100644 index 00000000000..f0460cceb79 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/defaultLanguages.ts @@ -0,0 +1,87 @@ +/** + * Source: https://github.com/microsoft/monaco-editor/tree/main/src/basic-languages + */ +export const defaultLanguages: Record = { + abap: 'ABAP', + apex: 'Apex', + azcli: 'Azure CLI', + bat: 'Batch', + bicep: 'Bicep', + cameligo: 'CameLIGO', + clojure: 'Clojure', + coffee: 'CoffeeScript', + cpp: 'C++', + csharp: 'C#', + csp: 'CSP', + css: 'CSS', + cypher: 'Cypher', + dart: 'Dart', + dockerfile: 'Dockerfile', + ecl: 'ECL', + elixir: 'Elixir', + flow9: 'Flow9', + freemarker2: 'FreeMarker 2', + fsharp: 'F#', + go: 'Go', + graphql: 'GraphQL', + handlebars: 'Handlebars', + hcl: 'HCL', + html: 'HTML', + ini: 'INI', + java: 'Java', + javascript: 'JavaScript', + julia: 'Julia', + kotlin: 'Kotlin', + less: 'Less', + lexon: 'Lexon', + liquid: 'Liquid', + lua: 'Lua', + m3: 'M3', + markdown: 'Markdown', + mdx: 'MDX', + mips: 'MIPS', + msdax: 'DAX', + mysql: 'MySQL', + 'objective-c': 'Objective-C', + pascal: 'Pascal', + pascaligo: 'PascaLIGO', + perl: 'Perl', + pgsql: 'PostgreSQL', + php: 'PHP', + pla: 'PLA', + plaintext: 'Plain Text', + postiats: 'Postiats', + powerquery: 'Power Query', + powershell: 'PowerShell', + protobuf: 'Protobuf', + pug: 'Pug', + python: 'Python', + qsharp: 'Q#', + r: 'R', + razor: 'Razor', + redis: 'Redis', + redshift: 'Amazon Redshift', + restructuredtext: 'reStructuredText', + ruby: 'Ruby', + rust: 'Rust', + sb: 'Small Basic', + scala: 'Scala', + scheme: 'Scheme', + scss: 'SCSS', + shell: 'Shell', + solidity: 'Solidity', + sophia: 'Sophia', + sparql: 'SPARQL', + sql: 'SQL', + st: 'Structured Text', + swift: 'Swift', + systemverilog: 'SystemVerilog', + tcl: 'Tcl', + twig: 'Twig', + typescript: 'TypeScript', + typespec: 'TypeSpec', + vb: 'Visual Basic', + wgsl: 'WGSL', + xml: 'XML', + yaml: 'YAML', +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/index.scss b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/index.scss new file mode 100644 index 00000000000..933b3525fa0 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/Component/index.scss @@ -0,0 +1,97 @@ +@import '~@payloadcms/ui/scss'; + +.payload-richtext-code-block.collapsible--collapsed { + .rah-static { + height: unset !important; + max-height: 150px !important; + position: relative; + + &::after { + content: ''; + pointer-events: none; + background: linear-gradient( + to bottom, + rgb(0 0 0 / 0%) 0%, + var(--theme-elevation-50) 90%, + var(--theme-elevation-50) 100% + ); + position: absolute; + height: 100px; + top: 50px; + left: 0; + right: 0; + } + + > div { + display: unset !important; + } + } +} + +.payload-richtext-code-block { + &__pill { + display: flex; + align-items: center; + justify-content: center; + margin-right: base(0.4); + margin-left: base(0.4); + color: var(--theme-elevation-500); + } + + .collapsible__header-wrap { + overflow: visible; + } + .collapsible__content { + padding: 0; + } + + .lexical-block { + &__block-header { + overflow: visible; + } + } + + .code-editor, + .monaco-editor, + .overflow-guard { + border-width: 0; + border-radius: 0 0 $style-radius-s $style-radius-s; + } + + &__actions { + display: flex; + flex-direction: row; + gap: calc(var(--base) * 0.4); + } + + .popup-button { + padding: 0 0 0 calc(var(--base) * 0.2); + } + + .copy-to-clipboard, + .code-block-collapse-button, + .popup-button { + border-radius: $style-radius-s; + color: var(--theme-elevation-500); + min-width: 24px; + display: flex; + align-items: center; + justify-content: center; + + &:hover { + color: var(--theme-elevation-800); + background-color: var(--theme-elevation-200); + } + } + + &__language-selector { + pointer-events: all; + + &-button { + display: flex; + flex-direction: row; + width: max-content; + align-items: center; + } + } +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converter.ts b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converter.ts new file mode 100644 index 00000000000..9b63412a5ad --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converter.ts @@ -0,0 +1,48 @@ +import type { BlockJSX } from 'payload' + +/** + * @internal + * @experimental - API may change in minor releases + */ +export const codeConverter: BlockJSX = { + customEndRegex: { + optional: true, + regExp: /[ \t]*```$/, + }, + customStartRegex: /^[ \t]*```(\w+)?/, + doNotTrimChildren: true, + export: ({ fields }) => { + const isSingleLine = !fields.code.includes('\n') && !fields.language?.length + if (isSingleLine) { + return '```' + fields.code + '```' + } + + return '```' + (fields.language || '') + (fields.code ? '\n' + fields.code : '') + '\n' + '```' + }, + import: ({ children, closeMatch, openMatch }) => { + const language = openMatch?.[1] + + // Removed first and last \n from children if present + if (children.startsWith('\n')) { + children = children.slice(1) + } + if (children.endsWith('\n')) { + children = children.slice(0, -1) + } + + const isSingleLineAndComplete = + !!closeMatch && !children.includes('\n') && openMatch?.input?.trim() !== '```' + language + + if (isSingleLineAndComplete) { + return { + code: language + (children?.length ? children : ''), // No need to add space to children as they are not trimmed + language: '', + } + } + + return { + code: children, + language, + } + }, +} diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converterClient.ts b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converterClient.ts new file mode 100644 index 00000000000..66943b8f5f2 --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/converterClient.ts @@ -0,0 +1,3 @@ +'use client' + +export { codeConverter as codeConverterClient } from './converter.js' diff --git a/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/index.ts b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/index.ts new file mode 100644 index 00000000000..cf832b814de --- /dev/null +++ b/packages/richtext-lexical/src/features/blocks/premade/CodeBlock/index.ts @@ -0,0 +1,58 @@ +import type { Block } from 'payload' + +import type { AdditionalCodeComponentProps } from './Component/Code.js' + +import { defaultLanguages } from './Component/defaultLanguages.js' +import { codeConverter } from './converter.js' + +/** + * @experimental - this API may change in future, minor releases + */ +export const CodeBlock: (args?: AdditionalCodeComponentProps) => Block = (args) => { + const languages = args?.languages || defaultLanguages + + return { + slug: args?.slug || 'Code', + admin: { + components: { + Block: { + clientProps: { + // If default languages are used, return undefined (=> do not pass `languages` variable) in order to reduce data sent to the client + languages: args?.languages, + }, + path: '@payloadcms/richtext-lexical/client#CodeBlockBlockComponent', + }, + }, + jsx: '@payloadcms/richtext-lexical/client#codeConverterClient', + }, + fields: [ + { + name: 'language', + type: 'select', + admin: { + // We'll manually render this field into the block component header + hidden: true, + }, + defaultValue: args?.defaultLanguage || Object.keys(languages)[0], + options: Object.entries(languages).map(([key, value]) => ({ + label: value, + value: key, + })), + }, + { + name: 'code', + type: 'code', + admin: { + components: { + Field: { + clientProps: args, + path: '@payloadcms/richtext-lexical/client#CodeComponent', + }, + }, + }, + label: '', + }, + ], + jsx: codeConverter, + } +} diff --git a/packages/richtext-lexical/src/features/blocks/server/index.ts b/packages/richtext-lexical/src/features/blocks/server/index.ts index eabc53dc457..0643efe56a1 100644 --- a/packages/richtext-lexical/src/features/blocks/server/index.ts +++ b/packages/richtext-lexical/src/features/blocks/server/index.ts @@ -13,7 +13,7 @@ import { createServerFeature } from '../../../utilities/createServerFeature.js' import { createNode } from '../../typeUtilities.js' import { blockPopulationPromiseHOC } from './graphQLPopulationPromise.js' import { i18n } from './i18n.js' -import { getBlockMarkdownTransformers } from './markdownTransformer.js' +import { getBlockMarkdownTransformers } from './markdown/markdownTransformer.js' import { ServerBlockNode } from './nodes/BlocksNode.js' import { ServerInlineBlockNode } from './nodes/InlineBlocksNode.js' import { blockValidationHOC } from './validate.js' diff --git a/packages/richtext-lexical/src/features/blocks/server/linesFromMatchToContentAndPropsString.ts b/packages/richtext-lexical/src/features/blocks/server/markdown/linesFromMatchToContentAndPropsString.ts similarity index 100% rename from packages/richtext-lexical/src/features/blocks/server/linesFromMatchToContentAndPropsString.ts rename to packages/richtext-lexical/src/features/blocks/server/markdown/linesFromMatchToContentAndPropsString.ts diff --git a/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts b/packages/richtext-lexical/src/features/blocks/server/markdown/markdownTransformer.ts similarity index 84% rename from packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts rename to packages/richtext-lexical/src/features/blocks/server/markdown/markdownTransformer.ts index c57323790d8..bdc5c09573f 100644 --- a/packages/richtext-lexical/src/features/blocks/server/markdownTransformer.ts +++ b/packages/richtext-lexical/src/features/blocks/server/markdown/markdownTransformer.ts @@ -1,28 +1,27 @@ -import type { ElementNode, SerializedEditorState, SerializedLexicalNode } from 'lexical' +import type { ElementNode, SerializedLexicalNode } from 'lexical' import type { Block } from 'payload' -import { createHeadlessEditor } from '@lexical/headless' import { $parseSerializedNode } from 'lexical' -import type { NodeWithHooks } from '../../typesServer.js' +import type { NodeWithHooks } from '../../../typesServer.js' -import { getEnabledNodesFromServerNodes } from '../../../lexical/nodes/index.js' +import { getEnabledNodesFromServerNodes } from '../../../../lexical/nodes/index.js' import { - $convertFromMarkdownString, - $convertToMarkdownString, type MultilineElementTransformer, type TextMatchTransformer, type Transformer, -} from '../../../packages/@lexical/markdown/index.js' -import { extractPropsFromJSXPropsString } from '../../../utilities/jsx/extractPropsFromJSXPropsString.js' -import { propsToJSXString } from '../../../utilities/jsx/jsx.js' -import { linesFromStartToContentAndPropsString } from './linesFromMatchToContentAndPropsString.js' -import { $createServerBlockNode, $isServerBlockNode, ServerBlockNode } from './nodes/BlocksNode.js' +} from '../../../../packages/@lexical/markdown/index.js' +import { extractPropsFromJSXPropsString } from '../../../../utilities/jsx/extractPropsFromJSXPropsString.js' +import { propsToJSXString } from '../../../../utilities/jsx/jsx.js' +import { getLexicalToMarkdown } from '../../client/markdown/getLexicalToMarkdown.js' +import { getMarkdownToLexical } from '../../client/markdown/getMarkdownToLexical.js' +import { $createServerBlockNode, $isServerBlockNode, ServerBlockNode } from '../nodes/BlocksNode.js' import { $createServerInlineBlockNode, $isServerInlineBlockNode, ServerInlineBlockNode, -} from './nodes/InlineBlocksNode.js' +} from '../nodes/InlineBlocksNode.js' +import { linesFromStartToContentAndPropsString } from './linesFromMatchToContentAndPropsString.js' export function createTagRegexes(tagName: string) { const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') @@ -96,6 +95,7 @@ function getMarkdownTransformerForBlock( if (!block.jsx) { return null } + const regex = createTagRegexes(block.slug) const toReturn: Array< (props: { @@ -118,7 +118,12 @@ function getMarkdownTransformerForBlock( } const nodeFields = node.getFields() - const lexicalToMarkdown = getLexicalToMarkdown(allNodes, allTransformers) + const lexicalToMarkdown = getLexicalToMarkdown( + getEnabledNodesFromServerNodes({ + nodes: allNodes, + }), + allTransformers, + ) const exportResult = block.jsx!.export({ fields: nodeFields, @@ -172,7 +177,12 @@ function getMarkdownTransformerForBlock( return } - const markdownToLexical = getMarkdownToLexical(allNodes, allTransformers) + const markdownToLexical = getMarkdownToLexical( + getEnabledNodesFromServerNodes({ + nodes: allNodes, + }), + allTransformers, + ) const blockFields = block.jsx.import({ children: content, @@ -214,7 +224,12 @@ function getMarkdownTransformerForBlock( } const nodeFields = node.getFields() - const lexicalToMarkdown = getLexicalToMarkdown(allNodes, allTransformers) + const lexicalToMarkdown = getLexicalToMarkdown( + getEnabledNodesFromServerNodes({ + nodes: allNodes, + }), + allTransformers, + ) const exportResult = block.jsx!.export({ fields: nodeFields, @@ -327,7 +342,12 @@ function getMarkdownTransformerForBlock( return [false, startLineIndex] } - const markdownToLexical = getMarkdownToLexical(allNodes, allTransformers) + const markdownToLexical = getMarkdownToLexical( + getEnabledNodesFromServerNodes({ + nodes: allNodes, + }), + allTransformers, + ) const blockFields = block.jsx.import({ children: content, @@ -411,7 +431,12 @@ function getMarkdownTransformerForBlock( const propsString = openMatch[1]?.trim() - const markdownToLexical = getMarkdownToLexical(allNodes, allTransformers) + const markdownToLexical = getMarkdownToLexical( + getEnabledNodesFromServerNodes({ + nodes: allNodes, + }), + allTransformers, + ) const blockFields = block.jsx.import({ children: childrenString, @@ -446,53 +471,3 @@ function getMarkdownTransformerForBlock( return toReturn } - -export function getMarkdownToLexical( - allNodes: Array, - allTransformers: Transformer[], -): (args: { markdown: string }) => SerializedEditorState { - const markdownToLexical = ({ markdown }: { markdown: string }): SerializedEditorState => { - const headlessEditor = createHeadlessEditor({ - nodes: getEnabledNodesFromServerNodes({ - nodes: allNodes, - }), - }) - - headlessEditor.update( - () => { - $convertFromMarkdownString(markdown, allTransformers) - }, - { discrete: true }, - ) - - return headlessEditor.getEditorState().toJSON() - } - return markdownToLexical -} - -export function getLexicalToMarkdown( - allNodes: Array, - allTransformers: Transformer[], -): (args: { editorState: Record }) => string { - const lexicalToMarkdown = ({ editorState }: { editorState: Record }): string => { - const headlessEditor = createHeadlessEditor({ - nodes: getEnabledNodesFromServerNodes({ - nodes: allNodes, - }), - }) - - try { - headlessEditor.setEditorState(headlessEditor.parseEditorState(editorState as any)) // This should commit the editor state immediately - } catch (e) { - console.error('getLexicalToMarkdown: ERROR parsing editor state', e) - } - - let markdown: string = '' - headlessEditor.getEditorState().read(() => { - markdown = $convertToMarkdownString(allTransformers) - }) - - return markdown - } - return lexicalToMarkdown -} diff --git a/packages/richtext-lexical/src/features/experimental_table/client/plugins/TableHoverActionsPlugin/index.tsx b/packages/richtext-lexical/src/features/experimental_table/client/plugins/TableHoverActionsPlugin/index.tsx index d0bba97d37f..dcd30e78bf6 100644 --- a/packages/richtext-lexical/src/features/experimental_table/client/plugins/TableHoverActionsPlugin/index.tsx +++ b/packages/richtext-lexical/src/features/experimental_table/client/plugins/TableHoverActionsPlugin/index.tsx @@ -5,6 +5,7 @@ import type { EditorConfig, NodeKey } from 'lexical' import type { JSX } from 'react' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { useLexicalEditable } from '@lexical/react/useLexicalEditable' import { $getTableAndElementByKey, $getTableColumnIndexFromTableCellNode, @@ -33,6 +34,8 @@ function TableHoverActionsContainer({ anchorElem: HTMLElement }): JSX.Element | null { const [editor] = useLexicalComposerContext() + const isEditable = useLexicalEditable() + const editorConfig = useEditorConfigContext() const [isShownRow, setShownRow] = useState(false) const [isShownColumn, setShownColumn] = useState(false) @@ -233,7 +236,7 @@ function TableHoverActionsContainer({ }) } - if (!editor?.isEditable()) { + if (!isEditable) { return null } @@ -293,8 +296,9 @@ export function TableHoverActionsPlugin({ }: { anchorElem?: HTMLElement }): null | React.ReactPortal { - const [editor] = useLexicalComposerContext() - if (!editor?.isEditable()) { + const isEditable = useLexicalEditable() + + if (!isEditable) { return null } diff --git a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx index f205e69e7d3..de91cf7182c 100644 --- a/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx +++ b/packages/richtext-lexical/src/features/link/client/plugins/floatingLinkEditor/LinkEditor/index.tsx @@ -3,6 +3,7 @@ import type { ElementNode, LexicalNode } from 'lexical' import type { Data, FormState } from 'payload' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js' +import { useLexicalEditable } from '@lexical/react/useLexicalEditable' import { $findMatchingParent, mergeRegister } from '@lexical/utils' import { getTranslation } from '@payloadcms/translations' import { @@ -61,6 +62,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R fieldProps: { schemaPath }, uuid, } = useEditorConfigContext() + const isEditable = useLexicalEditable() const { config, getEntityConfig } = useConfig() @@ -353,7 +355,7 @@ export function LinkEditor({ anchorElem }: { anchorElem: HTMLElement }): React.R ) : null} - {editor.isEditable() && ( + {isEditable && ( + {!disableHeaderToggle && ( + + )} + {dragHandleProps && (
= ({ ) : null}
{actions ?
{actions}
: null} -
- -
+ {!disableToggleIndicator && ( +
+ +
+ )}
{children}
+ {AfterCollapsible} ) diff --git a/packages/ui/src/elements/Combobox/index.scss b/packages/ui/src/elements/Combobox/index.scss new file mode 100644 index 00000000000..139cec76a61 --- /dev/null +++ b/packages/ui/src/elements/Combobox/index.scss @@ -0,0 +1,49 @@ +@import '../../scss/styles.scss'; + +@layer payload-default { + .combobox { + &__content { + display: flex; + flex-direction: column; + } + + &__search-wrapper { + padding-top: var(--popup-padding); + padding-bottom: calc(var(--base) * 0.5); + border-bottom: 1px solid var(--theme-elevation-150); + margin-bottom: calc(var(--base) * 0.5); + + &--no-results { + border-bottom: none; + margin-bottom: 0; + } + } + + &__search-input { + width: 100%; + background: var(--theme-elevation-50); + color: var(--theme-text); + border: none; + border-radius: var(--style-radius-s); + padding: calc(var(--base) * 0.25) calc(var(--base) * 0.5); + outline: none; + box-shadow: none; + + &::placeholder { + color: var(--theme-elevation-400); + } + + &:focus, + &:focus-visible { + background: var(--theme-elevation-100); + outline: none; + border: none; + box-shadow: none; + } + } + + &__entry { + cursor: pointer; + } + } +} diff --git a/packages/ui/src/elements/Combobox/index.tsx b/packages/ui/src/elements/Combobox/index.tsx new file mode 100644 index 00000000000..3a2359d372d --- /dev/null +++ b/packages/ui/src/elements/Combobox/index.tsx @@ -0,0 +1,137 @@ +'use client' +import React, { useMemo, useRef, useState } from 'react' + +import type { PopupProps } from '../Popup/index.js' + +import { Popup, PopupList } from '../Popup/index.js' +import './index.scss' + +const baseClass = 'combobox' + +/** + * @internal + * @experimental + */ +export type ComboboxEntry = { + Component: React.ReactNode + name: string +} + +/** + * @internal + * @experimental + */ +export type ComboboxProps = { + entries: ComboboxEntry[] + /** Minimum number of entries required to show search */ + minEntriesForSearch?: number + onSelect?: (entry: ComboboxEntry) => void + searchPlaceholder?: string +} & Omit + +/** + * A wrapper on top of Popup + PopupList.ButtonGroup that adds search functionality. + * + * @internal - this component may be removed or receive breaking changes in minor releases. + * @experimental + */ +export const Combobox: React.FC = (props) => { + const { + entries, + minEntriesForSearch = 8, + onSelect, + onToggleClose, + onToggleOpen, + searchPlaceholder = 'Search...', + ...popupProps + } = props + const [searchValue, setSearchValue] = useState('') + const isOpenRef = useRef(false) + const searchInputRef = useRef(null) + + const filteredEntries = useMemo(() => { + if (!searchValue) { + return entries + } + const search = searchValue.toLowerCase() + return entries.filter((entry) => entry.name.toLowerCase().includes(search)) + }, [entries, searchValue]) + + const showSearch = entries.length >= minEntriesForSearch + const hasResults = filteredEntries.length > 0 + + const handleToggleOpen = React.useCallback( + (active: boolean) => { + isOpenRef.current = active + if (active && showSearch) { + setTimeout(() => { + searchInputRef.current?.focus() + }, 100) + } + onToggleOpen?.(active) + }, + [showSearch, onToggleOpen], + ) + + const handleToggleClose = React.useCallback(() => { + isOpenRef.current = false + setSearchValue('') + onToggleClose?.() + }, [onToggleClose]) + + return ( + ( +
+ {showSearch && ( +
+ setSearchValue(e.target.value)} + placeholder={searchPlaceholder} + ref={searchInputRef} + type="text" + value={searchValue} + /> +
+ )} + + {filteredEntries.map((entry, index) => { + const handleClick = () => { + if (onSelect) { + onSelect(entry) + } + close() + } + + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + handleClick() + } + }} + role="menuitem" + tabIndex={0} + > + {entry.Component} +
+ ) + })} +
+
+ )} + /> + ) +} diff --git a/packages/ui/src/elements/Nav/context.tsx b/packages/ui/src/elements/Nav/context.tsx index de6050acecc..504afc83935 100644 --- a/packages/ui/src/elements/Nav/context.tsx +++ b/packages/ui/src/elements/Nav/context.tsx @@ -13,6 +13,9 @@ type NavContextType = { shouldAnimate: boolean } +/** + * @internal + */ export const NavContext = React.createContext({ hydrated: false, navOpen: true, @@ -33,6 +36,9 @@ const getNavPreference = async (getPreference): Promise => { } } +/** + * @internal + */ export const NavProvider: React.FC<{ children: React.ReactNode initialIsOpen?: boolean diff --git a/packages/ui/src/elements/PageControls/index.tsx b/packages/ui/src/elements/PageControls/index.tsx index a0ea41745ca..91ce2c98c31 100644 --- a/packages/ui/src/elements/PageControls/index.tsx +++ b/packages/ui/src/elements/PageControls/index.tsx @@ -1,3 +1,4 @@ +'use client' import type { ClientCollectionConfig, PaginatedDocs } from 'payload' import { isNumber } from 'payload/shared' @@ -13,6 +14,9 @@ import './index.scss' const baseClass = 'page-controls' +/** + * @internal + */ export const PageControlsComponent: React.FC<{ AfterPageControls?: React.ReactNode collectionConfig: ClientCollectionConfig @@ -65,9 +69,11 @@ export const PageControlsComponent: React.FC<{ ) } -/* +/** * These page controls are controlled by the global ListQuery state. * To override thi behavior, build your own wrapper around PageControlsComponent. + * + * @internal */ export const PageControls: React.FC<{ AfterPageControls?: React.ReactNode diff --git a/packages/ui/src/elements/Popup/PopupButtonList/index.scss b/packages/ui/src/elements/Popup/PopupButtonList/index.scss index a9f696da186..5ca3035a3fa 100644 --- a/packages/ui/src/elements/Popup/PopupButtonList/index.scss +++ b/packages/ui/src/elements/Popup/PopupButtonList/index.scss @@ -7,7 +7,6 @@ display: flex; flex-direction: column; text-align: left; - gap: var(--popup-button-list-gap); [dir='rtl'] &__text-align--left { text-align: right; } @@ -29,14 +28,14 @@ @extend %btn-reset; padding-left: var(--list-button-padding); padding-right: var(--list-button-padding); - padding-top: 2px; - padding-bottom: 2px; + padding-top: calc(2px + var(--popup-button-list-gap) / 2); + padding-bottom: calc(2px + var(--popup-button-list-gap) / 2); cursor: pointer; text-align: inherit; line-height: var(--base); text-decoration: none; border-radius: 3px; - + width: 100%; button { @extend %btn-reset; diff --git a/packages/ui/src/elements/Popup/index.scss b/packages/ui/src/elements/Popup/index.scss index 640174081fc..0d214aaab44 100644 --- a/packages/ui/src/elements/Popup/index.scss +++ b/packages/ui/src/elements/Popup/index.scss @@ -43,6 +43,8 @@ width: calc(100% + var(--scrollbar-width)); padding-top: var(--popup-padding); padding-bottom: var(--popup-padding); + max-height: calc(var(--base) * 10); + overflow-y: auto; } &__scroll-content { diff --git a/packages/ui/src/elements/StickyToolbar/index.tsx b/packages/ui/src/elements/StickyToolbar/index.tsx index 21f9a5ff8b2..ab819d14fb0 100644 --- a/packages/ui/src/elements/StickyToolbar/index.tsx +++ b/packages/ui/src/elements/StickyToolbar/index.tsx @@ -4,6 +4,9 @@ import './index.scss' const baseClass = 'sticky-toolbar' +/** + * @internal + */ export const StickyToolbar: React.FC<{ children: React.ReactNode }> = ({ children }) =>
{children}
diff --git a/packages/ui/src/exports/client/index.ts b/packages/ui/src/exports/client/index.ts index 9f03d4032c3..7959c50321e 100644 --- a/packages/ui/src/exports/client/index.ts +++ b/packages/ui/src/exports/client/index.ts @@ -103,6 +103,9 @@ export type { export { ListSelection } from '../../views/List/ListSelection/index.js' export { CollectionListHeader as ListHeader } from '../../views/List/ListHeader/index.js' export { GroupByHeader } from '../../views/List/GroupByHeader/index.js' +export { PageControls, PageControlsComponent } from '../../elements/PageControls/index.js' +export { StickyToolbar } from '../../elements/StickyToolbar/index.js' + export { GroupByPageControls } from '../../elements/PageControls/GroupByPageControls.js' export { LoadingOverlayToggle } from '../../elements/Loading/index.js' export { FormLoadingOverlayToggle } from '../../elements/Loading/index.js' @@ -110,7 +113,7 @@ export { LoadingOverlay } from '../../elements/Loading/index.js' export { Logout } from '../../elements/Logout/index.js' export { Modal, useModal } from '../../elements/Modal/index.js' export { NavToggler } from '../../elements/Nav/NavToggler/index.js' -export { useNav } from '../../elements/Nav/context.js' +export { NavContext, NavProvider, useNav } from '../../elements/Nav/context.js' export { NavGroup } from '../../elements/NavGroup/index.js' export { Pagination } from '../../elements/Pagination/index.js' export { PerPage } from '../../elements/PerPage/index.js' @@ -118,6 +121,8 @@ export { Pill } from '../../elements/Pill/index.js' import * as PopupList from '../../elements/Popup/PopupButtonList/index.js' export { PopupList } export { Popup } from '../../elements/Popup/index.js' +export { Combobox } from '../../elements/Combobox/index.js' +export type { ComboboxEntry, ComboboxProps } from '../../elements/Combobox/index.js' export { PublishMany } from '../../elements/PublishMany/index.js' export { PublishButton } from '../../elements/PublishButton/index.js' export { SaveButton } from '../../elements/SaveButton/index.js' @@ -341,6 +346,7 @@ export type { UploadHandlersContext } from '../../providers/UploadHandlers/index export { defaultTheme, type Theme, ThemeProvider, useTheme } from '../../providers/Theme/index.js' export { TranslationProvider, useTranslation } from '../../providers/Translation/index.js' export { useWindowInfo, WindowInfoProvider } from '../../providers/WindowInfo/index.js' +export { useControllableState } from '../../hooks/useControllableState.js' export { Text as TextCondition } from '../../elements/WhereBuilder/Condition/Text/index.js' export { Select as SelectCondition } from '../../elements/WhereBuilder/Condition/Select/index.js' diff --git a/packages/ui/src/fields/Code/index.tsx b/packages/ui/src/fields/Code/index.tsx index fd91c2894bb..fb9fa52b833 100644 --- a/packages/ui/src/fields/Code/index.tsx +++ b/packages/ui/src/fields/Code/index.tsx @@ -17,6 +17,7 @@ import './index.scss' const prismToMonacoLanguageMap = { js: 'javascript', ts: 'typescript', + tsx: 'typescript', } const baseClass = 'code-field' @@ -25,7 +26,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { const { field, field: { - admin: { className, description, editorOptions, language = 'javascript' } = {}, + admin: { className, description, editorOptions, editorProps, language = 'javascript' } = {}, label, localized, required, @@ -131,6 +132,7 @@ const CodeFieldComponent: CodeFieldClientComponent = (props) => { wrapperProps={{ id: `field-${path?.replace(/\./g, '__')}`, }} + {...(editorProps || {})} /> {AfterInput} 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';