feat: Lexical richtext editor (#3359)

* chore: move slate stuff into packages/richtext-slate

* chore: fieldTypes stuff

* chore: fix richtext-slate tsconfig

* chore: add clean:unix command

* chore: fix up things

* chore: undo subpath imports being hoisted up

* chore: fix incorrect imports

* chore: improve AdapterArguments type

* chore: remove unused richTextToHTML and stringifyRichText files

* fix: core-dev scss imports

* chore: fix publishConfig exports for richtext-slate

* chore: adjust joi schema for richtext field

* chore: various fixes

* chore: handle afterRead population in richText adapter

* chore: handle more after-read promise stuff

* chore: fix joi validation

* chore: add richtext adapter to tests

* chore: merge adapter props with field props

* chore: index.tsx => index.ts

* chore: rename `adapter` to `editor`

* chore: fix e2e tests not running due to importing a constant from a file (`Tabs`) which imports createSlate.

This fails because createSlate imports React components.

* chore: remove unnecessary import

* chore: improve various typings

* chore: improve typings for List view Cell components

* feat: richText adapter cell component

* chore: add missing types packages for packages/richtext-slate

* chore: add new adapter interface properties to joi schema

* chore: withMergedProps utility which replaces getSlateCellComponent and getSlateFieldComponent

* feat: added config.defaultEditor property which is now required. field.editor is no longer required and overrides config.defaultEditor

* docs: mention editor and defaultEditor property in the docs

* chore: fix incorrectly formatted JSX in docs files breaking mdx parser

* feat: create base structure for lexical adapter

* chore: upgrade dependencies

* chore: improve package.json property order

* chore: undo unnecessary changes in core

* chore: initial, working lexical editor

* chore: utils

* chore: add license.md

* chore: work on Floating toolbar

* chore: floatingSelectToolbar extensibility

* chore: FloatingSelectToolbar format buttons

* chore: add functionality to format buttons

* chore: caret handling improvements

* chore: keep toolbar hidden during mousedown

* chore: keep carat centered unless at edge of container

* chore: placeholder, style improvements

* chore: DraggableBlock Handle

* chore: limit cell preview length

* chore: fix drag handle styling in light mode

* chore: remove unnecessary listener

* feat: add block handle

* chore: handle removal of handles properly

* chore: add HistoryPlugin

* chore: improve naming of format button properties

* chore: fix incorrect position of HistoryPlugin

* chore: serif font

* chore: improve opacity handling of FloatingSelectToolbar

* feat: slash menu

* feat: slash menu interface

* chore: fix mixed-up icon

* chore: add missing editorConfig?.feature dependency

* feat: sanitizedEditorConfig, slash menu grouping

* feat: interface for nodes

* feat: Plugin interface

* feat: working AddBlockHandlePlugin

* feat: headings

* chore: improve editor focus handling for add block handle's slash menu

* chore: improve handling of slash menu filtering

* fix: cloneDeep function not handling all data types well

* feat: markdown transformers for headings

* feat: proper dependency system

* feat: add remaining markdown transformers

* feat: order system for floating toolbar format buttons

* chore: customizable floating select toolbar sections system

* feat: WIP floatingSelectToolbar dropdown sections

* feat: working dropdown & misc improvements

* chore: fix dropdown top positioning after scrolling down

* chore: downgrade @types/react

* feat: treeview debug feature, & misc improvements

* feat: add TabIndentationPlugin by default

* fix: wrong key for treeviewfeature

* chore: handle plugin keys

* chore: simplify Heading Feature

* feat: Text Align

* feat: Indent

* chore: floating text toolbar section dividers

* feat: isEnabled for floating select toolbar, and isEnabled handling for decreaseIndent

* chore: add all missing icons

* feat: checklist, orderedlist, unorderedlist

* chore: improve lists by using lexical/react's ListPlugin

* chore: lists markdown transformers

* chore: improve key generation for plugins

* fix: add missing payload scss import

* fix: make default editorConfig null instead of defaultSanitizedEditorConfig for EditorConfigProvider. This fixes a bug where the editor would crash if some feature was using this EditorConfigProvider, as EditorConfigProvider needs the feature (due to the serialization process) and the Feature needing EditorConfigProvider (circular dependency)

* chore: allow positioning plugins under floatingAnchorElem

* feat: WIP: Link Feature

* chore: add box shadow to slash menu in light mode

* chore: CheckList => Check List

* chore: respect admin.readOnly setting

* chore: Simplify Link Feature

* chore: restructuring

* chore: prettier

* chore: scss-ify

* feat: wip: nicer draggable blocks

* chore: lots of block drag improvements

* helllll yea

* chore: just pure niceness

* fix: drag handle not working when scrolling down

* fix: "add" drag handle not working when scrolling down

* chore: remove unnecessary console log

* chore: fix slash menu positioning if there is not enough space below

* chore: increase animation speed of floating select toolbar

* chore: expect transforms for top-level editor nodes

* chore: move css rule to correct position

* chore: slightly animate target drag line

* chore: do not indicate drag-ability in un-dragable positions

* chore: explanatory comment

* chore: link editor styling

* chore: lots of link-related improvements

* chore: a lot of floating toolbar improvements

* chore: adjust link colors to be the same as in the website

* chore: prep work link extensibility

* chore: work on link plugin

* feat: fully-working link feature 🎉

* chore: add upload icon

* chore: merge in useful changes from playground & misc stuff

* feat: WIP relationship feature

* feat: Relationship Feature

* feat: BlockQuote Feature

* chore: base structure for Upload feature

* chore: fix types

* chore: WIP work on population and upload nodes

* chore: stuff

* fix: ensure uuid is only generated once

* chore: remove console.log's

* feat: link and relationship population

* fix: populate relationships at correct position

* chore: bunch of progress on upload node

* chore: working upload node!

* chore: various upload feature improvements

* chore: misc

* feat: working upload feature fields customization

* feat: WIP Blocks feature

* chore: fix incorrectly registered editor commands

* chore: get Block fields to render

* feat: functional blocks feature

* chore: improve functionality of blocks feature component (styling, collapsing)

* chore: disable console logs
This commit is contained in:
Alessio Gravili
2023-10-01 21:03:44 +02:00
committed by GitHub
parent 8f14cd2c59
commit 9308c792e1
209 changed files with 12715 additions and 52 deletions

View File

@@ -40,6 +40,7 @@ export const BlocksDrawer: React.FC<Props> = (props) => {
useEffect(() => {
const searchTermToUse = searchTerm.toLowerCase()
const matchingBlocks = blocks.reduce((matchedBlocks, block) => {
const blockLabel = getBlockLabel(block, i18n)
if (blockLabel.includes(searchTermToUse)) matchedBlocks.push(block)

View File

@@ -9,28 +9,32 @@ import './index.scss'
const baseClass = 'section-title'
const SectionTitle: React.FC<Props> = (props) => {
const { path, readOnly } = props
const { customOnChange, customValue, path, readOnly } = props
const { setValue, value } = useField({ path })
const { t } = useTranslation('general')
const classes = [baseClass].filter(Boolean).join(' ')
const onChange =
customOnChange ||
((e) => {
e.stopPropagation()
e.preventDefault()
setValue(e.target.value)
})
return (
<div className={classes} data-value={value}>
<div className={classes} data-value={customValue || value}>
<input
className={`${baseClass}__input`}
id={path}
name={path}
onChange={(e) => {
e.stopPropagation()
e.preventDefault()
setValue(e.target.value)
}}
onChange={onChange}
placeholder={t('untitled')}
readOnly={readOnly}
type="text"
value={(value as string) || ''}
value={customValue || (value as string) || ''}
/>
</div>
)

View File

@@ -1,4 +1,6 @@
export type Props = {
customOnChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
customValue?: string
path: string
readOnly: boolean
}

View File

@@ -22,10 +22,10 @@ import { useForm, useFormSubmitted } from '../../Form/context'
import { NullifyLocaleField } from '../../NullifyField'
import useField from '../../useField'
import withCondition from '../../withCondition'
import { fieldBaseClass } from '../shared'
import { BlockRow } from './BlockRow'
import { BlocksDrawer } from './BlocksDrawer'
import './index.scss'
import { fieldBaseClass } from '../shared'
const baseClass = 'blocks-field'

View File

@@ -81,6 +81,26 @@
--color-error-900: rgb(51, 22, 24);
--color-error-950: rgb(25, 11, 12);
--color-blue-50: rgb(230, 247, 253);
--color-blue-100: rgb(204, 238, 252);
--color-blue-150: rgb(179, 230, 250);
--color-blue-200: rgb(153, 221, 249);
--color-blue-250: rgb(128, 213, 247);
--color-blue-300: rgb(102, 204, 245);
--color-blue-350: rgb(77, 196, 244);
--color-blue-400: rgb(51, 187, 242);
--color-blue-450: rgb(25, 179, 241);
--color-blue-500: rgb(0, 170, 239);
--color-blue-550: rgb(0, 153, 215);
--color-blue-600: rgb(0, 136, 191);
--color-blue-650: rgb(0, 119, 167);
--color-blue-700: rgb(0, 102, 143);
--color-blue-750: rgb(0, 85, 120);
--color-blue-800: rgb(0, 68, 96);
--color-blue-850: rgb(0, 51, 72);
--color-blue-900: rgb(0, 34, 48);
--color-blue-950: rgb(0, 17, 24);
--theme-border-color: var(--theme-elevation-150);
--theme-success-50: var(--color-success-50);

View File

@@ -1 +1,10 @@
export * from '../auth'
export type {
CollectionPermission,
FieldPermissions,
GlobalPermission,
IncomingAuthType,
Permission,
Permissions,
User,
VerifyConfig,
} from '../auth/types'

View File

@@ -1,6 +1,7 @@
export { default as Banner } from '../admin/components/elements/Banner'
export { default as Button } from '../admin/components/elements/Button'
export { ErrorPill } from '../admin/components/elements/ErrorPill'
export { default as Pill } from '../admin/components/elements/Pill'
export { default as Popup } from '../admin/components/elements/Popup'

View File

@@ -1,5 +1,6 @@
export { default as Button } from '../../admin/components/elements/Button'
export { default as Card } from '../../admin/components/elements/Card'
export { Collapsible } from '../../admin/components/elements/Collapsible'
export {
DocumentDrawer,
DocumentDrawerToggler,
@@ -7,12 +8,13 @@ export {
useDocumentDrawer,
} from '../../admin/components/elements/DocumentDrawer'
export { Drawer, DrawerToggler, formatDrawerSlug } from '../../admin/components/elements/Drawer'
export { useDrawerSlug } from '../../admin/components/elements/Drawer/useDrawerSlug'
export { default as Eyebrow } from '../../admin/components/elements/Eyebrow'
export { Gutter } from '../../admin/components/elements/Gutter'
export { AppHeader } from '../../admin/components/elements/Header'
export {
ListDrawer,
ListDrawerToggler,

View File

@@ -1 +1,8 @@
export { BlockRow } from '../../../admin/components/forms/field-types/Blocks/BlockRow'
export { BlocksDrawer } from '../../../admin/components/forms/field-types/Blocks/BlocksDrawer'
export { default as BlockSearch } from '../../../admin/components/forms/field-types/Blocks/BlocksDrawer/BlockSearch'
export type { Props as BlocksDrawerProps } from '../../../admin/components/forms/field-types/Blocks/BlocksDrawer/types'
export { RowActions } from '../../../admin/components/forms/field-types/Blocks/RowActions'
export { default as SectionTitle } from '../../../admin/components/forms/field-types/Blocks/SectionTitle/index'
export { Props as SectionTitleProps } from '../../../admin/components/forms/field-types/Blocks/SectionTitle/types'
export type { Props } from '../../../admin/components/forms/field-types/Blocks/types'

View File

@@ -4,6 +4,8 @@ export { default as FieldDescription } from '../../admin/components/forms/FieldD
export { default as Form } from '../../admin/components/forms/Form'
export { default as buildInitialState } from '../../admin/components/forms/Form/buildInitialState'
export {
useAllFormFields,
useForm,
@@ -16,28 +18,31 @@ export {
*/
useWatchForm,
} from '../../admin/components/forms/Form/context'
export { createNestedFieldPath } from '../../admin/components/forms/Form/createNestedFieldPath'
export { default as getSiblingData } from '../../admin/components/forms/Form/getSiblingData'
export { default as reduceFieldsToValues } from '../../admin/components/forms/Form/reduceFieldsToValues'
export { default as Label } from '../../admin/components/forms/Label'
export { default as RenderFields } from '../../admin/components/forms/RenderFields'
export { default as Submit } from '../../admin/components/forms/Submit'
export { default as Submit } from '../../admin/components/forms/Submit'
export { default as FormSubmit } from '../../admin/components/forms/Submit'
export { default as Checkbox } from '../../admin/components/forms/field-types/Checkbox'
export { default as Collapsible } from '../../admin/components/forms/field-types/Collapsible'
export { default as Group } from '../../admin/components/forms/field-types/Group'
export { default as HiddenInput } from '../../admin/components/forms/field-types/HiddenInput'
export { default as Select } from '../../admin/components/forms/field-types/Select'
export { default as SelectInput } from '../../admin/components/forms/field-types/Select/Input'
export { default as Text } from '../../admin/components/forms/field-types/Text'
export { default as TextInput } from '../../admin/components/forms/field-types/Text/Input'
/**
* @deprecated This method is now called useField. The useFieldType alias will be removed in an upcoming version.
*/
export { default as useFieldType } from '../../admin/components/forms/useField'
export { default as useField } from '../../admin/components/forms/useField'
export { default as withCondition } from '../../admin/components/forms/withCondition'

View File

@@ -2,6 +2,7 @@ export { buildConfig } from '../config/build'
export * from '../config/types'
export { type FieldTypes, fieldTypes } from '../admin/components/forms/field-types'
export { defaults } from '../config/defaults'
export { sanitizeConfig } from '../config/sanitize'
export { baseBlockFields } from '../fields/baseFields/baseBlockFields'
export { baseIDField } from '../fields/baseFields/baseIDField'

View File

@@ -2,6 +2,7 @@ export * from './../types'
export type {
CreateFormData,
Data,
Fields,
FormField,
FormFieldsContext,

View File

@@ -8,4 +8,5 @@ export { createArrayFromCommaDelineated } from '../utilities/createArrayFromComm
export { deepCopyObject } from '../utilities/deepCopyObject'
export { deepMerge } from '../utilities/deepMerge'
export { default as flattenTopLevelFields } from '../utilities/flattenTopLevelFields'
export { formatLabels, formatNames, toWords } from '../utilities/formatLabels'
export { getTranslation } from '../utilities/getTranslation'

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,37 @@
/** @type {import('prettier').Config} */
module.exports = {
extends: ['@payloadcms'],
overrides: [
{
extends: ['plugin:@typescript-eslint/disable-type-checked'],
files: ['*.js', '*.cjs', '*.json', '*.md', '*.yml', '*.yaml'],
},
{
files: ['package.json', 'tsconfig.json'],
rules: {
'perfectionist/sort-array-includes': 'off',
'perfectionist/sort-astro-attributes': 'off',
'perfectionist/sort-classes': 'off',
'perfectionist/sort-enums': 'off',
'perfectionist/sort-exports': 'off',
'perfectionist/sort-imports': 'off',
'perfectionist/sort-interfaces': 'off',
'perfectionist/sort-jsx-props': 'off',
'perfectionist/sort-keys': 'off',
'perfectionist/sort-maps': 'off',
'perfectionist/sort-named-exports': 'off',
'perfectionist/sort-named-imports': 'off',
'perfectionist/sort-object-types': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-svelte-attributes': 'off',
'perfectionist/sort-union-types': 'off',
'perfectionist/sort-vue-attributes': 'off',
},
},
],
parserOptions: {
project: ['./tsconfig.json'],
tsconfigRootDir: __dirname,
},
root: true,
}

View File

@@ -0,0 +1,10 @@
.tmp
**/.git
**/.hg
**/.pnp.*
**/.svn
**/.yarn/**
**/build
**/dist/**
**/node_modules
**/temp

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/swcrc",
"sourceMaps": "inline",
"jsc": {
"target": "esnext",
"parser": {
"syntax": "typescript",
"tsx": true,
"dts": true
}
},
"module": {
"type": "commonjs"
}
}

View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2018-2022 Payload CMS, LLC <info@payloadcms.com>
Portions Copyright (c) Meta Platforms, Inc. and affiliates.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,65 @@
{
"name": "@payloadcms/richtext-lexical",
"version": "0.0.1",
"description": "The officially supported Lexical richtext adapter for Payload",
"repository": "https://github.com/payloadcms/payload",
"license": "MIT",
"homepage": "https://payloadcms.com",
"author": "Payload CMS, Inc.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build": "pnpm build:swc && pnpm build:types",
"build:swc": "swc ./src -d ./dist --config-file .swcrc",
"build:types": "tsc --emitDeclarationOnly --outDir dist"
},
"dependencies": {
"@faceless-ui/modal": "2.0.1",
"@lexical/clipboard": "0.12.2",
"@lexical/code": "0.12.2",
"@lexical/file": "0.12.2",
"@lexical/hashtag": "0.12.2",
"@lexical/headless": "0.12.2",
"@lexical/html": "0.12.2",
"@lexical/link": "0.12.2",
"@lexical/list": "0.12.2",
"@lexical/mark": "0.12.2",
"@lexical/markdown": "0.12.2",
"@lexical/overflow": "0.12.2",
"@lexical/plain-text": "0.12.2",
"@lexical/react": "0.12.2",
"@lexical/rich-text": "0.12.2",
"@lexical/selection": "0.12.2",
"@lexical/table": "0.12.2",
"@lexical/utils": "0.12.2",
"classnames": "^2.3.2",
"i18next": "22.5.1",
"katex": "0.16.8",
"lexical": "0.12.2",
"lodash": "4.17.21",
"openai": "4.7.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.11",
"react-i18next": "11.18.6",
"ts-essentials": "7.0.3"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/node": "20.6.2",
"@types/react": "18.2.15",
"payload": "workspace:*"
},
"exports": {
".": {
"default": "./src/index.ts",
"types": "./src/index.ts"
}
},
"publishConfig": {
"exports": null,
"main": "./dist/index.js",
"registry": "https://registry.npmjs.org/",
"types": "./dist/index.d.ts"
}
}

View File

@@ -0,0 +1,45 @@
import type { SerializedEditorState } from 'lexical'
import type { CellComponentProps, RichTextField } from 'payload/types'
import { createHeadlessEditor } from '@lexical/headless'
import { $getRoot } from 'lexical'
import React, { useEffect } from 'react'
import type { AdapterProps } from '../types'
import { getEnabledNodes } from '../field/lexical/nodes'
export const RichTextCell: React.FC<
CellComponentProps<RichTextField<AdapterProps>, SerializedEditorState> & AdapterProps
> = ({ data, editorConfig }) => {
const [preview, setPreview] = React.useState('Loading...')
useEffect(() => {
if (data == null) {
setPreview('')
return
}
// initialize headless editor
const headlessEditor = createHeadlessEditor({
namespace: editorConfig.lexical.namespace,
nodes: getEnabledNodes({ editorConfig }),
theme: editorConfig.lexical.theme,
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(data))
const textContent =
headlessEditor.getEditorState().read(() => {
return $getRoot().getTextContent()
}) || ''
// Limit preview to 150 characters
if (textContent.length > 150) {
setPreview(textContent.slice(0, 150) + '...')
return
}
setPreview(textContent)
}, [data, editorConfig])
return <span>{preview}</span>
}

View File

@@ -0,0 +1,106 @@
import type { SerializedEditorState } from 'lexical'
import { Error, FieldDescription, Label, useField, withCondition } from 'payload/components/forms'
import React, { useCallback } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
import type { FieldProps } from '../types'
import { richTextValidate } from '../populate/validation'
import './index.scss'
import { LexicalProvider } from './lexical/LexicalProvider'
const baseClass = 'rich-text-lexical'
const RichText: React.FC<FieldProps> = (props) => {
const {
name,
admin: { className, condition, description, readOnly, style, width } = {
className,
condition,
description,
readOnly,
style,
width,
},
admin,
defaultValue: defaultValueFromProps,
editorConfig,
label,
path: pathFromProps,
required,
validate = richTextValidate,
} = props
const path = pathFromProps || name
const memoizedValidate = useCallback(
(value, validationOptions) => {
return validate(value, { ...validationOptions, required })
},
[validate, required],
)
const fieldType = useField<SerializedEditorState>({
condition,
path,
validate: memoizedValidate,
})
const { errorMessage, initialValue, setValue, showError, value } = fieldType
const classes = [
baseClass,
'field-type',
className,
showError && 'error',
readOnly && `${baseClass}--read-only`,
]
.filter(Boolean)
.join(' ')
return (
<div
className={classes}
style={{
...style,
width,
}}
>
<div className={`${baseClass}__wrap`}>
<Error message={errorMessage} showError={showError} />
<Label htmlFor={`field-${path.replace(/\./g, '__')}`} label={label} required={required} />
<ErrorBoundary fallbackRender={fallbackRender} onReset={(details) => {}}>
<LexicalProvider
editorConfig={editorConfig}
fieldProps={props}
initialState={initialValue}
onChange={(editorState, editor, tags) => {
const json = editorState.toJSON()
setValue(json)
}}
readOnly={readOnly}
setValue={setValue}
value={value}
/>
<FieldDescription description={description} value={value} />
</ErrorBoundary>
<FieldDescription description={description} value={value} />
</div>
</div>
)
}
function fallbackRender({ error }): JSX.Element {
// Call resetErrorBoundary() to reset the error boundary and retry the render.
return (
<div role="alert">
<p>Something went wrong:</p>
<pre style={{ color: 'red' }}>{error.message}</pre>
</div>
)
}
export default withCondition(RichText)

View File

@@ -0,0 +1,69 @@
import { $createQuoteNode, QuoteNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection } from 'lexical'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { BlockquoteIcon } from '../../lexical/ui/icons/Blockquote'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection'
import { MarkdownTransformer } from './markdownTransformer'
export const BlockQuoteFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
{
ChildComponent: BlockquoteIcon,
isActive: ({ editor, selection }) => false,
key: 'blockquote',
label: `Blockquote`,
onClick: ({ editor }) => {
//setHeading(editor, headingSize)
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode())
}
})
},
order: 20,
},
]),
],
},
markdownTransformers: [MarkdownTransformer],
nodes: [
{
node: QuoteNode,
type: QuoteNode.getType(),
},
],
props: null,
slashMenu: {
options: [
{
options: [
new SlashMenuOption(`Blockquote`, {
Icon: BlockquoteIcon,
keywords: ['quote', 'blockquote'],
onSelect: ({ editor }) => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createQuoteNode())
}
},
}),
],
title: 'Basic',
},
],
},
}
},
key: 'blockquote',
}
}

