Merge branch 'feat/next-poc' of https://github.com/payloadcms/payload into feat/next-poc

This commit is contained in:
Jarrod Flesch
2024-02-26 09:41:31 -05:00
37 changed files with 1457 additions and 1202 deletions

View File

@@ -78,7 +78,7 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jwt-decode": "3.1.2",
"lexical": "0.12.5",
"lexical": "0.13.1",
"lint-staged": "^14.0.1",
"minimist": "1.2.8",
"mongodb-memory-server": "^9",

View File

@@ -38,9 +38,14 @@ type RichTextAdapterBase<
}) => Map<string, Field[]>
outputSchema?: ({
field,
interfaceNameDefinitions,
isRequired,
}: {
field: RichTextField<Value, AdapterProps, ExtraFieldProperties>
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
populationPromise?: (data: {

View File

@@ -24,6 +24,10 @@ export function generateTypes(config: SanitizedConfig): void {
style: {
singleQuote: true,
},
// Generates code for $defs that aren't referenced by the schema. Reason:
// If a field defines an interfaceName, it should be included in the generated types
// even if it's not used by another type. Reason: the user might want to use it in their own code.
unreachableDefinitions: true,
}).then((compiled) => {
if (config.typescript.declare !== false) {
compiled += `\n\n${declare}`

View File

@@ -10,6 +10,7 @@ export { combineMerge } from '../utilities/combineMerge'
export {
configToJSONSchema,
entityToJSONSchema,
fieldsToJSONSchema,
withNullableJSONSchemaType,
} from '../utilities/configToJSONSchema'

View File

@@ -57,7 +57,7 @@ export const promise = async ({
skipValidation,
}: Args): Promise<void> => {
const passesCondition = field.admin?.condition
? field.admin.condition(data, siblingData, { user: req.user })
? Boolean(field.admin.condition(data, siblingData, { user: req.user }))
: true
let skipValidationFromHere = skipValidation || !passesCondition

View File

@@ -78,9 +78,12 @@ export function withNullableJSONSchemaType(
return fieldTypes
}
function fieldsToJSONSchema(
export function fieldsToJSONSchema(
collectionIDFieldTypes: { [key: string]: 'number' | 'string' },
fields: Field[],
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>,
): {
properties: {
@@ -135,6 +138,7 @@ function fieldsToJSONSchema(
if (field.editor.outputSchema) {
fieldSchema = field.editor.outputSchema({
field,
interfaceNameDefinitions,
isRequired,
})
} else {
@@ -515,8 +519,11 @@ export function configToJSONSchema(
config: SanitizedConfig,
defaultIDType?: 'number' | 'text',
): JSONSchema4 {
// a mutable Map to store custom top-level `interfaceName` types
// a mutable Map to store custom top-level `interfaceName` types. Fields with an `interfaceName` property will be moved to the top-level definitions here
const interfaceNameDefinitions: Map<string, JSONSchema4> = new Map()
// Collections and Globals have to be moved to the top-level definitions as well. Reason: The top-level type will be the `Config` type - we don't want all collection and global
// types to be inlined inside the `Config` type
const entityDefinitions: { [k: string]: JSONSchema4 } = [
...config.globals,
...config.collections,
@@ -528,6 +535,7 @@ export function configToJSONSchema(
return {
additionalProperties: false,
definitions: { ...entityDefinitions, ...Object.fromEntries(interfaceNameDefinitions) },
// These properties here will be very simple, as all the complexity is in the definitions. These are just the properties for the top-level `Config` type
properties: {
collections: generateEntitySchemas(config.collections || []),
globals: generateEntitySchemas(config.globals || []),

View File

@@ -1,6 +1,6 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "0.4.1",
"version": "0.7.0",
"description": "The officially supported Lexical richtext adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",
@@ -19,29 +19,32 @@
},
"dependencies": {
"@faceless-ui/modal": "2.0.1",
"@lexical/headless": "0.12.5",
"@lexical/link": "0.12.5",
"@lexical/list": "0.12.5",
"@lexical/mark": "0.12.5",
"@lexical/markdown": "0.12.5",
"@lexical/react": "0.12.5",
"@lexical/rich-text": "0.12.5",
"@lexical/selection": "0.12.5",
"@lexical/utils": "0.12.5",
"@lexical/headless": "0.13.1",
"@lexical/link": "0.13.1",
"@lexical/list": "0.13.1",
"@lexical/mark": "0.13.1",
"@lexical/markdown": "0.13.1",
"@lexical/react": "0.13.1",
"@lexical/rich-text": "0.13.1",
"@lexical/selection": "0.13.1",
"@lexical/utils": "0.13.1",
"@payloadcms/translations": "workspace:*",
"@payloadcms/ui": "workspace:*",
"bson-objectid": "2.0.4",
"classnames": "^2.3.2",
"deep-equal": "2.2.3",
"lexical": "0.12.5",
"i18next": "22.5.1",
"json-schema": "^0.4.0",
"lexical": "0.13.1",
"lodash": "4.17.21",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.11",
"react-error-boundary": "4.0.12",
"ts-essentials": "7.0.3"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/json-schema": "7.0.15",
"@types/node": "20.6.2",
"@types/react": "18.2.15",
"payload": "workspace:*"

View File

@@ -49,7 +49,7 @@ const RichText: React.FC<FieldProps> = (props) => {
validate: memoizedValidate,
})
const { errorMessage, setValue, showError, value } = fieldType
const { errorMessage, initialValue, setValue, showError, value } = fieldType
const classes = [
baseClass,
@@ -77,6 +77,7 @@ const RichText: React.FC<FieldProps> = (props) => {
<LexicalProvider
editorConfig={editorConfig}
fieldProps={props}
key={JSON.stringify({ initialValue, path })} // makes sure lexical is completely re-rendered when initialValue changes, bypassing the lexical-internal value memoization. That way, external changes to the form will update the editor. More infos in PR description (https://github.com/payloadcms/payload/pull/5010)
onChange={(editorState) => {
let serializedEditorState = editorState.toJSON()

View File

@@ -2,7 +2,7 @@ import type { SerializedQuoteNode } from '@lexical/rich-text'
import { $createQuoteNode, QuoteNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $INTERNAL_isPointSelection, $getSelection } from 'lexical'
import { $getSelection } from 'lexical'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
@@ -31,9 +31,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
onClick: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if ($INTERNAL_isPointSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode())
}
$setBlocksType(selection, () => $createQuoteNode())
})
},
order: 20,
@@ -44,6 +42,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
markdownTransformers: [MarkdownTransformer],
nodes: [
{
type: QuoteNode.getType(),
converters: {
html: {
converter: async ({ converters, node, parent }) => {
@@ -62,7 +61,6 @@ export const BlockQuoteFeature = (): FeatureProvider => {
} as HTMLConverter<SerializedQuoteNode>,
},
node: QuoteNode,
type: QuoteNode.getType(),
},
],
props: null,
@@ -82,9 +80,7 @@ export const BlockQuoteFeature = (): FeatureProvider => {
keywords: ['quote', 'blockquote'],
onSelect: () => {
const selection = $getSelection()
if ($INTERNAL_isPointSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode())
}
$setBlocksType(selection, () => $createQuoteNode())
},
}),
],

View File

@@ -1,8 +1,8 @@
import type { Block } from 'payload/types'
import type { Block, BlockField } from 'payload/types'
import { getTranslation } from '@payloadcms/translations'
import { baseBlockFields } from 'payload/config'
import { formatLabels } from 'payload/utilities'
import { fieldsToJSONSchema, formatLabels } from 'payload/utilities'
import type { FeatureProvider } from '../types'
@@ -32,6 +32,20 @@ export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
return {
feature: () => {
return {
generatedTypes: {
modifyOutputSchema: ({ currentSchema, field, interfaceNameDefinitions }) => {
const blocksField: BlockField = {
name: field?.name + '_lexical_blocks',
blocks: props.blocks,
type: 'blocks',
}
// This is only done so that interfaceNameDefinitions sets those block's interfaceNames.
// we don't actually use the JSON Schema itself in the generated types yet.
fieldsToJSONSchema({}, [blocksField], interfaceNameDefinitions)
return currentSchema
},
},
nodes: [
{
node: BlockNode,

View File

@@ -113,8 +113,8 @@ export class BlockNode extends DecoratorBlockNode {
exportJSON(): SerializedBlockNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
fields: this.getFields(),
version: 2,
}
}
@@ -123,9 +123,6 @@ export class BlockNode extends DecoratorBlockNode {
return this.getLatest().__fields
}
getId(): string {
return this.__id
}
getTextContent(): string {
return `Block Field`
}

View File

@@ -39,8 +39,16 @@ export function BlocksPlugin(): JSX.Element | null {
const { focus } = selection
const focusNode = focus.getNode()
// First, delete currently selected node if it's an empty paragraph
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParent()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
focusNode.remove()
}

View File

@@ -44,9 +44,11 @@ export const blockValidationHOC = (
const fieldValue = 'name' in field ? node.fields[field.name] : null
const passesCondition = field.admin?.condition
? field.admin.condition(fieldValue, node.fields, {
user: req?.user,
})
? Boolean(
field.admin.condition(fieldValue, node.fields, {
user: req?.user,
}),
)
: true
if (!passesCondition) {
continue // Fixes https://github.com/payloadcms/payload/issues/4000

View File

@@ -2,7 +2,7 @@ import type { HeadingTagType, SerializedHeadingNode } from '@lexical/rich-text'
import { $createHeadingNode, HeadingNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $INTERNAL_isPointSelection, $getSelection } from 'lexical'
import { $getSelection } from 'lexical'
import type { HTMLConverter } from '../converters/html/converter/types'
import type { FeatureProvider } from '../types'
@@ -14,9 +14,7 @@ import { MarkdownTransformer } from './markdownTransformer'
const setHeading = (headingSize: HeadingTagType) => {
const selection = $getSelection()
if ($INTERNAL_isPointSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize))
}
$setBlocksType(selection, () => $createHeadingNode(headingSize))
}
type Props = {
@@ -67,6 +65,7 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
markdownTransformers: [MarkdownTransformer(enabledHeadingSizes)],
nodes: [
{
type: HeadingNode.getType(),
converters: {
html: {
converter: async ({ converters, node, parent }) => {
@@ -85,7 +84,6 @@ export const HeadingFeature = (props: Props): FeatureProvider => {
} as HTMLConverter<SerializedHeadingNode>,
},
node: HeadingNode,
type: HeadingNode.getType(),
},
],
props,

View File

@@ -22,6 +22,8 @@ import {
import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
import { $isAutoLinkNode } from './AutoLinkNode'
export type LinkFields = {
// unknown, custom fields:
[key: string]: unknown
@@ -140,8 +142,8 @@ export class LinkNode extends ElementNode {
exportJSON(): SerializedLinkNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
fields: this.getFields(),
version: 2,
}
}

View File

@@ -235,7 +235,8 @@ function handleLinkCreation(
onChange: ChangeHandler,
): void {
let currentNodes = [...nodes]
let text = currentNodes.map((node) => node.getTextContent()).join('')
const initialText = currentNodes.map((node) => node.getTextContent()).join('')
let text = initialText
let match
let invalidMatchEnd = 0
@@ -247,7 +248,7 @@ function handleLinkCreation(
const isValid = isContentAroundIsValid(
invalidMatchEnd + matchStart,
invalidMatchEnd + matchEnd,
text,
initialText,
currentNodes,
)

View File

@@ -35,6 +35,8 @@ import { useEditorConfigContext } from '../../../../../lexical/config/EditorConf
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor'
import { LinkDrawer } from '../../../drawer'
import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode'
import { $createLinkNode } from '../../../nodes/LinkNode'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode'
import { transformExtraFields } from '../utilities'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands'
@@ -78,7 +80,7 @@ export function LinkEditor({
const fields = sanitizeFields({
// TODO: fix this
// @ts-ignore-next-line
config: config,
config,
fields: fieldsUnsanitized,
validRelationships,
})
@@ -89,6 +91,7 @@ export function LinkEditor({
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const [isAutoLink, setIsAutoLink] = useState(false)
const drawerSlug = formatDrawerSlug({
depth: editDepth,
@@ -103,9 +106,10 @@ export function LinkEditor({
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
selectedNodeDomRect = editor.getElementByKey(node.getKey())?.getBoundingClientRect()
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode) as LinkNode
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode)
if (linkParent == null) {
setIsLink(false)
setIsAutoLink(false)
setLinkUrl('')
setLinkLabel('')
return
@@ -129,8 +133,9 @@ export function LinkEditor({
} else {
// internal link
setLinkUrl(
`/admin/collections/${linkParent.getFields()?.doc?.relationTo}/${linkParent.getFields()
?.doc?.value}`,
`/admin/collections/${linkParent.getFields()?.doc?.relationTo}/${
linkParent.getFields()?.doc?.value
}`,
)
const relatedField = config.collections.find(
@@ -159,6 +164,11 @@ export function LinkEditor({
})
setInitialState(state)
setIsLink(true)
if ($isAutoLinkNode(linkParent)) {
setIsAutoLink(true)
} else {
setIsAutoLink(false)
}
}
const editorElem = editorRef.current
@@ -272,6 +282,7 @@ export function LinkEditor({
() => {
if (isLink) {
setIsLink(false)
setIsAutoLink(false)
return true
}
return false
@@ -308,18 +319,20 @@ export function LinkEditor({
tabIndex={0}
type="button"
/>
<button
aria-label="Remove link"
className="link-trash"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
onMouseDown={(event) => {
event.preventDefault()
}}
tabIndex={0}
type="button"
/>
{!isAutoLink && (
<button
aria-label="Remove link"
className="link-trash"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
onMouseDown={(event) => {
event.preventDefault()
}}
tabIndex={0}
type="button"
/>
)}
</React.Fragment>
)}
</div>
@@ -332,6 +345,22 @@ export function LinkEditor({
const newLinkPayload: LinkPayload = data as LinkPayload
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const parent = getSelectedNode(selection).getParent()
if ($isAutoLinkNode(parent)) {
const linkNode = $createLinkNode({
fields: newLinkPayload.fields,
})
parent.replace(linkNode, true)
}
}
})
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
}}
initialState={initialState}

View File

@@ -1,5 +1,5 @@
import { $setBlocksType } from '@lexical/selection'
import { $INTERNAL_isPointSelection, $createParagraphNode, $getSelection } from 'lexical'
import { $createParagraphNode, $getSelection } from 'lexical'
import type { FeatureProvider } from '../types'
@@ -23,9 +23,7 @@ export const ParagraphFeature = (): FeatureProvider => {
onClick: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if ($INTERNAL_isPointSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode())
}
$setBlocksType(selection, () => $createParagraphNode())
})
},
order: 1,
@@ -49,9 +47,7 @@ export const ParagraphFeature = (): FeatureProvider => {
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if ($INTERNAL_isPointSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode())
}
$setBlocksType(selection, () => $createParagraphNode())
})
},
}),

View File

@@ -142,10 +142,6 @@ export class RelationshipNode extends DecoratorBlockNode {
return this.getLatest().__data
}
getId(): string {
return this.__id
}
getTextContent(): string {
return `${this.__data?.relationTo} relation to ${this.__data?.value?.id}`
}

View File

@@ -54,8 +54,16 @@ export function RelationshipPlugin(props?: RelationshipFeatureProps): JSX.Elemen
const { focus } = selection
const focusNode = focus.getNode()
// First, delete currently selected node if it's an empty paragraph
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParent()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
focusNode.remove()
}

View File

@@ -53,8 +53,16 @@ export function UploadPlugin(): JSX.Element | null {
const { focus } = selection
const focusNode = focus.getNode()
// First, delete currently selected node if it's an empty paragraph
if ($isParagraphNode(focusNode) && focusNode.getTextContentSize() === 0) {
// First, delete currently selected node if it's an empty paragraph and if there are sufficient
// paragraph nodes (more than 1) left in the parent node, so that we don't "trap" the user
if (
$isParagraphNode(focusNode) &&
focusNode.getTextContentSize() === 0 &&
focusNode
.getParent()
.getChildren()
.filter((node) => $isParagraphNode(node)).length > 1
) {
focusNode.remove()
}

View File

@@ -56,6 +56,22 @@ export const AlignFeature = (): FeatureProvider => {
order: 3,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: () =>
// @ts-expect-error
import('../../lexical/ui/icons/AlignJustify').then(
(module) => module.AlignJustifyIcon,
),
isActive: () => false,
key: 'align-justify',
label: `Align Justify`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'justify')
},
order: 4,
},
]),
],
},
props: null,

View File

@@ -24,7 +24,12 @@ export const IndentFeature = (): FeatureProvider => {
}
for (const node of selection.getNodes()) {
// If at least one node is indented, this should be active
if (node.__indent > 0 || node.getParent().__indent > 0) {
if (
('__indent' in node && (node.__indent as number) > 0) ||
(node.getParent() &&
'__indent' in node.getParent() &&
node.getParent().__indent > 0)
) {
return true
}
}

View File

@@ -1,4 +1,5 @@
import type { Transformer } from '@lexical/markdown'
import type { JSONSchema4 } from 'json-schema'
import type { Klass, LexicalEditor, LexicalNode, SerializedEditorState } from 'lexical'
import type { SerializedLexicalNode } from 'lexical'
import type { LexicalNodeReplacement } from 'lexical'
@@ -65,6 +66,25 @@ export type Feature = {
floatingSelectToolbar?: {
sections: FloatingToolbarSection[]
}
generatedTypes?: {
modifyOutputSchema: ({
currentSchema,
field,
interfaceNameDefinitions,
isRequired,
}: {
/**
* Current schema which will be modified by this function.
*/
currentSchema: JSONSchema4
field: RichTextField<SerializedEditorState, AdapterProps>
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
}
hooks?: {
afterReadPromise?: ({
field,
@@ -200,6 +220,27 @@ export type SanitizedFeatures = Required<
floatingSelectToolbar: {
sections: FloatingToolbarSection[]
}
generatedTypes: {
modifyOutputSchemas: Array<
({
currentSchema,
field,
interfaceNameDefinitions,
isRequired,
}: {
/**
* Current schema which will be modified by this function.
*/
currentSchema: JSONSchema4
field: RichTextField<SerializedEditorState, AdapterProps>
/**
* Allows you to define new top-level interfaces that can be re-used in the output schema.
*/
interfaceNameDefinitions: Map<string, JSONSchema4>
isRequired: boolean
}) => JSONSchema4
>
}
hooks: {
afterReadPromises: Array<
({

View File

@@ -1,7 +1,6 @@
.rich-text-lexical {
.editor {
position: relative;
z-index: 1;
}
.editor-shell {

View File

@@ -12,6 +12,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
floatingSelectToolbar: {
sections: [],
},
generatedTypes: {
modifyOutputSchemas: [],
},
hooks: {
afterReadPromises: [],
load: [],
@@ -29,6 +32,9 @@ export const sanitizeFeatures = (features: ResolvedFeatureMap): SanitizedFeature
}
features.forEach((feature) => {
if (feature?.generatedTypes?.modifyOutputSchema) {
sanitized.generatedTypes.modifyOutputSchemas.push(feature.generatedTypes.modifyOutputSchema)
}
if (feature.hooks) {
if (feature.hooks.afterReadPromise) {
sanitized.hooks.afterReadPromises = sanitized.hooks.afterReadPromises.concat(

View File

@@ -2,12 +2,8 @@
import type { ParagraphNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getNearestNodeFromDOMNode,
$getNodeByKey,
type LexicalEditor,
type LexicalNode,
} from 'lexical'
import { $createParagraphNode } from 'lexical'
import { $getNodeByKey, type LexicalEditor, type LexicalNode } from 'lexical'
import * as React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
@@ -50,14 +46,13 @@ function getBlockElement(
horizontalOffset = 0,
): {
blockElem: HTMLElement | null
shouldRemove: boolean
blockNode: LexicalNode | null
} {
const anchorElementRect = anchorElem.getBoundingClientRect()
const topLevelNodeKeys = getTopLevelNodeKeys(editor)
let blockElem: HTMLElement | null = null
let blockNode: LexicalNode | null = null
let shouldRemove = false
// Return null if matching block element is the first or last node
editor.getEditorState().read(() => {
@@ -82,7 +77,6 @@ function getBlockElement(
if (blockElem) {
return {
blockElem: null,
shouldRemove,
}
}
}
@@ -118,16 +112,6 @@ function getBlockElement(
blockElem = elem
blockNode = $getNodeByKey(key)
prevIndex = index
// Check if blockNode is an empty text node
if (
!blockNode ||
blockNode.getType() !== 'paragraph' ||
blockNode.getTextContent() !== ''
) {
blockElem = null
shouldRemove = true
}
break
}
@@ -147,8 +131,8 @@ function getBlockElement(
})
return {
blockElem: blockElem,
shouldRemove,
blockElem,
blockNode,
}
}
@@ -160,7 +144,10 @@ function useAddBlockHandle(
const scrollerElem = anchorElem.parentElement
const menuRef = useRef<HTMLButtonElement>(null)
const [emptyBlockElem, setEmptyBlockElem] = useState<HTMLElement | null>(null)
const [hoveredElement, setHoveredElement] = useState<{
elem: HTMLElement
node: LexicalNode
} | null>(null)
useEffect(() => {
function onDocumentMouseMove(event: MouseEvent) {
@@ -185,7 +172,7 @@ function useAddBlockHandle(
pageX < left - horizontalBuffer ||
pageX > right + horizontalBuffer
) {
setEmptyBlockElem(null)
setHoveredElement(null)
return
}
@@ -199,21 +186,24 @@ function useAddBlockHandle(
if (isOnHandleElement(target, ADD_BLOCK_MENU_CLASSNAME)) {
return
}
const { blockElem: _emptyBlockElem, shouldRemove } = getBlockElement(
const { blockElem: _emptyBlockElem, blockNode } = getBlockElement(
anchorElem,
editor,
event,
false,
-distanceFromScrollerElem,
)
if (!_emptyBlockElem && !shouldRemove) {
if (!_emptyBlockElem) {
return
}
setEmptyBlockElem(_emptyBlockElem)
setHoveredElement({
elem: _emptyBlockElem,
node: blockNode,
})
}
// Since the draggableBlockElem is outside the actual editor, we need to listen to the document
// to be able to detect when the mouse is outside the editor and respect a buffer around the
// to be able to detect when the mouse is outside the editor and respect a buffer around
// the scrollerElem to avoid the draggableBlockElem disappearing too early.
document?.addEventListener('mousemove', onDocumentMouseMove)
@@ -223,42 +213,86 @@ function useAddBlockHandle(
}, [scrollerElem, anchorElem, editor])
useEffect(() => {
if (menuRef.current) {
setHandlePosition(emptyBlockElem, menuRef.current, anchorElem, SPACE)
if (menuRef.current && hoveredElement?.node) {
editor.getEditorState().read(() => {
// Check if blockNode is an empty text node
let isEmptyParagraph = true
if (
hoveredElement.node.getType() !== 'paragraph' ||
hoveredElement.node.getTextContent() !== ''
) {
isEmptyParagraph = false
}
setHandlePosition(
hoveredElement?.elem,
menuRef.current,
anchorElem,
isEmptyParagraph ? SPACE : SPACE - 20,
)
})
}
}, [anchorElem, emptyBlockElem])
}, [anchorElem, hoveredElement, editor])
const handleAddClick = useCallback(
(event) => {
if (!emptyBlockElem) {
let hoveredElementToUse = hoveredElement
if (!hoveredElementToUse?.node) {
return
}
let node: ParagraphNode
editor.update(() => {
node = $getNearestNodeFromDOMNode(emptyBlockElem) as ParagraphNode
if (!node || node.getType() !== 'paragraph') {
return
}
editor.focus()
node.select()
/*const ns = $createNodeSelection();
ns.add(node.getKey())
$setSelection(ns)*/
// 1. Update hoveredElement.node to a new paragraph node if the hoveredElement.node is not a paragraph node
editor.update(() => {
// Check if blockNode is an empty text node
let isEmptyParagraph = true
if (
hoveredElementToUse.node.getType() !== 'paragraph' ||
hoveredElementToUse.node.getTextContent() !== ''
) {
isEmptyParagraph = false
}
if (!isEmptyParagraph) {
const newParagraph = $createParagraphNode()
hoveredElementToUse.node.insertAfter(newParagraph)
setTimeout(() => {
hoveredElementToUse = {
elem: editor.getElementByKey(newParagraph.getKey()),
node: newParagraph,
}
setHoveredElement(hoveredElementToUse)
}, 0)
}
})
// Make sure this is called AFTER the editorfocus() event has been processed by the browser
// 2. Focus on the new paragraph node
setTimeout(() => {
editor.update(() => {
editor.focus()
if (
hoveredElementToUse.node &&
'select' in hoveredElementToUse.node &&
typeof hoveredElementToUse.node.select === 'function'
) {
hoveredElementToUse.node.select()
}
})
}, 1)
// Make sure this is called AFTER the focusing has been processed by the browser
// Otherwise, this won't work
setTimeout(() => {
editor.dispatchCommand(ENABLE_SLASH_MENU_COMMAND, {
node: node,
node: hoveredElementToUse.node as ParagraphNode,
})
}, 0)
}, 2)
event.stopPropagation()
event.preventDefault()
},
[editor, emptyBlockElem],
[editor, hoveredElement],
)
return createPortal(

View File

@@ -56,6 +56,7 @@ export const LexicalEditorTheme: EditorThemeClasses = {
inlineImage: 'LexicalEditor__inline-image',
link: 'LexicalEditorTheme__link',
list: {
checklist: 'LexicalEditorTheme__checklist',
listitem: 'LexicalEditorTheme__listItem',
listitemChecked: 'LexicalEditorTheme__listItemChecked',
listitemUnchecked: 'LexicalEditorTheme__listItemUnchecked',

View File

@@ -6,7 +6,6 @@
tab-size: 1;
outline: 0;
padding-top: 8px;
isolation: isolate;
&:focus-visible {
outline: none !important;

View File

@@ -0,0 +1,18 @@
import React from 'react'
export const AlignJustifyIcon: React.FC = () => (
<svg
aria-hidden="true"
className="icon"
fill="none"
focusable="false"
height="20"
viewBox="0 0 20 20"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M2.5 5H17.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M2.5 10H17.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M2.5 15H17.5" stroke="currentColor" strokeWidth="1.5" />
</svg>
)

View File

@@ -1,3 +1,4 @@
import type { JSONSchema4 } from 'json-schema'
import type { SerializedEditorState } from 'lexical'
import type { EditorConfig as LexicalEditorConfig } from 'lexical/LexicalEditor'
import type { RichTextAdapter } from 'payload/types'
@@ -101,8 +102,8 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
// })
// },
// editorConfig: finalSanitizedEditorConfig,
// outputSchema: ({ isRequired }) => {
// return {
// outputSchema: ({ field, interfaceNameDefinitions, isRequired }) => {
// let outputSchema: JSONSchema4 = {
// // This schema matches the SerializedEditorState type so far, that it's possible to cast SerializedEditorState to this schema without any errors.
// // In the future, we should
// // 1) allow recursive children
@@ -158,6 +159,17 @@ export function lexicalEditor(props?: LexicalEditorProps): LexicalRichTextAdapte
// required: ['root'],
// type: withNullableJSONSchemaType('object', isRequired),
// }
// for (const modifyOutputSchema of finalSanitizedEditorConfig.features.generatedTypes
// .modifyOutputSchemas) {
// outputSchema = modifyOutputSchema({
// currentSchema: outputSchema,
// field,
// interfaceNameDefinitions,
// isRequired,
// })
// }
//
// return outputSchema
// },
// populationPromise({
// context,

View File

@@ -299,7 +299,7 @@ const RichText: React.FC<
{Label}
<Slate
editor={editor}
key={JSON.stringify({ initialValue, path })}
key={JSON.stringify({ initialValue, path })} // makes sure slate is completely re-rendered when initialValue changes, bypassing the slate-internal value memoization. That way, external changes to the form will update the editor
onChange={handleChange}
value={valueToRender as any[]}
>

View File

@@ -42,7 +42,7 @@ export const DocumentTab: React.FC<DocumentTabProps & DocumentTabConfig> = (prop
})
}
if (!condition || (condition && condition({ collectionConfig, config, globalConfig }))) {
if (!condition || (condition && Boolean(condition({ collectionConfig, config, globalConfig })))) {
const labelToRender =
typeof label === 'function'
? label({

View File

@@ -49,7 +49,8 @@ export const DocumentTabs: React.FC<{
const { condition } = tabConfig || {}
const meetsCondition =
!condition || (condition && condition({ collectionConfig, config, globalConfig }))
!condition ||
(condition && Boolean(condition({ collectionConfig, config, globalConfig })))
if (meetsCondition) {
return (

View File

@@ -49,7 +49,7 @@ export const iterateFields = async ({
if (!fieldIsPresentationalOnly(field) && !field?.admin?.disabled) {
const passesCondition = Boolean(
(field?.admin?.condition
? field.admin.condition(fullData || {}, initialData || {}, { user })
? Boolean(field.admin.condition(fullData || {}, initialData || {}, { user }))
: true) && parentPassesCondition,
)

2189
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -73,6 +73,7 @@ export const TextBlock: Block = {
}
export const RadioButtonsBlock: Block = {
interfaceName: 'LexicalBlocksRadioButtonsBlock',
fields: [
{
name: 'radioButtons',