Merge branch 'feat/next-poc' of https://github.com/payloadcms/payload into feat/next-poc
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -10,6 +10,7 @@ export { combineMerge } from '../utilities/combineMerge'
|
||||
export {
|
||||
configToJSONSchema,
|
||||
entityToJSONSchema,
|
||||
fieldsToJSONSchema,
|
||||
withNullableJSONSchemaType,
|
||||
} from '../utilities/configToJSONSchema'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 || []),
|
||||
|
||||
@@ -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:*"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<
|
||||
({
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
.rich-text-lexical {
|
||||
.editor {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.editor-shell {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
tab-size: 1;
|
||||
outline: 0;
|
||||
padding-top: 8px;
|
||||
isolation: isolate;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none !important;
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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[]}
|
||||
>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
2189
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -73,6 +73,7 @@ export const TextBlock: Block = {
|
||||
}
|
||||
|
||||
export const RadioButtonsBlock: Block = {
|
||||
interfaceName: 'LexicalBlocksRadioButtonsBlock',
|
||||
fields: [
|
||||
{
|
||||
name: 'radioButtons',
|
||||
|
||||
Reference in New Issue
Block a user