View File

@@ -0,0 +1,40 @@
import type { ElementTransformer } from '@lexical/markdown'
import { $createQuoteNode, $isQuoteNode, QuoteNode } from '@lexical/rich-text'
import { $createLineBreakNode } from 'lexical'
export const MarkdownTransformer: ElementTransformer = {
dependencies: [QuoteNode],
export: (node, exportChildren) => {
if (!$isQuoteNode(node)) {
return null
}
const lines = exportChildren(node).split('\n')
const output = []
for (const line of lines) {
output.push('> ' + line)
}
return output.join('\n')
},
regExp: /^>\s/,
replace: (parentNode, children, _match, isImport) => {
if (isImport) {
const previousNode = parentNode.getPreviousSibling()
if ($isQuoteNode(previousNode)) {
previousNode.splice(previousNode.getChildrenSize(), 0, [
$createLineBreakNode(),
...children,
])
previousNode.select(0, 0)
parentNode.remove()
return
}
}
const node = $createQuoteNode()
node.append(...children)
parentNode.replace(node)
node.select(0, 0)
},
type: 'element',
}

View File

@@ -0,0 +1,30 @@
import type { Data, FieldWithPath } from 'payload/types'
import type React from 'react'
import { reduceFieldsToValues, useAllFormFields } from 'payload/components/forms'
import { useEffect } from 'react'
import './index.scss'
type Props = {
fieldSchema: FieldWithPath[]
onChange?: ({ formData }: { formData: Data }) => void
}
export const FormSavePlugin: React.FC<Props> = (props) => {
const { onChange } = props
const [fields, dispatchFields] = useAllFormFields()
// Pass in fields, and indicate if you'd like to "unflatten" field data.
// The result below will reflect the data stored in the form at the given time
const formData = reduceFieldsToValues(fields, true)
useEffect(() => {
if (onChange) {
onChange({ formData })
}
}, [formData, onChange])
return null
}

View File

@@ -0,0 +1,125 @@
@import 'payload/scss';
.lexical-block {
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
font-family: var(
--font-body
); // Reset font to non-serif, body font, as the lexical editor parent uses a serif font.
&__header {
h3 {
margin: 0;
}
}
&__header-wrap {
display: flex;
align-items: flex-end;
width: 100%;
justify-content: space-between;
}
&__heading-with-error {
display: flex;
align-items: center;
gap: base(0.5);
}
&--has-no-error {
> .array-field__header .array-field__heading-with-error {
color: var(--theme-text);
}
}
&--has-error {
> .array-field__header {
color: var(--theme-error-500);
}
}
&__error-pill {
align-self: center;
}
&__header-actions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
}
&__header-action {
@extend %btn-reset;
cursor: pointer;
margin-left: base(0.5);
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
&__block-header {
display: inline-flex;
max-width: 100%;
overflow: hidden;
gap: base(0.375);
}
&__block-number {
flex-shrink: 0;
}
&__block-pill {
flex-shrink: 0;
display: block;
line-height: unset;
}
&__error-wrap {
position: relative;
}
&__rows {
display: flex;
flex-direction: column;
gap: calc(var(--base) / 2);
}
&__drawer-toggler {
background-color: transparent;
margin: 0;
padding: 0;
border: none;
align-self: flex-start;
.btn {
color: var(--theme-elevation-400);
margin: 0;
&:hover {
color: var(--theme-elevation-800);
}
}
}
}
html[data-theme='light'] {
.blocks-field--has-error {
.section-title__input,
.blocks-field__heading-with-error {
color: var(--theme-error-750);
}
}
}
html[data-theme='dark'] {
.blocks-field--has-error {
.section-title__input,
.blocks-field__heading-with-error {
color: var(--theme-error-500);
}
}
}

View File

@@ -0,0 +1,152 @@
import { $getNodeByKey, type ElementFormatType } from 'lexical'
import { Collapsible } from 'payload/components/elements'
import {
Form,
HiddenInput,
RenderFields,
buildInitialState,
createNestedFieldPath,
} from 'payload/components/forms'
import React, { useCallback, useMemo } from 'react'
import type { BlockNode } from '../nodes/BlocksNode'
import { $createBlockNode, type BlockFields } from '../nodes/BlocksNode'
const baseClass = 'lexical-block'
import type { Data } from 'payload/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { ErrorPill, Pill } from 'payload/components'
import { RowActions, SectionTitle } from 'payload/components/fields/Blocks'
import { getTranslation } from 'payload/utilities'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { FormSavePlugin } from './FormSavePlugin'
import './index.scss'
type Props = {
children?: React.ReactNode
className?: string
fields: BlockFields
format?: ElementFormatType
nodeKey?: string
}
export const BlockComponent: React.FC<Props> = (props) => {
const { children, className, fields, format, nodeKey } = props
const [editor] = useLexicalComposerContext()
const { editorConfig, field } = useEditorConfigContext()
const { i18n } = useTranslation()
const [collapsed, setCollapsed] = React.useState<boolean>(fields.collapsed)
const [blockName, setBlockName] = React.useState<string>(fields.blockName)
const path = `${field.path}.${0}`
const block = (
editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps
)?.blocks?.find((block) => block.slug === fields?.type)
const onFormChange = useCallback(
({ formData }: { formData: Data }) => {
editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey)
if (node) {
node.setFields({
blockName: blockName,
collapsed: collapsed,
data: formData,
type: block.slug,
})
}
})
},
[block?.slug, editor, nodeKey, collapsed, blockName],
)
const onCollapsedOrBlockNameChange = useCallback(() => {
editor.update(() => {
const node: BlockNode = $getNodeByKey(nodeKey)
if (node) {
node.setFields({
...node.getFields(),
blockName: blockName,
collapsed: collapsed,
})
}
})
}, [editor, nodeKey, collapsed, blockName])
const initialDataRef = React.useRef<Data>(buildInitialState(fields.data || {})) // Store initial value in a ref, so it doesn't change on re-render and only gets initialized once
// Memoized Form JSX
const formContent = useMemo(() => {
return (
block && (
<Form initialState={initialDataRef?.current}>
<div id={`row-${0}`} key={`$row-${0}`}>
<Collapsible
className=""
collapsed={collapsed}
collapsibleStyle={false ? 'error' : 'default'}
header={
<div className={`${baseClass}__block-header`}>
<Pill
className={`${baseClass}__block-pill ${baseClass}__block-pill-${fields.type}`}
pillStyle="white"
>
{getTranslation(block.labels.singular, i18n)}
</Pill>
<SectionTitle
customOnChange={(e) => {
e.stopPropagation()
e.preventDefault()
setBlockName(e.target.value)
onCollapsedOrBlockNameChange()
}}
customValue={blockName}
path={`${path}.blockName`}
readOnly={field?.admin?.readOnly}
/>
{false && <ErrorPill count={0} withMessage />}
</div>
}
key={0}
onToggle={(collapsed) => {
setCollapsed(collapsed)
onCollapsedOrBlockNameChange()
}}
>
<HiddenInput name={`${path}.id`} value={0} />
<RenderFields
className={`${baseClass}__fields`}
fieldSchema={block.fields.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))}
fieldTypes={field.fieldTypes}
margins="small"
permissions={field.permissions?.blocks?.[fields?.type]?.fields}
readOnly={field.admin.readOnly}
/>
</Collapsible>
</div>
<FormSavePlugin
fieldSchema={block.fields.map((field) => ({
...field,
path: createNestedFieldPath(null, field),
}))}
onChange={onFormChange}
/>
</Form>
)
)
}, [block, onFormChange, field.fieldTypes, field.admin.readOnly])
return <div className={baseClass}>{formContent}</div>
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,117 @@
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getNodeByKey,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
type LexicalEditor,
createCommand,
} from 'lexical'
import { formatDrawerSlug } from 'payload/components/elements'
import { BlocksDrawer } from 'payload/components/fields/Blocks'
import { useEditDepth } from 'payload/components/utilities'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { BlocksFeatureProps } from '..'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { $createBlockNode } from '../nodes/BlocksNode'
import { INSERT_BLOCK_COMMAND } from '../plugin'
import './index.scss'
const baseClass = 'lexical-blocks-drawer'
export const INSERT_BLOCK_WITH_DRAWER_COMMAND: LexicalCommand<{
replace: { nodeKey: string } | false
}> = createCommand('INSERT_BLOCK_WITH_DRAWER_COMMAND')
const insertBlock = ({
blockType,
editor,
replaceNodeKey,
}: {
blockType: string
editor: LexicalEditor
replaceNodeKey: null | string
}) => {
if (!replaceNodeKey) {
editor.dispatchCommand(INSERT_BLOCK_COMMAND, {
blockName: '',
collapsed: false,
data: null,
type: blockType,
})
} else {
editor.update(() => {
const node = $getNodeByKey(replaceNodeKey)
if (node) {
node.replace(
$createBlockNode({
blockName: '',
collapsed: false,
data: null,
type: blockType,
}),
)
}
})
}
}
export const BlocksDrawerComponent: React.FC = () => {
const [editor] = useLexicalComposerContext()
const { editorConfig, uuid } = useEditorConfigContext()
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const editDepth = useEditDepth()
const { t } = useTranslation('fields')
const { closeModal, openModal } = useModal()
const labels = {
plural: t('blocks') || 'Blocks',
singular: t('block') || 'Block',
}
const addRow = useCallback(
async (rowIndex: number, blockType: string) => {
insertBlock({
blockType: blockType,
editor,
replaceNodeKey,
})
},
[editor, replaceNodeKey],
)
const drawerSlug = formatDrawerSlug({
depth: editDepth,
slug: `lexical-rich-text-blocks-` + uuid,
})
const blocks = (editorConfig?.resolvedFeatureMap?.get('blocks')?.props as BlocksFeatureProps)
?.blocks
useEffect(() => {
return editor.registerCommand<{
replace: { nodeKey: string } | false
}>(
INSERT_BLOCK_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openModal(drawerSlug)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, drawerSlug, openModal])
return (
<BlocksDrawer
addRow={addRow}
addRowIndex={0}
blocks={blocks}
drawerSlug={drawerSlug}
labels={labels}
/>
)
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,79 @@
import type { Block } from 'payload/types'
import { baseBlockFields } from 'payload/config'
import { formatLabels } from 'payload/utilities'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { BlockIcon } from '../../lexical/ui/icons/Block'
import { INSERT_BLOCK_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss'
import { BlockNode } from './nodes/BlocksNode'
import { BlocksPlugin } from './plugin'
export type BlocksFeatureProps = {
blocks: Block[]
}
export const BlocksFeature = (props?: BlocksFeatureProps): FeatureProvider => {
// Sanitization taken from payload/src/fields/config/sanitize.ts
if (props?.blocks?.length) {
props.blocks = props.blocks.map((block) => ({
...block,
fields: block.fields.concat(baseBlockFields),
}))
props.blocks = props.blocks.map((block) => {
const unsanitizedBlock = { ...block }
unsanitizedBlock.labels = !unsanitizedBlock.labels
? formatLabels(unsanitizedBlock.slug)
: unsanitizedBlock.labels
// TODO
/*unsanitizedBlock.fields = sanitizeFields({
config,
fields: block.fields,
validRelationships,
})*/
return unsanitizedBlock
})
}
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
nodes: [
{
node: BlockNode,
type: BlockNode.getType(),
},
],
plugins: [
{
Component: BlocksPlugin,
position: 'normal',
},
],
props: props,
slashMenu: {
options: [
{
options: [
new SlashMenuOption('Block', {
Icon: BlockIcon,
keywords: ['block', 'blocks'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_BLOCK_WITH_DRAWER_COMMAND, null)
},
}),
],
title: 'Basic',
},
],
},
}
},
key: 'blocks',
}
}

View File

@@ -0,0 +1,128 @@
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import type {
DOMConversionMap,
DOMExportOutput,
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import React from 'react'
import { BlockComponent } from '../component'
export type BlockFields = {
blockName: string
collapsed: boolean
/** Block data */
data: null | unknown
/** Block type. This is the slug of the block added to the Blocks Feature */
type: string
}
export type SerializedBlockNode = Spread<
{
fields: BlockFields
},
SerializedDecoratorBlockNode
>
export class BlockNode extends DecoratorBlockNode {
__fields: BlockFields
constructor({
fields,
format,
key,
}: {
fields: BlockFields
format?: ElementFormatType
key?: NodeKey
}) {
super(format, key)
this.__fields = fields
}
static clone(node: BlockNode): BlockNode {
return new BlockNode({
fields: node.__fields,
format: node.__format,
key: node.__key,
})
}
static getType(): string {
return 'block'
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {}
}
static importJSON(serializedNode: SerializedBlockNode): BlockNode {
const node = $createBlockNode(serializedNode.fields)
node.setFormat(serializedNode.format)
return node
}
static isInline(): false {
return false
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
return (
<BlockComponent
className={config.theme.block ?? 'LexicalEditorTheme__block'}
fields={this.__fields}
format={this.__format}
nodeKey={this.getKey()}
/>
)
}
exportDOM(): DOMExportOutput {
const element = document.createElement('div')
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
exportJSON(): SerializedBlockNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
version: 1,
}
}
getFields(): BlockFields {
return this.getLatest().__fields
}
getId(): string {
return this.__id
}
getTextContent(): string {
return `Block Field`
}
setFields(fields: BlockFields): void {
const writable = this.getWritable()
writable.__fields = fields
}
}
export function $createBlockNode(fields: BlockFields): BlockNode {
return new BlockNode({
fields,
})
}
export function $isBlockNode(node: BlockNode | LexicalNode | null | undefined): node is BlockNode {
return node instanceof BlockNode
}

View File

@@ -0,0 +1,42 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, type LexicalCommand, createCommand } from 'lexical'
import React, { useEffect } from 'react'
import type { BlockFields } from '../nodes/BlocksNode'
import { BlocksDrawerComponent } from '../drawer'
import { $createBlockNode, BlockNode } from '../nodes/BlocksNode'
export type InsertBlockPayload = BlockFields
export const INSERT_BLOCK_COMMAND: LexicalCommand<InsertBlockPayload> =
createCommand('INSERT_BLOCK_COMMAND')
export function BlocksPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([BlockNode])) {
throw new Error('BlocksPlugin: BlocksNode not registered on editor')
}
return mergeRegister(
editor.registerCommand<InsertBlockPayload>(
INSERT_BLOCK_COMMAND,
(payload: InsertBlockPayload) => {
editor.update(() => {
const uploadNode = $createBlockNode(payload)
$insertNodeToNearestRoot(uploadNode)
})
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return <BlocksDrawerComponent />
}

View File

@@ -0,0 +1,91 @@
import type { HeadingTagType } from '@lexical/rich-text'
import type { LexicalEditor } from 'lexical'
import { $createHeadingNode, HeadingNode } from '@lexical/rich-text'
import { $setBlocksType } from '@lexical/selection'
import { $getSelection, $isRangeSelection, DEPRECATED_$isGridSelection } from 'lexical'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { H1Icon } from '../../lexical/ui/icons/H1'
import { H2Icon } from '../../lexical/ui/icons/H2'
import { H3Icon } from '../../lexical/ui/icons/H3'
import { H4Icon } from '../../lexical/ui/icons/H4'
import { H5Icon } from '../../lexical/ui/icons/H5'
import { H6Icon } from '../../lexical/ui/icons/H6'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection'
import { MarkdownTransformer } from './markdownTransformer'
const setHeading = (editor: LexicalEditor, headingSize: HeadingTagType) => {
const selection = $getSelection()
if ($isRangeSelection(selection) || DEPRECATED_$isGridSelection(selection)) {
$setBlocksType(selection, () => $createHeadingNode(headingSize))
}
}
type Props = {
enabledHeadingSizes?: HeadingTagType[]
}
const HeadingToIconMap: Record<HeadingTagType, React.FC> = {
h1: H1Icon,
h2: H2Icon,
h3: H3Icon,
h4: H4Icon,
h5: H5Icon,
h6: H6Icon,
}
export const HeadingFeature = (props: Props): FeatureProvider => {
const { enabledHeadingSizes = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] } = props
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
...enabledHeadingSizes.map((headingSize, i) =>
TextDropdownSectionWithEntries([
{
ChildComponent: HeadingToIconMap[headingSize],
isActive: ({ editor, selection }) => false,
key: headingSize,
label: `Heading ${headingSize.charAt(1)}`,
onClick: ({ editor }) => {
editor.update(() => {
setHeading(editor, headingSize)
})
},
order: i + 2,
},
]),
),
],
},
markdownTransformers: [MarkdownTransformer],
nodes: [{ node: HeadingNode, type: HeadingNode.getType() }],
props,
slashMenu: {
options: [
...enabledHeadingSizes.map((headingSize) => {
return {
options: [
new SlashMenuOption(`Heading ${headingSize.charAt(1)}`, {
Icon: HeadingToIconMap[headingSize],
keywords: ['heading', headingSize],
onSelect: ({ editor }) => {
setHeading(editor, headingSize)
},
}),
],
title: 'Basic',
}
}),
],
},
}
},
key: 'heading',
}
}

View File

@@ -0,0 +1,23 @@
import type { ElementTransformer } from '@lexical/markdown'
import type { HeadingTagType } from '@lexical/rich-text'
import { $createHeadingNode, $isHeadingNode, HeadingNode } from '@lexical/rich-text'
import { createBlockNode } from '../../lexical/utils/markdown/createBlockNode'
export const MarkdownTransformer: ElementTransformer = {
dependencies: [HeadingNode],
export: (node, exportChildren) => {
if (!$isHeadingNode(node)) {
return null
}
const level = Number(node.getTag().slice(1))
return '#'.repeat(level) + ' ' + exportChildren(node)
},
regExp: /^(#{1,6})\s/,
replace: createBlockNode((match) => {
const tag = ('h' + match[1].length) as HeadingTagType
return $createHeadingNode(tag)
}),
type: 'element',
}

View File

@@ -0,0 +1,60 @@
import type { LinkFeatureProps } from '.'
import type { AfterReadPromise } from '../types'
import type { SerializedLinkNode } from './nodes/LinkNode'
import { populate } from '../../../populate/populate'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const linkAfterReadPromiseHOC = (
props: LinkFeatureProps,
): AfterReadPromise<SerializedLinkNode> => {
const linkAfterReadPromise: AfterReadPromise<SerializedLinkNode> = ({
afterReadPromises,
currentDepth,
depth,
field,
node,
overrideAccess,
req,
showHiddenFields,
}) => {
const promises: Promise<void>[] = []
if (node?.fields?.doc?.value && node?.fields?.doc?.relationTo) {
const collection = req.payload.collections[node?.fields?.doc?.relationTo]
if (collection) {
promises.push(
populate({
id: node?.fields?.doc.value,
collection,
currentDepth,
data: node?.fields?.doc,
depth,
field,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
}
if (Array.isArray(props.fields)) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: node.fields || {},
depth,
fields: props.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
return promises
}
return linkAfterReadPromise
}

View File

@@ -0,0 +1,86 @@
import type { Config } from 'payload/config'
import type { Field } from 'payload/types'
import { extractTranslations } from 'payload/utilities'
const translations = extractTranslations([
'fields:textToDisplay',
'fields:linkType',
'fields:chooseBetweenCustomTextOrDocument',
'fields:customURL',
'fields:internalLink',
'fields:enterURL',
'fields:chooseDocumentToLink',
'fields:openInNewTab',
])
export const getBaseFields = (config: Config): Field[] => [
{
name: 'text',
label: translations['fields:textToDisplay'],
required: true,
type: 'text',
},
{
name: 'fields',
admin: {
style: {
borderBottom: 0,
borderTop: 0,
margin: 0,
padding: 0,
},
},
fields: [
{
name: 'linkType',
admin: {
description: translations['fields:chooseBetweenCustomTextOrDocument'],
},
defaultValue: 'custom',
label: translations['fields:linkType'],
options: [
{
label: translations['fields:customURL'],
value: 'custom',
},
{
label: translations['fields:internalLink'],
value: 'internal',
},
],
required: true,
type: 'radio',
},
{
name: 'url',
admin: {
condition: ({ fields }) => fields?.linkType !== 'internal',
},
label: translations['fields:enterURL'],
required: true,
type: 'text',
},
{
name: 'doc',
admin: {
condition: ({ fields }) => {
return fields?.linkType === 'internal'
},
},
label: translations['fields:chooseDocumentToLink'],
relationTo: config.collections
.filter(({ admin: { enableRichTextLink } }) => enableRichTextLink)
.map(({ slug }) => slug),
required: true,
type: 'relationship',
},
{
name: 'newTab',
label: translations['fields:openInNewTab'],
type: 'checkbox',
},
],
type: 'group',
},
]

View File

@@ -0,0 +1,50 @@
@import 'payload/scss';
.lexical-link-edit-drawer {
&__template {
position: relative;
z-index: 1;
padding-top: base(1);
padding-bottom: base(2);
}
&__header {
width: 100%;
margin-bottom: $baseline;
display: flex;
justify-content: space-between;
margin-top: base(2.5);
margin-bottom: base(1);
@include mid-break {
margin-top: base(1.5);
}
}
&__header-text {
margin: 0;
}
&__header-close {
border: 0;
background-color: transparent;
padding: 0;
cursor: pointer;
overflow: hidden;
width: base(1);
height: base(1);
svg {
width: base(2.75);
height: base(2.75);
position: relative;
left: base(-0.825);
top: base(-0.825);
.stroke {
stroke-width: 2px;
vector-effect: non-scaling-stroke;
}
}
}
}

View File

@@ -0,0 +1,34 @@
import { Drawer } from 'payload/components/elements'
import { Form } from 'payload/components/forms'
import { RenderFields } from 'payload/components/forms'
import { FormSubmit } from 'payload/components/forms'
import { fieldTypes } from 'payload/config'
import React from 'react'
import { useTranslation } from 'react-i18next'
import './index.scss'
import { type Props } from './types'
const baseClass = 'lexical-link-edit-drawer'
export const LinkDrawer: React.FC<Props> = ({
drawerSlug,
fieldSchema,
handleModalSubmit,
initialState,
}) => {
const { t } = useTranslation('fields')
return (
<Drawer className={baseClass} slug={drawerSlug} title={t('editLink') ?? ''}>
<Form initialState={initialState} onSubmit={handleModalSubmit}>
<RenderFields
fieldSchema={fieldSchema}
fieldTypes={fieldTypes}
forceRender
readOnly={false}
/>
<FormSubmit>{t('general:submit')}</FormSubmit>
</Form>
</Drawer>
)
}

View File

@@ -0,0 +1,8 @@
import { type Field, type Fields } from 'payload/types'
export interface Props {
drawerSlug: string
fieldSchema: Field[]
handleModalSubmit: (fields: Fields, data: Record<string, unknown>) => void
initialState?: Fields
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,112 @@
import type { i18n } from 'i18next'
import type { SanitizedConfig } from 'payload/config'
import type { Field } from 'payload/types'
import LexicalClickableLinkPlugin from '@lexical/react/LexicalClickableLinkPlugin'
import { $findMatchingParent } from '@lexical/utils'
import { $getSelection, $isRangeSelection } from 'lexical'
import { withMergedProps } from 'payload/components/utilities'
import type { FeatureProvider } from '../types'
import type { LinkFields } from './nodes/LinkNode'
import { LinkIcon } from '../../lexical/ui/icons/Link'
import { getSelectedNode } from '../../lexical/utils/getSelectedNode'
import { FeaturesSectionWithEntries } from '../common/floatingSelectToolbarFeaturesButtonsSection'
import { linkAfterReadPromiseHOC } from './afterReadPromise'
import './index.scss'
import { AutoLinkNode } from './nodes/AutoLinkNode'
import { $isLinkNode, LinkNode, TOGGLE_LINK_COMMAND } from './nodes/LinkNode'
import { AutoLinkPlugin } from './plugins/autoLink'
import { FloatingLinkEditorPlugin } from './plugins/floatingLinkEditor'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './plugins/floatingLinkEditor/LinkEditor'
import { LinkPlugin } from './plugins/link'
export type LinkFeatureProps = {
fields?:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: i18n }) => Field[])
| Field[]
}
export const LinkFeature = (props: LinkFeatureProps): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
FeaturesSectionWithEntries([
{
ChildComponent: LinkIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
const selectedNode = getSelectedNode(selection)
const linkParent = $findMatchingParent(selectedNode, $isLinkNode)
return linkParent != null
}
return false
},
key: 'link',
label: `Link`,
onClick: ({ editor, isActive }) => {
if (!isActive) {
let selectedText = null
editor.getEditorState().read(() => {
selectedText = $getSelection().getTextContent()
})
const linkFields: LinkFields = {
doc: null,
linkType: 'custom',
newTab: false,
url: 'https://',
}
editor.dispatchCommand(TOGGLE_LINK_WITH_MODAL_COMMAND, {
fields: linkFields,
text: selectedText,
})
} else {
// remove link
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}
},
order: 1,
},
]),
],
},
nodes: [
{
afterReadPromises: [linkAfterReadPromiseHOC(props)],
node: LinkNode,
type: LinkNode.getType(),
},
{
node: AutoLinkNode,
type: AutoLinkNode.getType(),
},
],
plugins: [
{
Component: LinkPlugin,
position: 'normal',
},
{
Component: AutoLinkPlugin,
position: 'normal',
},
{
Component: LexicalClickableLinkPlugin,
position: 'normal',
},
{
Component: withMergedProps({
Component: FloatingLinkEditorPlugin,
toMergeIntoProps: props,
}),
position: 'floatingAnchorElem',
},
],
props,
}
},
key: 'link',
}
}

View File

@@ -0,0 +1,63 @@
import {
$applyNodeReplacement,
$isElementNode,
type ElementNode,
type LexicalNode,
type RangeSelection,
} from 'lexical'
import { type LinkFields, LinkNode, type SerializedLinkNode } from './LinkNode'
export type SerializedAutoLinkNode = SerializedLinkNode
// Custom node type to override `canInsertTextAfter` that will
// allow typing within the link
export class AutoLinkNode extends LinkNode {
static clone(node: AutoLinkNode): AutoLinkNode {
return new AutoLinkNode({ fields: node.__fields, key: node.__key })
}
static getType(): string {
return 'autolink'
}
static importDOM(): null {
// TODO: Should link node should handle the import over autolink?
return null
}
static importJSON(serializedNode: SerializedAutoLinkNode): AutoLinkNode {
const node = $createAutoLinkNode({ fields: serializedNode.fields })
node.setFormat(serializedNode.format)
node.setIndent(serializedNode.indent)
node.setDirection(serializedNode.direction)
return node
}
exportJSON(): SerializedAutoLinkNode {
return {
...super.exportJSON(),
type: 'autolink',
version: 1,
}
}
insertNewAfter(selection: RangeSelection, restoreSelection = true): ElementNode | null {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection)
if ($isElementNode(element)) {
const linkNode = $createAutoLinkNode({ fields: this.__fields })
element.append(linkNode)
return linkNode
}
return null
}
}
export function $createAutoLinkNode({ fields }: { fields: LinkFields }): AutoLinkNode {
return $applyNodeReplacement(new AutoLinkNode({ fields }))
}
export function $isAutoLinkNode(node: LexicalNode | null | undefined): node is AutoLinkNode {
return node instanceof AutoLinkNode
}

View File

@@ -0,0 +1,446 @@
/** @module @lexical/link */
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { addClassNamesToElement, isHTMLAnchorElement } from '@lexical/utils'
import {
$applyNodeReplacement,
$createTextNode,
$getSelection,
$isElementNode,
$isRangeSelection,
type DOMConversionMap,
type DOMConversionOutput,
type EditorConfig,
ElementNode,
type GridSelection,
type LexicalCommand,
type LexicalNode,
type NodeKey,
type NodeSelection,
type RangeSelection,
type SerializedElementNode,
type Spread,
createCommand,
} from 'lexical'
import type { LinkPayload } from '../plugins/floatingLinkEditor/types'
export type LinkFields = {
// unknown, custom fields:
[key: string]: unknown
doc: {
data?: any // Will be populated in afterRead hook
relationTo: string
value: string
} | null
linkType: 'custom' | 'internal'
newTab: boolean
url: string
}
export type SerializedLinkNode = Spread<
{
fields: LinkFields
},
SerializedElementNode
>
const SUPPORTED_URL_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'sms:', 'tel:'])
/** @noInheritDoc */
export class LinkNode extends ElementNode {
__fields: LinkFields
constructor({
fields = {
doc: null,
linkType: 'custom',
newTab: false,
url: undefined,
},
key,
}: {
fields: LinkFields
key?: NodeKey
}) {
super(key)
this.__fields = fields
}
static clone(node: LinkNode): LinkNode {
return new LinkNode({
fields: node.__fields,
key: node.__key,
})
}
static getType(): string {
return 'link'
}
static importDOM(): DOMConversionMap | null {
return {
a: (node: Node) => ({
conversion: convertAnchorElement,
priority: 1,
}),
}
}
static importJSON(serializedNode: SerializedLinkNode): LinkNode {
const node = $createLinkNode({
fields: serializedNode.fields,
})
node.setFormat(serializedNode.format)
node.setIndent(serializedNode.indent)
node.setDirection(serializedNode.direction)
return node
}
canBeEmpty(): false {
return false
}
canInsertTextAfter(): false {
return false
}
canInsertTextBefore(): false {
return false
}
createDOM(config: EditorConfig): HTMLAnchorElement {
const element = document.createElement('a')
if (this.__fields?.linkType === 'custom') {
element.href = this.sanitizeUrl(this.__fields.url ?? '')
}
if (this.__fields?.newTab ?? false) {
element.target = '_blank'
}
element.rel = ''
if (this.__fields?.newTab === true && this.__fields?.linkType === 'custom') {
element.rel = manageRel(element.rel, 'add', 'noopener')
}
if (this.__fields?.sponsored ?? false) {
element.rel = manageRel(element.rel, 'add', 'sponsored')
}
if (this.__fields?.nofollow ?? false) {
element.rel = manageRel(element.rel, 'add', 'nofollow')
}
if (this.__fields?.rel !== null) {
element.rel += ` ${this.__rel}`
}
addClassNamesToElement(element, config.theme.link)
return element
}
exportJSON(): SerializedLinkNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
version: 1,
}
}
extractWithChild(
child: LexicalNode,
selection: GridSelection | NodeSelection | RangeSelection,
destination: 'clone' | 'html',
): boolean {
if (!$isRangeSelection(selection)) {
return false
}
const anchorNode = selection.anchor.getNode()
const focusNode = selection.focus.getNode()
return (
this.isParentOf(anchorNode) &&
this.isParentOf(focusNode) &&
selection.getTextContent().length > 0
)
}
getFields(): LinkFields {
return this.getLatest().__fields
}
insertNewAfter(selection: RangeSelection, restoreSelection = true): ElementNode | null {
const element = this.getParentOrThrow().insertNewAfter(selection, restoreSelection)
if ($isElementNode(element)) {
const linkNode = $createLinkNode({ fields: this.__fields })
element.append(linkNode)
return linkNode
}
return null
}
isInline(): true {
return true
}
sanitizeUrl(url: string): string {
try {
const parsedUrl = new URL(url)
// eslint-disable-next-line no-script-url
if (!SUPPORTED_URL_PROTOCOLS.has(parsedUrl.protocol)) {
return 'about:blank'
}
} catch (e) {
return 'https://'
}
return url
}
setFields(fields: LinkFields): void {
const writable = this.getWritable()
writable.__fields = fields
}
updateDOM(prevNode: LinkNode, anchor: HTMLAnchorElement, config: EditorConfig): boolean {
const url = this.__fields?.url
const newTab = this.__fields?.newTab
const sponsored = this.__fields?.sponsored
const nofollow = this.__fields?.nofollow
const rel = this.__fields?.rel
if (url != null && url !== prevNode.__fields?.url && this.__fields?.linkType === 'custom') {
anchor.href = url
}
if (this.__fields?.linkType === 'internal' && prevNode.__fields?.linkType === 'custom') {
anchor.removeAttribute('href')
}
// TODO: not 100% sure why we're settign rel to '' - revisit
// Start rel config here, then check newTab below
if (anchor.rel == null) {
anchor.rel = ''
}
if (newTab !== prevNode.__fields?.newTab) {
if (newTab ?? false) {
anchor.target = '_blank'
if (this.__fields?.linkType === 'custom') {
anchor.rel = manageRel(anchor.rel, 'add', 'noopener')
}
} else {
anchor.removeAttribute('target')
anchor.rel = manageRel(anchor.rel, 'remove', 'noopener')
}
}
if (nofollow !== prevNode.__fields.nofollow) {
if (nofollow ?? false) {
anchor.rel = manageRel(anchor.rel, 'add', 'nofollow')
} else {
anchor.rel = manageRel(anchor.rel, 'remove', 'nofollow')
}
}
if (sponsored !== prevNode.__fields.sponsored) {
if (sponsored ?? false) {
anchor.rel = manageRel(anchor.rel, 'add', 'sponsored')
} else {
anchor.rel = manageRel(anchor.rel, 'remove', 'sponsored')
}
}
// TODO - revisit - I don't think there can be any other rel
// values other than nofollow and noopener - so not
// sure why anchor.rel += rel below
if (rel !== prevNode.__fields.rel) {
if (rel != null) {
anchor.rel += rel
} else {
anchor.removeAttribute('rel')
}
}
return false
}
}
function convertAnchorElement(domNode: Node): DOMConversionOutput {
let node: LinkNode | null = null
if (isHTMLAnchorElement(domNode)) {
const content = domNode.textContent
if (content !== null && content !== '') {
node = $createLinkNode({
fields: {
doc: null,
linkType: 'custom',
newTab: domNode.getAttribute('target') === '_blank',
nofollow: domNode.getAttribute('rel')?.includes('nofollow') ?? false,
rel: domNode.getAttribute('rel'),
sponsored: domNode.getAttribute('rel')?.includes('sponsored') ?? false,
url: domNode.getAttribute('href') ?? '',
},
})
}
}
return { node }
}
export function $createLinkNode({ fields }: { fields: LinkFields }): LinkNode {
return $applyNodeReplacement(new LinkNode({ fields }))
}
export function $isLinkNode(node: LexicalNode | null | undefined): node is LinkNode {
return node instanceof LinkNode
}
export const TOGGLE_LINK_COMMAND: LexicalCommand<LinkPayload | null> =
createCommand('TOGGLE_LINK_COMMAND')
export function toggleLink(payload: LinkPayload): void {
const selection = $getSelection()
if (!$isRangeSelection(selection)) {
return
}
const nodes = selection.extract()
if (payload === null) {
// Remove LinkNodes
nodes.forEach((node) => {
const parent = node.getParent()
if ($isLinkNode(parent)) {
const children = parent.getChildren()
for (let i = 0; i < children.length; i += 1) {
parent.insertBefore(children[i])
}
parent.remove()
}
})
} else {
// Add or merge LinkNodes
if (nodes.length === 1) {
const firstNode = nodes[0]
// if the first node is a LinkNode or if its
// parent is a LinkNode, we update the URL, target and rel.
const linkNode: LinkNode | null = $isLinkNode(firstNode)
? firstNode
: $getLinkAncestor(firstNode)
if (linkNode !== null) {
linkNode.setFields(payload.fields)
if (payload.text != null && payload.text !== linkNode.getTextContent()) {
// remove all children and add child with new textcontent:
linkNode.append($createTextNode(payload.text))
linkNode.getChildren().forEach((child) => {
if (child !== linkNode.getLastChild()) {
child.remove()
}
})
}
return
}
}
let prevParent: ElementNode | LinkNode | null = null
let linkNode: LinkNode | null = null
nodes.forEach((node) => {
const parent = node.getParent()
if (parent === linkNode || parent === null || ($isElementNode(node) && !node.isInline())) {
return
}
if ($isLinkNode(parent)) {
linkNode = parent
parent.setFields(payload.fields)
if (payload.text != null && payload.text !== parent.getTextContent()) {
// remove all children and add child with new textcontent:
parent.append($createTextNode(payload.text))
parent.getChildren().forEach((child) => {
if (child !== parent.getLastChild()) {
child.remove()
}
})
}
return
}
if (!parent.is(prevParent)) {
prevParent = parent
linkNode = $createLinkNode({ fields: payload.fields })
if ($isLinkNode(parent)) {
if (node.getPreviousSibling() === null) {
parent.insertBefore(linkNode)
} else {
parent.insertAfter(linkNode)
}
} else {
node.insertBefore(linkNode)
}
}
if ($isLinkNode(node)) {
if (node.is(linkNode)) {
return
}
if (linkNode !== null) {
const children = node.getChildren()
for (let i = 0; i < children.length; i += 1) {
linkNode.append(children[i])
}
}
node.remove()
return
}
if (linkNode !== null) {
linkNode.append(node)
}
})
}
}
function $getLinkAncestor(node: LexicalNode): LinkNode | null {
return $getAncestor(node, (ancestor) => $isLinkNode(ancestor)) as LinkNode
}
function $getAncestor(
node: LexicalNode,
predicate: (ancestor: LexicalNode) => boolean,
): LexicalNode | null {
let parent: LexicalNode | null = node
while (parent !== null && (parent = parent.getParent()) !== null && !predicate(parent));
return parent
}
function manageRel(input: string, action: 'add' | 'remove', value: string): string {
let result: string
let mutableInput = `${input}`
if (action === 'add') {
// if we somehow got out of sync - clean up
if (mutableInput.includes(value)) {
const re = new RegExp(value, 'g')
mutableInput = mutableInput.replace(re, '').trim()
}
mutableInput = mutableInput.trim()
result = mutableInput.length === 0 ? `${value}` : `${mutableInput} ${value}`
} else {
const re = new RegExp(value, 'g')
result = mutableInput.replace(re, '').trim()
}
return result
}

View File

@@ -0,0 +1,307 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type { ElementNode, LexicalEditor, LexicalNode } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import { $createTextNode, $isElementNode, $isLineBreakNode, $isTextNode, TextNode } from 'lexical'
import { useEffect } from 'react'
import { invariant } from '../../../../lexical/utils/invariant'
import { $createAutoLinkNode, $isAutoLinkNode, AutoLinkNode } from '../../nodes/AutoLinkNode'
import { $isLinkNode, type LinkFields } from '../../nodes/LinkNode'
type ChangeHandler = (url: null | string, prevUrl: null | string) => void
interface LinkMatcherResult {
fields?: LinkFields
index: number
length: number
text: string
url: string
}
export type LinkMatcher = (text: string) => LinkMatcherResult | null
export function createLinkMatcherWithRegExp(
regExp: RegExp,
urlTransformer: (text: string) => string = (text) => text,
) {
return (text: string) => {
const match = regExp.exec(text)
if (match === null) return null
return {
index: match.index,
length: match[0].length,
text: match[0],
url: urlTransformer(text),
}
}
}
function findFirstMatch(text: string, matchers: LinkMatcher[]): LinkMatcherResult | null {
for (let i = 0; i < matchers.length; i++) {
const match = matchers[i](text)
if (match != null) {
return match
}
}
return null
}
const PUNCTUATION_OR_SPACE = /[.,;\s]/
function isSeparator(char: string): boolean {
return PUNCTUATION_OR_SPACE.test(char)
}
function endsWithSeparator(textContent: string): boolean {
return isSeparator(textContent[textContent.length - 1])
}
function startsWithSeparator(textContent: string): boolean {
return isSeparator(textContent[0])
}
function isPreviousNodeValid(node: LexicalNode): boolean {
let previousNode = node.getPreviousSibling()
if ($isElementNode(previousNode)) {
previousNode = previousNode.getLastDescendant()
}
return (
previousNode === null ||
$isLineBreakNode(previousNode) ||
($isTextNode(previousNode) && endsWithSeparator(previousNode.getTextContent()))
)
}
function isNextNodeValid(node: LexicalNode): boolean {
let nextNode = node.getNextSibling()
if ($isElementNode(nextNode)) {
nextNode = nextNode.getFirstDescendant()
}
return (
nextNode === null ||
$isLineBreakNode(nextNode) ||
($isTextNode(nextNode) && startsWithSeparator(nextNode.getTextContent()))
)
}
function isContentAroundIsValid(
matchStart: number,
matchEnd: number,
text: string,
node: TextNode,
): boolean {
const contentBeforeIsValid =
matchStart > 0 ? isSeparator(text[matchStart - 1]) : isPreviousNodeValid(node)
if (!contentBeforeIsValid) {
return false
}
const contentAfterIsValid =
matchEnd < text.length ? isSeparator(text[matchEnd]) : isNextNodeValid(node)
return contentAfterIsValid
}
function handleLinkCreation(
node: TextNode,
matchers: LinkMatcher[],
onChange: ChangeHandler,
): void {
const nodeText = node.getTextContent()
let text = nodeText
let invalidMatchEnd = 0
let remainingTextNode = node
let match
while ((match = findFirstMatch(text, matchers)) != null && match !== null) {
const matchStart: number = match.index
const matchLength: number = match.length
const matchEnd = matchStart + matchLength
const isValid = isContentAroundIsValid(
invalidMatchEnd + matchStart,
invalidMatchEnd + matchEnd,
nodeText,
node,
)
if (isValid) {
let linkTextNode
if (invalidMatchEnd + matchStart === 0) {
;[linkTextNode, remainingTextNode] = remainingTextNode.splitText(
invalidMatchEnd + matchLength,
)
} else {
;[, linkTextNode, remainingTextNode] = remainingTextNode.splitText(
invalidMatchEnd + matchStart,
invalidMatchEnd + matchStart + matchLength,
)
}
const fields: LinkFields = {
linkType: 'custom',
url: match.url,
...match.attributes,
}
const linkNode = $createAutoLinkNode({ fields })
const textNode = $createTextNode(match.text)
textNode.setFormat(linkTextNode.getFormat())
textNode.setDetail(linkTextNode.getDetail())
linkNode.append(textNode)
linkTextNode.replace(linkNode)
onChange(match.url, null)
invalidMatchEnd = 0
} else {
invalidMatchEnd += matchEnd
}
text = text.substring(matchEnd)
}
}
function handleLinkEdit(
linkNode: AutoLinkNode,
matchers: LinkMatcher[],
onChange: ChangeHandler,
): void {
// Check children are simple text
const children = linkNode.getChildren()
const childrenLength = children.length
for (let i = 0; i < childrenLength; i++) {
const child = children[i]
if (!$isTextNode(child) || !child.isSimpleText()) {
replaceWithChildren(linkNode)
onChange(null, linkNode.getFields()?.url ?? null)
return
}
}
// Check text content fully matches
const text = linkNode.getTextContent()
const match = findFirstMatch(text, matchers)
if (match === null || match.text !== text) {
replaceWithChildren(linkNode)
onChange(null, linkNode.getFields()?.url ?? null)
return
}
// Check neighbors
if (!isPreviousNodeValid(linkNode) || !isNextNodeValid(linkNode)) {
replaceWithChildren(linkNode)
onChange(null, linkNode.getFields()?.url ?? null)
return
}
const url = linkNode.getFields()?.url
if (url !== match?.url) {
const flds = linkNode.getFields()
flds.url = match?.url
linkNode.setFields(flds)
onChange(match.url, url ?? null)
}
}
// Bad neighbours are edits in neighbor nodes that make AutoLinks incompatible.
// Given the creation preconditions, these can only be simple text nodes.
function handleBadNeighbors(
textNode: TextNode,
matchers: LinkMatcher[],
onChange: ChangeHandler,
): void {
const previousSibling = textNode.getPreviousSibling()
const nextSibling = textNode.getNextSibling()
const text = textNode.getTextContent()
if ($isAutoLinkNode(previousSibling) && !startsWithSeparator(text)) {
previousSibling.append(textNode)
handleLinkEdit(previousSibling, matchers, onChange)
onChange(null, previousSibling.getFields()?.url ?? null)
}
if ($isAutoLinkNode(nextSibling) && !endsWithSeparator(text)) {
replaceWithChildren(nextSibling)
handleLinkEdit(nextSibling, matchers, onChange)
onChange(null, nextSibling.getFields()?.url ?? null)
}
}
function replaceWithChildren(node: ElementNode): LexicalNode[] {
const children = node.getChildren()
const childrenLength = children.length
for (let j = childrenLength - 1; j >= 0; j--) {
node.insertAfter(children[j])
}
node.remove()
return children.map((child) => child.getLatest())
}
function useAutoLink(
editor: LexicalEditor,
matchers: LinkMatcher[],
onChange?: ChangeHandler,
): void {
useEffect(() => {
if (!editor.hasNodes([AutoLinkNode])) {
invariant(false, 'LexicalAutoLinkPlugin: AutoLinkNode not registered on editor')
}
const onChangeWrapped = (url: null | string, prevUrl: null | string): void => {
if (onChange != null) {
onChange(url, prevUrl)
}
}
return mergeRegister(
editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
const parent = textNode.getParentOrThrow()
const previous = textNode.getPreviousSibling()
if ($isAutoLinkNode(parent)) {
handleLinkEdit(parent, matchers, onChangeWrapped)
} else if (!$isLinkNode(parent)) {
if (
textNode.isSimpleText() &&
(startsWithSeparator(textNode.getTextContent()) || !$isAutoLinkNode(previous))
) {
handleLinkCreation(textNode, matchers, onChangeWrapped)
}
handleBadNeighbors(textNode, matchers, onChangeWrapped)
}
}),
)
}, [editor, matchers, onChange])
}
const URL_REGEX =
/((https?:\/\/(www\.)?)|(www\.))[-\w@:%.+~#=]{1,256}\.[a-zA-Z\d()]{1,6}\b([-\w()@:%+.~#?&/=]*)/
const EMAIL_REGEX =
/(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\])|(([a-zA-Z\-\d]+\.)+[a-zA-Z]{2,}))/
const MATCHERS = [
createLinkMatcherWithRegExp(URL_REGEX, (text) => {
return text.startsWith('http') ? text : `https://${text}`
}),
createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => {
return `mailto:${text}`
}),
]
export function AutoLinkPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
useAutoLink(editor, MATCHERS)
return null
}

View File

@@ -0,0 +1,318 @@
import type { LexicalCommand } from 'lexical'
import type { Fields } from 'payload/types'
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
import {
$getSelection,
$isRangeSelection,
COMMAND_PRIORITY_HIGH,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
createCommand,
} from 'lexical'
import { formatDrawerSlug } from 'payload/components/elements'
import { reduceFieldsToValues } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
useConfig,
useDocumentInfo,
useEditDepth,
useLocale,
} from 'payload/components/utilities'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { LinkFeatureProps } from '../../..'
import type { LinkNode } from '../../../nodes/LinkNode'
import type { LinkPayload } from '../types'
import { useEditorConfigContext } from '../../../../../lexical/config/EditorConfigProvider'
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor'
import { LinkDrawer } from '../../../drawer'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode'
import { transformExtraFields } from '../utilities'
export const TOGGLE_LINK_WITH_MODAL_COMMAND: LexicalCommand<LinkPayload | null> = createCommand(
'TOGGLE_LINK_WITH_MODAL_COMMAND',
)
export function LinkEditor({
anchorElem,
fields: customFieldSchema,
}: { anchorElem: HTMLElement } & LinkFeatureProps): JSX.Element {
const [editor] = useLexicalComposerContext()
const editorRef = useRef<HTMLDivElement | null>(null)
const [linkUrl, setLinkUrl] = useState('')
const [linkLabel, setLinkLabel] = useState('')
const { uuid } = useEditorConfigContext()
const config = useConfig()
const { user } = useAuth()
const { code: locale } = useLocale()
const { i18n, t } = useTranslation(['fields', 'upload', 'general'])
const { getDocPreferences } = useDocumentInfo()
const [initialState, setInitialState] = useState<Fields>({})
const [fieldSchema] = useState(() => {
const fields = transformExtraFields(customFieldSchema, config, i18n)
return fields
})
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const drawerSlug = formatDrawerSlug({
depth: editDepth,
slug: `lexical-rich-text-link-` + uuid,
})
const updateLinkEditor = useCallback(async () => {
const selection = $getSelection()
let selectedNodeDomRect: DOMRect | undefined = null
// Handle the data displayed in the floating link editor & drawer when you click on a link node
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
selectedNodeDomRect = editor.getElementByKey(node.getKey())?.getBoundingClientRect()
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode) as LinkNode
if (linkParent == null) {
setIsLink(false)
setLinkUrl('')
setLinkLabel('')
return
}
// Initial state:
const data: LinkPayload = {
fields: {
doc: undefined,
linkType: undefined,
newTab: undefined,
nofollow: undefined,
sponsored: undefined,
url: '',
...linkParent.getFields(),
},
text: linkParent.getTextContent(),
}
if (linkParent.getFields()?.linkType === 'custom') {
setLinkUrl(linkParent.getFields()?.url ?? '')
setLinkLabel('')
} else {
// internal link
setLinkUrl(
`/admin/collections/${linkParent.getFields()?.doc?.relationTo}/${linkParent.getFields()
?.doc?.value}`,
)
setLinkLabel(
`relation to ${linkParent.getFields()?.doc?.relationTo}: ${linkParent.getFields()?.doc
?.value}`,
)
}
// Set initial state of the drawer. This will basically pre-fill the drawer fields with the
// values saved in the link node you clicked on.
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
data,
fieldSchema,
locale,
operation: 'create',
preferences,
t,
user: user ?? undefined,
})
setInitialState(state)
setIsLink(true)
}
const editorElem = editorRef.current
const nativeSelection = window.getSelection()
const { activeElement } = document
if (editorElem === null) {
return
}
const rootElement = editor.getRootElement()
if (
selection !== null &&
nativeSelection !== null &&
rootElement !== null &&
rootElement.contains(nativeSelection.anchorNode) &&
editor.isEditable()
) {
if (!selectedNodeDomRect) {
// Get the DOM rect of the selected node using the native selection. This sometimes produces the wrong
// result, which is why we use lexical's selection preferably.
selectedNodeDomRect = nativeSelection.getRangeAt(0).getBoundingClientRect()
}
if (selectedNodeDomRect != null) {
selectedNodeDomRect.y += 40
setFloatingElemPositionForLinkEditor(selectedNodeDomRect, editorElem, anchorElem)
}
} else if (activeElement == null || activeElement.className !== 'link-input') {
if (rootElement !== null) {
setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)
}
setLinkUrl('')
setLinkLabel('')
}
return true
}, [anchorElem, editor, fieldSchema])
useEffect(() => {
return mergeRegister(
editor.registerCommand(
TOGGLE_LINK_WITH_MODAL_COMMAND,
(payload: LinkPayload) => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, payload)
// Now, open the modal
updateLinkEditor()
.then(() => {
toggleModal(drawerSlug)
})
.catch((error) => {
throw error
})
return true
},
COMMAND_PRIORITY_LOW,
),
)
}, [editor, updateLinkEditor, toggleModal, drawerSlug])
useEffect(() => {
if (!isLink && editorRef) {
editorRef.current.style.opacity = '0'
editorRef.current.style.transform = 'translate(-10000px, -10000px)'
}
}, [isLink])
useEffect(() => {
const scrollerElem = anchorElem.parentElement
const update = (): void => {
editor.getEditorState().read(() => {
void updateLinkEditor()
})
}
window.addEventListener('resize', update)
if (scrollerElem != null) {
scrollerElem.addEventListener('scroll', update)
}
return () => {
window.removeEventListener('resize', update)
if (scrollerElem != null) {
scrollerElem.removeEventListener('scroll', update)
}
}
}, [anchorElem.parentElement, editor, updateLinkEditor])
useEffect(() => {
return mergeRegister(
editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
void updateLinkEditor()
})
}),
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => {
void updateLinkEditor()
return true
},
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
() => {
if (isLink) {
setIsLink(false)
return true
}
return false
},
COMMAND_PRIORITY_HIGH,
),
)
}, [editor, updateLinkEditor, setIsLink, isLink])
useEffect(() => {
editor.getEditorState().read(() => {
void updateLinkEditor()
})
}, [editor, updateLinkEditor])
return (
<React.Fragment>
<div className="link-editor" ref={editorRef}>
<div className="link-input">
<a href={linkUrl} rel="noopener noreferrer" target="_blank">
{linkLabel != null && linkLabel.length > 0 ? linkLabel : linkUrl}
</a>
<button
aria-label="Edit link"
className="link-edit"
onClick={() => {
toggleModal(drawerSlug)
}}
onMouseDown={(event) => {
event.preventDefault()
}}
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"
/>
</div>
</div>
<LinkDrawer
drawerSlug={drawerSlug}
fieldSchema={fieldSchema}
handleModalSubmit={(fields: Fields) => {
closeModal(drawerSlug)
const data = reduceFieldsToValues(fields, true)
const newLinkPayload: LinkPayload = data as LinkPayload
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
}}
initialState={initialState}
/>
</React.Fragment>
)
}

View File

@@ -0,0 +1,79 @@
@import 'payload/scss';
html[data-theme='light'] {
.link-editor {
box-shadow: 0px 6px 20px 0px rgba(0, 0, 0, 0.2);
}
}
.link-editor {
z-index: 10;
display: flex;
align-items: center;
background: var(--color-base-0);
padding: 0px 3.72px 0px 6.25px;
vertical-align: middle;
position: absolute;
top: 0;
left: 0;
z-index: 10;
opacity: 0;
border-radius: 6.25px;
transition: opacity 0.2s;
height: 37.5px;
will-change: transform;
.link-input {
display: flex;
align-items: center;
flex-direction: row;
flex-wrap: nowrap;
min-height: 28px;
box-sizing: border-box;
padding: 2px 4px;
font-size: 15px;
border: 0;
outline: 0;
position: relative;
font-family: inherit;
a {
text-decoration: none;
display: block;
white-space: nowrap;
overflow: hidden;
margin-right: 30px;
text-overflow: ellipsis;
color: var(--color-blue-600);
border-bottom: 1px dotted;
&:hover {
color: var(--color-blue-400);
}
}
}
button {
all: unset;
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
width: 30px;
height: 30px;
cursor: pointer;
color: var(--color-base-600);
border-radius: 4px;
&:hover:not([disabled]) {
background-color: var(--color-base-100);
}
}
.link-edit {
background-image: url(../../../../lexical/ui/icons/Edit/index.svg);
}
.link-trash {
background-image: url(../../../../lexical/ui/icons/Remove/index.svg);
}
}

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import type { LinkFeatureProps } from '../..'
import { LinkEditor } from './LinkEditor'
import './index.scss'
export const FloatingLinkEditorPlugin: React.FC<
{
anchorElem?: HTMLElement
} & LinkFeatureProps
> = ({ anchorElem = document.body, fields = [] }) => {
return createPortal(<LinkEditor anchorElem={anchorElem} fields={fields} />, anchorElem)
}

View File

@@ -0,0 +1,13 @@
import type { LinkFields } from '../../nodes/LinkNode'
/**
* The payload of a link node
* This can be delivered from the link node to the drawer, or from the drawer/anything to the TOGGLE_LINK_COMMAND
*/
export type LinkPayload = {
fields: LinkFields
/**
* The text content of the link node - will be displayed in the drawer
*/
text: null | string
}

View File

@@ -0,0 +1,56 @@
import type { i18n } from 'i18next'
import type { SanitizedConfig } from 'payload/config'
import type { Field, GroupField } from 'payload/types'
import { getBaseFields } from '../../drawer/baseFields'
/**
* This function is run to enrich the basefields which every link has with potential, custom user-added fields.
*/
export function transformExtraFields(
customFieldSchema:
| ((args: { config: SanitizedConfig; defaultFields: Field[]; i18n: i18n }) => Field[])
| Field[],
config: SanitizedConfig,
i18n: i18n,
): Field[] {
const baseFields: Field[] = getBaseFields(config)
const fields =
typeof customFieldSchema === 'function'
? customFieldSchema({ config, defaultFields: baseFields, i18n })
: baseFields
// Wrap fields which are not part of the base schema in a group named 'fields' - otherwise they will be rendered but not saved
const extraFields = []
fields.forEach((field) => {
if ('name' in field) {
if (
!baseFields.find((baseField) => !('name' in baseField) || baseField.name === field.name)
) {
if (field.name !== 'fields' && field.type !== 'group') {
extraFields.push(field)
// Remove from fields from now, as they need to be part of the fields group below
fields.splice(fields.indexOf(field), 1)
}
}
}
})
if (Array.isArray(customFieldSchema) || fields.length > 0) {
// find field with name 'fields' and add the extra fields to it
const fieldsField: GroupField = fields.find(
(field) => field.type === 'group' && field.name === 'fields',
) as GroupField
if (!fieldsField) {
throw new Error(
'Could not find field with name "fields". This is required to add fields to the link field.',
)
}
fieldsField.fields = Array.isArray(fieldsField.fields) ? fieldsField.fields : []
fieldsField.fields.push(
...(Array.isArray(customFieldSchema) ? customFieldSchema.concat(extraFields) : extraFields),
)
}
return fields
}

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { mergeRegister } from '@lexical/utils'
import {
$getSelection,
$isElementNode,
$isRangeSelection,
COMMAND_PRIORITY_LOW,
PASTE_COMMAND,
} from 'lexical'
import { useEffect } from 'react'
import type { LinkPayload } from '../floatingLinkEditor/types'
import { validateUrl } from '../../../../lexical/utils/url'
import { type LinkFields, LinkNode, TOGGLE_LINK_COMMAND, toggleLink } from '../../nodes/LinkNode'
export function LinkPlugin(): null {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!editor.hasNodes([LinkNode])) {
throw new Error('LinkPlugin: LinkNode not registered on editor')
}
return mergeRegister(
editor.registerCommand(
TOGGLE_LINK_COMMAND,
(payload: LinkPayload) => {
// validate
if (payload?.fields.linkType === 'custom') {
if (!(validateUrl === undefined || validateUrl(payload?.fields.url))) {
return false
}
}
toggleLink(payload)
return true
},
COMMAND_PRIORITY_LOW,
),
validateUrl !== undefined
? editor.registerCommand(
PASTE_COMMAND,
(event) => {
const selection = $getSelection()
if (
!$isRangeSelection(selection) ||
selection.isCollapsed() ||
!(event instanceof ClipboardEvent) ||
event.clipboardData == null
) {
return false
}
const clipboardText = event.clipboardData.getData('text')
if (!validateUrl(clipboardText)) {
return false
}
// If we select nodes that are elements then avoid applying the link.
if (!selection.getNodes().some((node) => $isElementNode(node))) {
const linkFields: LinkFields = {
doc: null,
linkType: 'custom',
newTab: false,
url: clipboardText,
}
editor.dispatchCommand(TOGGLE_LINK_COMMAND, {
fields: linkFields,
text: null,
})
event.preventDefault()
return true
}
return false
},
COMMAND_PRIORITY_LOW,
)
: () => {
// Don't paste arbitrary text as a link when there's no validate function
},
)
}, [editor])
return null
}

View File

@@ -0,0 +1,61 @@
import { $setBlocksType } from '@lexical/selection'
import { $createParagraphNode, $getSelection, $isRangeSelection } from 'lexical'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { TextIcon } from '../../lexical/ui/icons/Text'
import { TextDropdownSectionWithEntries } from '../common/floatingSelectToolbarTextDropdownSection'
export const ParagraphFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
TextDropdownSectionWithEntries([
{
ChildComponent: TextIcon,
isActive: ({ editor, selection }) => false,
key: 'normal-text',
label: 'Normal Text',
onClick: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode())
}
})
},
order: 1,
},
]),
],
},
props: null,
slashMenu: {
options: [
{
options: [
new SlashMenuOption('Paragraph', {
Icon: TextIcon,
keywords: ['normal', 'paragraph', 'p', 'text'],
onSelect: ({ editor }) => {
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
$setBlocksType(selection, () => $createParagraphNode())
}
})
},
}),
],
title: 'Basic',
},
],
},
}
},
key: 'paragraph',
}
}

View File

@@ -0,0 +1,39 @@
import type { AfterReadPromise } from '../types'
import type { SerializedRelationshipNode } from './nodes/RelationshipNode'
import { populate } from '../../../populate/populate'
export const relationshipAfterReadPromise: AfterReadPromise<SerializedRelationshipNode> = ({
currentDepth,
depth,
field,
node,
overrideAccess,
req,
showHiddenFields,
}) => {
const promises: Promise<void>[] = []
if (node?.fields?.id) {
const collection = req.payload.collections[node?.fields?.relationTo]
if (collection) {
promises.push(
populate({
id: node?.fields?.id,
collection,
currentDepth,
data: node.fields,
depth,
field,
key: 'data',
overrideAccess,
req,
showHiddenFields,
}),
)
}
}
return promises
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,108 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getNodeByKey,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
type LexicalEditor,
createCommand,
} from 'lexical'
import { useListDrawer } from 'payload/components/elements'
import React, { useCallback, useEffect, useState } from 'react'
import { $createRelationshipNode } from '../nodes/RelationshipNode'
import { INSERT_RELATIONSHIP_COMMAND } from '../plugins'
import { EnabledRelationshipsCondition } from '../utils/EnabledRelationshipsCondition'
import './index.scss'
const baseClass = 'lexical-relationship-drawer'
export const INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND: LexicalCommand<{
replace: { nodeKey: string } | false
}> = createCommand('INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND')
const insertRelationship = ({
id,
editor,
relationTo,
replaceNodeKey,
}: {
editor: LexicalEditor
id: string
relationTo: string
replaceNodeKey: null | string
}) => {
if (!replaceNodeKey) {
editor.dispatchCommand(INSERT_RELATIONSHIP_COMMAND, {
id,
data: null,
relationTo,
})
} else {
editor.update(() => {
const node = $getNodeByKey(replaceNodeKey)
if (node) {
node.replace($createRelationshipNode({ id, data: null, relationTo }))
}
})
}
}
type Props = {
enabledCollectionSlugs: string[]
}
const RelationshipDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }) => {
const [editor] = useLexicalComposerContext()
const [selectedCollectionSlug, setSelectedCollectionSlug] = useState(
() => enabledCollectionSlugs[0],
)
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const [ListDrawer, ListDrawerToggler, { closeDrawer, isDrawerOpen, openDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
selectedCollection: selectedCollectionSlug,
})
useEffect(() => {
return editor.registerCommand<{
replace: { nodeKey: string } | false
}>(
INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openDrawer()
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, openDrawer])
const onSelect = useCallback(
({ collectionConfig, docID }) => {
insertRelationship({
id: docID,
editor,
relationTo: collectionConfig.slug,
replaceNodeKey,
})
closeDrawer()
},
[editor, closeDrawer, replaceNodeKey],
)
useEffect(() => {
// always reset back to first option
// TODO: this is not working, see the ListDrawer component
setSelectedCollectionSlug(enabledCollectionSlugs[0])
}, [isDrawerOpen, enabledCollectionSlugs])
return <ListDrawer onSelect={onSelect} />
}
export const RelationshipDrawer = (props: Props): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props}>
<RelationshipDrawerComponent {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,52 @@
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { RelationshipIcon } from '../../lexical/ui/icons/Relationship'
import { relationshipAfterReadPromise } from './afterReadPromise'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss'
import { RelationshipNode } from './nodes/RelationshipNode'
import RelationshipPlugin from './plugins'
export const RelationshipFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
nodes: [
{
afterReadPromises: [relationshipAfterReadPromise],
node: RelationshipNode,
type: RelationshipNode.getType(),
},
],
plugins: [
{
Component: RelationshipPlugin,
position: 'normal',
},
],
props: null,
slashMenu: {
options: [
{
options: [
new SlashMenuOption('Relationship', {
Icon: RelationshipIcon,
keywords: ['relationship', 'relation', 'rel'],
onSelect: ({ editor, queryString }) => {
// dispatch INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND
editor.dispatchCommand(INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND, {
replace: false,
})
},
}),
],
title: 'Basic',
},
],
},
}
},
key: 'relationship',
}
}

View File

@@ -0,0 +1,161 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
ElementFormatType,
LexicalEditor,
LexicalNode,
NodeKey,
Spread,
} from 'lexical'
import {
DecoratorBlockNode,
type SerializedDecoratorBlockNode,
} from '@lexical/react/LexicalDecoratorBlockNode'
import * as React from 'react'
import { RelationshipComponent } from './components/RelationshipComponent'
export type RelationshipFields = {
data: null | unknown
id: string
relationTo: string
}
export type SerializedRelationshipNode = Spread<
{
fields: RelationshipFields
},
SerializedDecoratorBlockNode
>
function relationshipElementToNode(domNode: HTMLDivElement): DOMConversionOutput | null {
const id = domNode.getAttribute('data-lexical-relationship-id')
const relationTo = domNode.getAttribute('data-lexical-relationship-relationTo')
if (id != null && relationTo != null) {
const node = $createRelationshipNode({
id,
data: null,
relationTo,
})
return { node }
}
return null
}
export class RelationshipNode extends DecoratorBlockNode {
__fields: RelationshipFields
constructor({
fields,
format,
key,
}: {
fields: RelationshipFields
format?: ElementFormatType
key?: NodeKey
}) {
super(format, key)
this.__fields = fields
}
static clone(node: RelationshipNode): RelationshipNode {
return new RelationshipNode({
fields: node.__fields,
format: node.__format,
key: node.__key,
})
}
static getType(): string {
return 'relationship'
}
static importDOM(): DOMConversionMap<HTMLDivElement> | null {
return {
div: (domNode: HTMLDivElement) => {
if (
!domNode.hasAttribute('data-lexical-relationship-relationTo') ||
!domNode.hasAttribute('data-lexical-relationship-id')
) {
return null
}
return {
conversion: relationshipElementToNode,
priority: 2,
}
},
}
}
static importJSON(serializedNode: SerializedRelationshipNode): RelationshipNode {
const node = $createRelationshipNode(serializedNode.fields)
node.setFormat(serializedNode.format)
return node
}
static isInline(): false {
return false
}
decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element {
return (
<RelationshipComponent
className={config.theme.relationship ?? 'LexicalEditorTheme__relationship'}
fields={this.__fields}
format={this.__format}
nodeKey={this.getKey()}
/>
)
}
exportDOM(): DOMExportOutput {
const element = document.createElement('div')
element.setAttribute('data-lexical-relationship-id', this.__fields?.id)
element.setAttribute('data-lexical-relationship-relationTo', this.__fields?.relationTo)
const text = document.createTextNode(this.getTextContent())
element.append(text)
return { element }
}
exportJSON(): SerializedRelationshipNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
version: 1,
}
}
getFields(): RelationshipFields {
return this.getLatest().__fields
}
getId(): string {
return this.__id
}
getTextContent(): string {
return `${this?.__fields?.relationTo} relation to ${this.__fields?.id}`
}
setFields(fields: RelationshipFields): void {
const writable = this.getWritable()
writable.__fields = fields
}
}
export function $createRelationshipNode(fields: RelationshipFields): RelationshipNode {
return new RelationshipNode({
fields,
})
}
export function $isRelationshipNode(
node: LexicalNode | RelationshipNode | null | undefined,
): node is RelationshipNode {
return node instanceof RelationshipNode
}

View File

@@ -0,0 +1,140 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { $getNodeByKey, type ElementFormatType } from 'lexical'
import { Button } from 'payload/components'
import { useDocumentDrawer } from 'payload/components/elements'
import { usePayloadAPI } from 'payload/components/hooks'
import { useConfig } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { RelationshipFields } from '../RelationshipNode'
import { useEditorConfigContext } from '../../../../lexical/config/EditorConfigProvider'
import { INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND } from '../../drawer'
import { EnabledRelationshipsCondition } from '../../utils/EnabledRelationshipsCondition'
import './index.scss'
const baseClass = 'lexical-relationship'
const initialParams = {
depth: 0,
}
type Props = {
children?: React.ReactNode
className?: string
fields: RelationshipFields
format?: ElementFormatType
nodeKey?: string
}
const Component: React.FC<Props> = (props) => {
const {
children,
fields: { id, relationTo },
nodeKey,
} = props
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const { field } = useEditorConfigContext()
const {
collections,
routes: { api },
serverURL,
} = useConfig()
const [relatedCollection, setRelatedCollection] = useState(() =>
collections.find((coll) => coll.slug === relationTo),
)
const { i18n, t } = useTranslation(['fields', 'general'])
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${id}`,
{ initialParams },
)
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
id: id,
collectionSlug: relatedCollection.slug,
})
const removeRelationship = useCallback(() => {
editor.update(() => {
$getNodeByKey(nodeKey).remove()
})
}, [editor, nodeKey])
const updateRelationship = React.useCallback(
({ doc }) => {
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
closeDrawer()
dispatchCacheBust()
},
[cacheBust, setParams, closeDrawer],
)
return (
<div
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
contentEditable={false}
>
<div className={`${baseClass}__wrap`}>
<p className={`${baseClass}__label`}>
{t('labelRelationship', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
</p>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<p className={`${baseClass}__title`}>
{data[relatedCollection?.admin?.useAsTitle || 'id']}
</p>
</DocumentDrawerToggler>
</div>
<div className={`${baseClass}__actions`}>
<Button
buttonStyle="icon-label"
disabled={field?.admin?.readOnly}
el="div"
icon="swap"
onClick={() => {
editor.dispatchCommand(INSERT_RELATIONSHIP_WITH_DRAWER_COMMAND, {
replace: { nodeKey },
})
}}
round
tooltip={t('swapRelationship')}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.admin?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeRelationship()
}}
round
tooltip={t('fields:removeRelationship')}
/>
</div>
{id && <DocumentDrawer onSave={updateRelationship} />}
{children}
</div>
)
}
export const RelationshipComponent = (props: Props): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props}>
<Component {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1,89 @@
@import 'payload/scss';
.lexical-relationship {
@extend %body;
@include shadow-sm;
padding: base(0.75);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
max-width: base(15);
font-family: var(--font-body);
&:hover {
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
&__label {
margin-bottom: base(0.25);
}
&__title {
margin: 0;
}
&__label,
&__title {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
line-height: 1 !important;
}
&__title {
font-weight: bold;
}
&__wrap {
flex-grow: 1;
overflow: hidden;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
&__doc-drawer-toggler {
text-decoration: underline;
pointer-events: all;
line-height: inherit;
& > * {
margin: 0;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
& > *:not(:last-child) {
margin-right: base(0.25);
}
}
&__removeButton {
margin: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
}

View File

@@ -0,0 +1,39 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodeToNearestRoot } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, type LexicalCommand, createCommand } from 'lexical'
import { useConfig } from 'payload/components/utilities'
import { useEffect } from 'react'
import React from 'react'
import type { RelationshipFields } from '../nodes/RelationshipNode'
import { RelationshipDrawer } from '../drawer'
import { $createRelationshipNode, RelationshipNode } from '../nodes/RelationshipNode'
export const INSERT_RELATIONSHIP_COMMAND: LexicalCommand<RelationshipFields> = createCommand(
'INSERT_RELATIONSHIP_COMMAND',
)
export default function RelationshipPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const { collections } = useConfig()
useEffect(() => {
if (!editor.hasNodes([RelationshipNode])) {
throw new Error('RelationshipPlugin: RelationshipNode not registered on editor')
}
return editor.registerCommand<RelationshipFields>(
INSERT_RELATIONSHIP_COMMAND,
(payload) => {
const relationshipNode = $createRelationshipNode(payload)
$insertNodeToNearestRoot(relationshipNode)
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor])
return <RelationshipDrawer enabledCollectionSlugs={collections.map(({ slug }) => slug)} />
}

View File

@@ -0,0 +1,34 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import { useConfig } from 'payload/components/utilities'
import * as React from 'react'
type options = { uploads: boolean }
type FilteredCollectionsT = (
collections: SanitizedCollectionConfig[],
options?: options,
) => SanitizedCollectionConfig[]
const filterRichTextCollections: FilteredCollectionsT = (collections, options) => {
return collections.filter(({ admin: { enableRichTextRelationship }, upload }) => {
if (options?.uploads) {
return enableRichTextRelationship && Boolean(upload) === true
}
return upload ? false : enableRichTextRelationship
})
}
export const EnabledRelationshipsCondition: React.FC<any> = (props) => {
const { children, uploads = false, ...rest } = props
const { collections } = useConfig()
const [enabledCollectionSlugs] = React.useState(() =>
filterRichTextCollections(collections, { uploads }).map(({ slug }) => slug),
)
if (!enabledCollectionSlugs.length) {
return null
}
return React.cloneElement(children, { ...rest, enabledCollectionSlugs })
}

View File

@@ -0,0 +1,61 @@
import type { UploadFeatureProps } from '.'
import type { AfterReadPromise } from '../types'
import type { SerializedUploadNode } from './nodes/UploadNode'
import { populate } from '../../../populate/populate'
import { recurseNestedFields } from '../../../populate/recurseNestedFields'
export const uploadAfterReadPromiseHOC = (
props?: UploadFeatureProps,
): AfterReadPromise<SerializedUploadNode> => {
const uploadAfterReadPromise: AfterReadPromise<SerializedUploadNode> = ({
afterReadPromises,
currentDepth,
depth,
field,
node,
overrideAccess,
req,
showHiddenFields,
}) => {
const promises: Promise<void>[] = []
if (node?.fields?.value?.id) {
const collection = req.payload.collections[node?.fields?.relationTo]
if (collection) {
promises.push(
populate({
id: node?.fields?.value?.id,
collection,
currentDepth,
data: node.fields,
depth,
field,
key: 'value',
overrideAccess,
req,
showHiddenFields,
}),
)
}
if (Array.isArray(props?.collections?.[node?.fields?.relationTo]?.fields)) {
recurseNestedFields({
afterReadPromises,
currentDepth,
data: node.fields || {},
depth,
fields: props?.collections?.[node?.fields?.relationTo]?.fields,
overrideAccess,
promises,
req,
showHiddenFields,
})
}
}
return promises
}
return uploadAfterReadPromise
}

View File

@@ -0,0 +1,105 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import { useModal } from '@faceless-ui/modal'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $getNodeByKey } from 'lexical'
import { Drawer } from 'payload/components/elements'
import { Form, FormSubmit, RenderFields } from 'payload/components/forms'
import {
buildStateFromSchema,
useAuth,
useDocumentInfo,
useLocale,
} from 'payload/components/utilities'
import { fieldTypes } from 'payload/config'
import { deepCopyObject, getTranslation } from 'payload/utilities'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { ElementProps } from '..'
import type { UploadFeatureProps } from '../..'
import type { UploadNode } from '../../nodes/UploadNode'
import { useEditorConfigContext } from '../../../../lexical/config/EditorConfigProvider'
/**
* This handles the extra fields, e.g. captions or alt text, which are
* potentially added to the upload feature.
*/
export const ExtraFieldsUploadDrawer: React.FC<
ElementProps & {
drawerSlug: string
relatedCollection: SanitizedCollectionConfig
}
> = (props) => {
const {
drawerSlug,
fields: { relationTo, value },
fields,
nodeKey,
relatedCollection,
} = props
const [editor] = useLexicalComposerContext()
const { editorConfig, field } = useEditorConfigContext()
const { i18n, t } = useTranslation()
const { code: locale } = useLocale()
const { user } = useAuth()
const { closeModal } = useModal()
const { getDocPreferences } = useDocumentInfo()
const [initialState, setInitialState] = useState({})
const fieldSchema = (editorConfig?.resolvedFeatureMap.get('upload')?.props as UploadFeatureProps)
?.collections?.[relatedCollection.slug]?.fields
const handleUpdateEditData = useCallback(
(_, data) => {
// Update lexical node (with key nodeKey) with new data
editor.update(() => {
const uploadNode: UploadNode | null = $getNodeByKey(nodeKey)
if (uploadNode) {
const newFields = {
...uploadNode.getFields(),
...data,
}
uploadNode.setFields(newFields)
}
})
closeModal(drawerSlug)
},
[closeModal, editor, drawerSlug, nodeKey],
)
useEffect(() => {
const awaitInitialState = async () => {
const preferences = await getDocPreferences()
const state = await buildStateFromSchema({
data: deepCopyObject(fields || {}),
fieldSchema,
locale,
operation: 'update',
preferences,
t,
user,
})
setInitialState(state)
}
void awaitInitialState()
}, [user, locale, t, getDocPreferences, fields, fieldSchema])
return (
<Drawer
slug={drawerSlug}
title={t('general:editLabel', {
label: getTranslation(relatedCollection.labels.singular, i18n),
})}
>
<Form initialState={initialState} onSubmit={handleUpdateEditData}>
<RenderFields fieldSchema={fieldSchema} fieldTypes={fieldTypes} readOnly={false} />
<FormSubmit>{t('fields:saveChanges')}</FormSubmit>
</Form>
</Drawer>
)
}

View File

@@ -0,0 +1,151 @@
@import 'payload/scss';
.lexical-upload {
@extend %body;
@include shadow-sm;
max-width: base(15);
display: flex;
align-items: center;
background: var(--theme-input-bg);
border: 1px solid var(--theme-elevation-100);
position: relative;
font-family: var(--font-body);
.btn {
margin: 0;
}
&:hover {
border: 1px solid var(--theme-elevation-150);
}
&[data-slate-node='element'] {
margin: base(0.625) 0;
}
&__card {
@include soft-shadow-bottom;
display: flex;
flex-direction: column;
width: 100%;
}
&__topRow {
display: flex;
}
&__thumbnail {
width: base(3.25);
height: auto;
position: relative;
overflow: hidden;
flex-shrink: 0;
img,
svg {
position: absolute;
object-fit: cover;
width: 100%;
height: 100%;
background-color: var(--theme-elevation-800);
}
}
&__topRowRightPanel {
flex-grow: 1;
display: flex;
align-items: center;
padding: base(0.75);
justify-content: space-between;
max-width: calc(100% - #{base(3.25)});
}
&__actions {
display: flex;
align-items: center;
flex-shrink: 0;
margin-left: base(0.5);
.lexical-upload__doc-drawer-toggler {
pointer-events: all;
}
& > *:not(:last-child) {
margin-right: base(0.25);
}
}
&__removeButton {
margin: 0;
line {
stroke-width: $style-stroke-width-m;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__upload-drawer-toggler {
background-color: transparent;
border: none;
padding: 0;
margin: 0;
outline: none;
line-height: inherit;
}
&__doc-drawer-toggler {
text-decoration: underline;
}
&__doc-drawer-toggler,
&__list-drawer-toggler,
&__upload-drawer-toggler {
& > * {
margin: 0;
}
&:disabled {
color: var(--theme-elevation-300);
pointer-events: none;
}
}
&__collectionLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__bottomRow {
padding: base(0.5);
border-top: 1px solid var(--theme-elevation-100);
}
h5 {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
&__wrap {
padding: base(0.5) base(0.5) base(0.5) base(1);
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
}
&--selected {
box-shadow: $focus-box-shadow;
outline: none;
}
@include small-break {
&__topRowRightPanel {
padding: base(0.75) base(0.5);
}
}
}

View File

@@ -0,0 +1,180 @@
import type { SanitizedCollectionConfig } from 'payload/types'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { $getNodeByKey } from 'lexical'
import { Button } from 'payload/components'
import { DrawerToggler, useDocumentDrawer, useDrawerSlug } from 'payload/components/elements'
import { FileGraphic } from 'payload/components/graphics'
import { usePayloadAPI, useThumbnail } from 'payload/components/hooks'
import { useConfig } from 'payload/components/utilities'
import { getTranslation } from 'payload/utilities'
import React, { useCallback, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { UploadFeatureProps } from '..'
import type { UploadFields } from '../nodes/UploadNode'
import { useEditorConfigContext } from '../../../lexical/config/EditorConfigProvider'
import { EnabledRelationshipsCondition } from '../../Relationship/utils/EnabledRelationshipsCondition'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from '../drawer'
import { ExtraFieldsUploadDrawer } from './ExtraFieldsDrawer'
import './index.scss'
const baseClass = 'lexical-upload'
const initialParams = {
depth: 0,
}
export type ElementProps = {
fields: UploadFields
nodeKey: string
//uploadProps: UploadFeatureProps
}
const Component: React.FC<ElementProps> = (props) => {
const {
fields: { relationTo, value },
nodeKey,
} = props
const {
collections,
routes: { api },
serverURL,
} = useConfig()
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const { editorConfig, field } = useEditorConfigContext()
const { i18n, t } = useTranslation('fields')
const [cacheBust, dispatchCacheBust] = useReducer((state) => state + 1, 0)
const [relatedCollection, setRelatedCollection] = useState<SanitizedCollectionConfig>(() =>
collections.find((coll) => coll.slug === relationTo),
)
const drawerSlug = useDrawerSlug('upload-drawer')
const [DocumentDrawer, DocumentDrawerToggler, { closeDrawer }] = useDocumentDrawer({
id: value?.id,
collectionSlug: relatedCollection.slug,
})
// Get the referenced document
const [{ data }, { setParams }] = usePayloadAPI(
`${serverURL}${api}/${relatedCollection.slug}/${value?.id}`,
{ initialParams },
)
const thumbnailSRC = useThumbnail(relatedCollection, data)
const removeUpload = useCallback(() => {
editor.update(() => {
$getNodeByKey(nodeKey).remove()
})
}, [editor, nodeKey])
const updateUpload = useCallback(
(json) => {
setParams({
...initialParams,
cacheBust, // do this to get the usePayloadAPI to re-fetch the data even though the URL string hasn't changed
})
dispatchCacheBust()
closeDrawer()
},
[setParams, cacheBust, closeDrawer],
)
const customFields = (
editorConfig?.resolvedFeatureMap?.get('upload')?.props as UploadFeatureProps
)?.collections?.[relatedCollection.slug]?.fields
return (
<div
className={[baseClass, isSelected && `${baseClass}--selected`].filter(Boolean).join(' ')}
contentEditable={false}
>
<div className={`${baseClass}__card`}>
<div className={`${baseClass}__topRow`}>
<div className={`${baseClass}__thumbnail`}>
{thumbnailSRC ? <img alt={data?.filename} src={thumbnailSRC} /> : <FileGraphic />}
</div>
<div className={`${baseClass}__topRowRightPanel`}>
<div className={`${baseClass}__collectionLabel`}>
{getTranslation(relatedCollection.labels.singular, i18n)}
</div>
<div className={`${baseClass}__actions`}>
{customFields?.length > 0 && (
<DrawerToggler
className={`${baseClass}__upload-drawer-toggler`}
disabled={field?.admin?.readOnly}
slug={drawerSlug}
>
<Button
buttonStyle="icon-label"
el="div"
icon="edit"
onClick={(e) => {
e.preventDefault()
}}
round
tooltip={t('fields:editRelationship')}
/>
</DrawerToggler>
)}
<Button
buttonStyle="icon-label"
disabled={field?.admin?.readOnly}
el="div"
icon="swap"
onClick={() => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: { nodeKey },
})
}}
round
tooltip={t('swapUpload')}
/>
<Button
buttonStyle="icon-label"
className={`${baseClass}__removeButton`}
disabled={field?.admin?.readOnly}
icon="x"
onClick={(e) => {
e.preventDefault()
removeUpload()
}}
round
tooltip={t('removeUpload')}
/>
</div>
</div>
</div>
<div className={`${baseClass}__bottomRow`}>
<DocumentDrawerToggler className={`${baseClass}__doc-drawer-toggler`}>
<strong>{data?.filename}</strong>
</DocumentDrawerToggler>
</div>
</div>
{value?.id && <DocumentDrawer onSave={updateUpload} />}
<ExtraFieldsUploadDrawer
drawerSlug={drawerSlug}
relatedCollection={relatedCollection}
{...props}
/>
</div>
)
}
export default (props: ElementProps): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<Component {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,108 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import {
$getNodeByKey,
COMMAND_PRIORITY_EDITOR,
type LexicalCommand,
type LexicalEditor,
createCommand,
} from 'lexical'
import { useListDrawer } from 'payload/components/elements'
import React, { useCallback, useEffect, useState } from 'react'
import { EnabledRelationshipsCondition } from '../../Relationship/utils/EnabledRelationshipsCondition'
import { $createUploadNode } from '../nodes/UploadNode'
import { INSERT_UPLOAD_COMMAND } from '../plugin'
import './index.scss'
const baseClass = 'lexical-upload-drawer'
export const INSERT_UPLOAD_WITH_DRAWER_COMMAND: LexicalCommand<{
replace: { nodeKey: string } | false
}> = createCommand('INSERT_UPLOAD_WITH_DRAWER_COMMAND')
const insertUpload = ({
id,
editor,
relationTo,
replaceNodeKey,
}: {
editor: LexicalEditor
id: string
relationTo: string
replaceNodeKey: null | string
}) => {
if (!replaceNodeKey) {
editor.dispatchCommand(INSERT_UPLOAD_COMMAND, {
id,
relationTo,
})
} else {
editor.update(() => {
const node = $getNodeByKey(replaceNodeKey)
if (node) {
node.replace(
$createUploadNode({
fields: {
relationTo,
value: {
id,
},
},
}),
)
}
})
}
}
type Props = {
enabledCollectionSlugs: string[]
}
const UploadDrawerComponent: React.FC<Props> = ({ enabledCollectionSlugs }) => {
const [editor] = useLexicalComposerContext()
const [replaceNodeKey, setReplaceNodeKey] = useState<null | string>(null)
const [ListDrawer, ListDrawerToggler, { closeDrawer, openDrawer }] = useListDrawer({
collectionSlugs: enabledCollectionSlugs,
uploads: true,
})
useEffect(() => {
return editor.registerCommand<{
replace: { nodeKey: string } | false
}>(
INSERT_UPLOAD_WITH_DRAWER_COMMAND,
(payload) => {
setReplaceNodeKey(payload?.replace ? payload?.replace.nodeKey : null)
openDrawer()
return true
},
COMMAND_PRIORITY_EDITOR,
)
}, [editor, openDrawer])
const onSelect = useCallback(
({ collectionConfig, docID }) => {
insertUpload({
id: docID,
editor,
relationTo: collectionConfig.slug,
replaceNodeKey,
})
closeDrawer()
},
[editor, closeDrawer, replaceNodeKey],
)
return <ListDrawer onSelect={onSelect} />
}
export const UploadDrawer = (props: Props): React.ReactNode => {
return (
<EnabledRelationshipsCondition {...props} uploads>
<UploadDrawerComponent {...props} />
</EnabledRelationshipsCondition>
)
}

View File

@@ -0,0 +1 @@
@import 'payload/scss';

View File

@@ -0,0 +1,61 @@
import type { Field } from 'payload/types'
import type { FeatureProvider } from '../types'
import { SlashMenuOption } from '../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { UploadIcon } from '../../lexical/ui/icons/Upload'
import { uploadAfterReadPromiseHOC } from './afterReadPromise'
import { INSERT_UPLOAD_WITH_DRAWER_COMMAND } from './drawer'
import './index.scss'
import { UploadNode } from './nodes/UploadNode'
import { UploadPlugin } from './plugin'
export type UploadFeatureProps = {
collections: {
[collection: string]: {
fields: Field[]
}
}
}
export const UploadFeature = (props?: UploadFeatureProps): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
nodes: [
{
afterReadPromises: [uploadAfterReadPromiseHOC(props)],
node: UploadNode,
type: UploadNode.getType(),
},
],
plugins: [
{
Component: UploadPlugin,
position: 'normal',
},
],
props: props,
slashMenu: {
options: [
{
options: [
new SlashMenuOption('Upload', {
Icon: UploadIcon,
keywords: ['upload', 'image', 'file', 'img', 'picture', 'photo', 'media'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_UPLOAD_WITH_DRAWER_COMMAND, {
replace: false,
})
},
}),
],
title: 'Basic',
},
],
},
}
},
key: 'upload',
}
}

View File

@@ -0,0 +1,4 @@
@import 'payload/scss';
.editor-upload {
}

View File

@@ -0,0 +1,146 @@
import type { SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import type { ElementFormatType, NodeKey } from 'lexical'
import { DecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import {
$applyNodeReplacement,
type DOMConversionMap,
type DOMConversionOutput,
type DOMExportOutput,
type LexicalNode,
type Spread,
} from 'lexical'
import * as React from 'react'
import { UploadFeatureProps } from '..'
// @ts-expect-error TypeScript being dumb
const RawUploadComponent = React.lazy(async () => await import('../component'))
export interface RawUploadPayload {
id: string
relationTo: string
}
export type UploadFields = {
// unknown, custom fields:
[key: string]: unknown
relationTo: string
value: {
// Actual upload data, populated in afterRead hook
[key: string]: unknown
id: string
}
}
function convertUploadElement(domNode: Node): DOMConversionOutput | null {
if (domNode instanceof HTMLImageElement) {
// const { alt: altText, src } = domNode;
// const node = $createImageNode({ altText, src });
// return { node };
// TODO: Auto-upload functionality here!
}
return null
}
export type SerializedUploadNode = Spread<
{
fields: UploadFields
},
SerializedDecoratorBlockNode
>
export class UploadNode extends DecoratorBlockNode {
__fields: UploadFields
constructor({
fields,
format,
key,
}: {
fields: UploadFields
format?: ElementFormatType
key?: NodeKey
}) {
super(format, key)
this.__fields = fields
}
static clone(node: UploadNode): UploadNode {
return new UploadNode({
fields: node.__fields,
format: node.__format,
key: node.__key,
})
}
static getType(): string {
return 'upload'
}
static importDOM(): DOMConversionMap | null {
return {
img: (node: Node) => ({
conversion: convertUploadElement,
priority: 0,
}),
}
}
static importJSON(serializedNode: SerializedUploadNode): UploadNode {
const node = $createUploadNode({
fields: serializedNode.fields,
})
node.setFormat(serializedNode.format)
return node
}
static isInline(): false {
return false
}
decorate(): JSX.Element {
return (
<RawUploadComponent fields={this.__fields} format={this.__format} nodeKey={this.getKey()} />
)
}
exportDOM(): DOMExportOutput {
const element = document.createElement('img')
// element.setAttribute('src', this.__src);
// element.setAttribute('alt', this.__altText); //TODO
return { element }
}
exportJSON(): SerializedUploadNode {
return {
...super.exportJSON(),
fields: this.getFields(),
type: this.getType(),
version: 1,
}
}
getFields(): UploadFields {
return this.getLatest().__fields
}
setFields(fields: UploadFields): void {
const writable = this.getWritable()
writable.__fields = fields
}
// eslint-disable-next-line class-methods-use-this
updateDOM(): false {
return false
}
}
export function $createUploadNode({ fields }: { fields: UploadFields }): UploadNode {
return $applyNodeReplacement(new UploadNode({ fields }))
}
export function $isUploadNode(node: LexicalNode | null | undefined): node is UploadNode {
return node instanceof UploadNode
}

View File

@@ -0,0 +1,51 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $insertNodeToNearestRoot, mergeRegister } from '@lexical/utils'
import { COMMAND_PRIORITY_EDITOR, type LexicalCommand, createCommand } from 'lexical'
import { useConfig } from 'payload/components/utilities'
import React, { useEffect } from 'react'
import type { RawUploadPayload } from '../nodes/UploadNode'
import { UploadDrawer } from '../drawer'
import { $createUploadNode, UploadNode } from '../nodes/UploadNode'
export type InsertUploadPayload = Readonly<RawUploadPayload>
export const INSERT_UPLOAD_COMMAND: LexicalCommand<InsertUploadPayload> =
createCommand('INSERT_UPLOAD_COMMAND')
export function UploadPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext()
const { collections } = useConfig()
useEffect(() => {
if (!editor.hasNodes([UploadNode])) {
throw new Error('UploadPlugin: UploadNode not registered on editor')
}
return mergeRegister(
editor.registerCommand<InsertUploadPayload>(
INSERT_UPLOAD_COMMAND,
(payload: InsertUploadPayload) => {
editor.update(() => {
const uploadNode = $createUploadNode({
fields: {
relationTo: payload.relationTo,
value: {
id: payload.id,
},
},
})
$insertNodeToNearestRoot(uploadNode)
})
return true
},
COMMAND_PRIORITY_EDITOR,
),
)
}, [editor])
return <UploadDrawer enabledCollectionSlugs={collections.map(({ slug }) => slug)} />
}

View File

@@ -0,0 +1,19 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../lexical/plugins/FloatingSelectToolbar/types'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft'
import './index.scss'
export const AlignDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
ChildComponent: AlignLeftIcon,
entries,
key: 'dropdown-align',
order: 2,
type: 'dropdown',
}
}

View File

@@ -0,0 +1,4 @@
.floating-select-toolbar-popup__section-dropdown-align {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,59 @@
import { FORMAT_ELEMENT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../types'
import { AlignCenterIcon } from '../../lexical/ui/icons/AlignCenter'
import { AlignLeftIcon } from '../../lexical/ui/icons/AlignLeft'
import { AlignDropdownSectionWithEntries } from './floatingSelectToolbarAlignDropdownSection'
import './index.scss'
export const AlignFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignLeftIcon,
isActive: ({ editor, selection }) => false,
key: 'align-left',
label: `Align Left`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'left')
},
order: 1,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignCenterIcon,
isActive: ({ editor, selection }) => false,
key: 'align-center',
label: `Align Center`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'center')
},
order: 2,
},
]),
AlignDropdownSectionWithEntries([
{
ChildComponent: AlignLeftIcon,
isActive: ({ editor, selection }) => false,
key: 'align-right',
label: `Align Right`,
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, 'right')
},
order: 3,
},
]),
],
},
props: null,
}
},
key: 'align',
}
}

View File

@@ -0,0 +1,4 @@
.floating-select-toolbar-popup__section-features {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,17 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types'
import './index.scss'
export const FeaturesSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
entries,
key: 'features',
order: 5,
type: 'buttons',
}
}

View File

@@ -0,0 +1,4 @@
.floating-select-toolbar-popup__section-dropdown-text {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,19 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types'
import { TextIcon } from '../../../lexical/ui/icons/Text'
import './index.scss'
export const TextDropdownSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
ChildComponent: TextIcon,
entries,
key: 'dropdown-text',
order: 1,
type: 'dropdown',
}
}

View File

@@ -0,0 +1,78 @@
.tree-view-output {
display: block;
background: #222;
color: #fff;
padding: 0;
font-size: 12px;
margin: 1px auto 10px auto;
position: relative;
overflow: hidden;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
pre {
line-height: 1.1;
background: #222;
color: #fff;
margin: 0;
padding: 10px;
font-size: 12px;
overflow: auto;
max-height: 400px;
}
.debug-treetype-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 85px;
position: absolute;
background: none;
color: #fff;
&:hover {
text-decoration: underline;
}
}
.debug-timetravel-button {
border: 0;
padding: 0;
font-size: 12px;
top: 10px;
right: 15px;
position: absolute;
background: none;
color: #fff;
&:hover {
text-decoration: underline;
}
}
.debug-timetravel-panel {
overflow: hidden;
padding: 0 0 10px;
margin: auto;
display: flex;
&-button {
padding: 0;
border: 0;
background: none;
flex: 1;
color: #fff;
font-size: 12px;
&:hover {
text-decoration: underline;
}
}
&-slider {
padding: 0;
flex: 8;
}
}
}

View File

@@ -0,0 +1,21 @@
import type { FeatureProvider } from '../../types'
import './index.scss'
import { TreeViewPlugin } from './plugin'
export const TreeviewFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
plugins: [
{
Component: TreeViewPlugin,
position: 'normal',
},
],
props: null,
}
},
key: 'debug-treeview',
}
}

View File

@@ -0,0 +1,18 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { TreeView } from '@lexical/react/LexicalTreeView'
import * as React from 'react'
export function TreeViewPlugin(): JSX.Element {
const [editor] = useLexicalComposerContext()
return (
<TreeView
editor={editor}
timeTravelButtonClassName="debug-timetravel-button"
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
timeTravelPanelClassName="debug-timetravel-panel"
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
treeTypeButtonClassName="debug-treetype-button"
viewClassName="tree-view-output"
/>
)
}

View File

@@ -0,0 +1,50 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { BoldIcon } from '../../../lexical/ui/icons/Bold'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
import {
BOLD_ITALIC_STAR,
BOLD_ITALIC_UNDERSCORE,
BOLD_STAR,
BOLD_UNDERSCORE,
} from './markdownTransformers'
export const BoldTextFeature = (): FeatureProvider => {
return {
dependenciesSoft: ['italic'],
feature: ({ featureProviderMap }) => {
const markdownTransformers = [BOLD_STAR, BOLD_UNDERSCORE]
if (featureProviderMap.get('italic')) {
markdownTransformers.push(BOLD_ITALIC_UNDERSCORE, BOLD_ITALIC_STAR)
}
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: BoldIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('bold')
}
return false
},
key: 'bold',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold')
},
order: 1,
},
]),
],
},
markdownTransformers: markdownTransformers,
props: null,
}
},
key: 'bold',
}
}

View File

@@ -0,0 +1,27 @@
import type { TextFormatTransformer } from '@lexical/markdown'
export const BOLD_ITALIC_STAR: TextFormatTransformer = {
format: ['bold', 'italic'],
tag: '***',
type: 'text-format',
}
export const BOLD_ITALIC_UNDERSCORE: TextFormatTransformer = {
format: ['bold', 'italic'],
intraword: false,
tag: '___',
type: 'text-format',
}
export const BOLD_STAR: TextFormatTransformer = {
format: ['bold'],
tag: '**',
type: 'text-format',
}
export const BOLD_UNDERSCORE: TextFormatTransformer = {
format: ['bold'],
intraword: false,
tag: '__',
type: 'text-format',
}

View File

@@ -0,0 +1,39 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { CodeIcon } from '../../../lexical/ui/icons/Code'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
import { INLINE_CODE } from './markdownTransformers'
export const InlineCodeTextFeature = (): FeatureProvider => {
return {
feature: ({ featureProviderMap }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: CodeIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('code')
}
return false
},
key: 'code',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code')
},
order: 7,
},
]),
],
},
markdownTransformers: [INLINE_CODE],
props: null,
}
},
key: 'inlineCode',
}
}

View File

@@ -0,0 +1,7 @@
import type { TextFormatTransformer } from '@lexical/markdown'
export const INLINE_CODE: TextFormatTransformer = {
format: ['code'],
tag: '`',
type: 'text-format',
}

View File

@@ -0,0 +1,39 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { ItalicIcon } from '../../../lexical/ui/icons/Italic'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
import { ITALIC_STAR, ITALIC_UNDERSCORE } from './markdownTransformers'
export const ItalicTextFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: ItalicIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('italic')
}
return false
},
key: 'italic',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic')
},
order: 2,
},
]),
],
},
markdownTransformers: [ITALIC_STAR, ITALIC_UNDERSCORE],
props: null,
}
},
key: 'italic',
}
}

View File

@@ -0,0 +1,14 @@
import type { TextFormatTransformer } from '@lexical/markdown'
export const ITALIC_STAR: TextFormatTransformer = {
format: ['italic'],
tag: '*',
type: 'text-format',
}
export const ITALIC_UNDERSCORE: TextFormatTransformer = {
format: ['italic'],
intraword: false,
tag: '_',
type: 'text-format',
}

View File

@@ -0,0 +1,17 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../../lexical/plugins/FloatingSelectToolbar/types'
import './index.scss'
export const SectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
entries,
key: 'format',
order: 4,
type: 'buttons',
}
}

View File

@@ -0,0 +1,4 @@
.floating-select-toolbar-popup__section-format {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,40 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { StrikethroughIcon } from '../../../lexical/ui/icons/Strikethrough'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
import { STRIKETHROUGH } from './markdownTransformers'
export const StrikethroughTextFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: StrikethroughIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('strikethrough')
}
return false
},
key: 'strikethrough',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough')
},
order: 4,
},
]),
],
},
markdownTransformers: [STRIKETHROUGH],
props: null,
props: null,
}
},
key: 'strikethrough',
}
}

View File

@@ -0,0 +1,7 @@
import type { TextFormatTransformer } from '@lexical/markdown'
export const STRIKETHROUGH: TextFormatTransformer = {
format: ['strikethrough'],
tag: '~~',
type: 'text-format',
}

View File

@@ -0,0 +1,36 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { SubscriptIcon } from '../../../lexical/ui/icons/Subscript'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
export const SubscriptTextFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: SubscriptIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('subscript')
}
return false
},
key: 'subscript',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript')
},
order: 5,
},
]),
],
},
}
},
key: 'subscript',
}
}

View File

@@ -0,0 +1,37 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { SuperscriptIcon } from '../../../lexical/ui/icons/Superscript'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
export const SuperscriptTextFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: SuperscriptIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('superscript')
}
return false
},
key: 'superscript',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript')
},
order: 6,
},
]),
],
},
props: null,
}
},
key: 'superscript',
}
}

View File

@@ -0,0 +1,37 @@
import { $isRangeSelection, FORMAT_TEXT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../../types'
import { UnderlineIcon } from '../../../lexical/ui/icons/Underline'
import { SectionWithEntries } from '../common/floatingSelectToolbarSection'
export const UnderlineTextFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
SectionWithEntries([
{
ChildComponent: UnderlineIcon,
isActive: ({ editor, selection }) => {
if ($isRangeSelection(selection)) {
return selection.hasFormat('underline')
}
return false
},
key: 'underline',
onClick: ({ editor }) => {
editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline')
},
order: 3,
},
]),
],
},
props: null,
}
},
key: 'underline',
}
}

View File

@@ -0,0 +1,17 @@
import type {
FloatingToolbarSection,
FloatingToolbarSectionEntry,
} from '../../lexical/plugins/FloatingSelectToolbar/types'
import './index.scss'
export const IndentSectionWithEntries = (
entries: FloatingToolbarSectionEntry[],
): FloatingToolbarSection => {
return {
entries,
key: 'indent',
order: 3,
type: 'buttons',
}
}

View File

@@ -0,0 +1,4 @@
.floating-select-toolbar-popup__section-indent {
display: flex;
gap: 2px;
}

View File

@@ -0,0 +1,59 @@
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from 'lexical'
import type { FeatureProvider } from '../types'
import { IndentDecreaseIcon } from '../../lexical/ui/icons/IndentDecrease'
import { IndentIncreaseIcon } from '../../lexical/ui/icons/IndentIncrease'
import { IndentSectionWithEntries } from './floatingSelectToolbarIndentSection'
import './index.scss'
export const IndentFeature = (): FeatureProvider => {
return {
feature: ({ resolvedFeatures, unsanitizedEditorConfig }) => {
return {
floatingSelectToolbar: {
sections: [
IndentSectionWithEntries([
{
ChildComponent: IndentDecreaseIcon,
isActive: ({ editor, selection }) => false,
isEnabled: ({ editor, selection }) => {
if (!selection || !selection?.getNodes()?.length) {
return false
}
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) {
return true
}
}
return false
},
key: 'indent-decrease',
label: `Decrease Indent`,
onClick: ({ editor }) => {
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)
},
order: 1,
},
]),
IndentSectionWithEntries([
{
ChildComponent: IndentIncreaseIcon,
isActive: ({ editor, selection }) => false,
key: 'indent-increase',
label: `Increase Indent`,
onClick: ({ editor }) => {
editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)
},
order: 2,
},
]),
],
},
props: null,
}
},
key: 'indent',
}
}

View File

@@ -0,0 +1,57 @@
import { INSERT_CHECK_LIST_COMMAND, ListItemNode, ListNode } from '@lexical/list'
import { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'
import type { FeatureProvider } from '../../types'
import { SlashMenuOption } from '../../../lexical/plugins/SlashMenu/LexicalTypeaheadMenuPlugin/LexicalMenu'
import { ChecklistIcon } from '../../../lexical/ui/icons/Checklist'
import { CHECK_LIST } from './markdownTransformers'
// 345
// carbs 7
export const CheckListFeature = (): FeatureProvider => {
return {
feature: ({ featureProviderMap, resolvedFeatures, unsanitizedEditorConfig }) => {
return {
markdownTransformers: [CHECK_LIST],
nodes:
featureProviderMap.has('unorderedList') || featureProviderMap.has('orderedList')
? []
: [
{
node: ListNode,
type: ListNode.getType(),
},
{
node: ListItemNode,
type: ListItemNode.getType(),
},
],
plugins: [
{
Component: CheckListPlugin,
position: 'normal',
},
],
props: null,
slashMenu: {
options: [
{
options: [
new SlashMenuOption('Check List', {
Icon: ChecklistIcon,
keywords: ['check list', 'check', 'checklist', 'cl'],
onSelect: ({ editor }) => {
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
},
}),
],
title: 'Lists',
},
],
},
}
},
key: 'checkList',
}
}

View File

@@ -0,0 +1,15 @@
import type { ElementTransformer } from '@lexical/markdown'
import { $isListNode, ListItemNode, ListNode } from '@lexical/list'
import { listExport, listReplace } from '../common/markdown'
export const CHECK_LIST: ElementTransformer = {
dependencies: [ListNode, ListItemNode],
export: (node, exportChildren) => {
return $isListNode(node) ? listExport(node, exportChildren, 0) : null
},
regExp: /^(\s*)(?:-\s)?\s?(\[(\s|x)?\])\s/i,
replace: listReplace('check'),
type: 'element',
}

Some files were not shown because too many files have changed in this diff Show More