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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export type Props = {
|
||||
customOnChange?: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
customValue?: string
|
||||
path: string
|
||||
readOnly: boolean
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1 +1,10 @@
|
||||
export * from '../auth'
|
||||
export type {
|
||||
CollectionPermission,
|
||||
FieldPermissions,
|
||||
GlobalPermission,
|
||||
IncomingAuthType,
|
||||
Permission,
|
||||
Permissions,
|
||||
User,
|
||||
VerifyConfig,
|
||||
} from '../auth/types'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './../types'
|
||||
|
||||
export type {
|
||||
CreateFormData,
|
||||
Data,
|
||||
Fields,
|
||||
FormField,
|
||||
FormFieldsContext,
|
||||
|
||||
@@ -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'
|
||||
|
||||
10
packages/richtext-lexical/.eslintignore
Normal file
10
packages/richtext-lexical/.eslintignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
37
packages/richtext-lexical/.eslintrc.cjs
Normal file
37
packages/richtext-lexical/.eslintrc.cjs
Normal 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,
|
||||
}
|
||||
10
packages/richtext-lexical/.prettierignore
Normal file
10
packages/richtext-lexical/.prettierignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.tmp
|
||||
**/.git
|
||||
**/.hg
|
||||
**/.pnp.*
|
||||
**/.svn
|
||||
**/.yarn/**
|
||||
**/build
|
||||
**/dist/**
|
||||
**/node_modules
|
||||
**/temp
|
||||
15
packages/richtext-lexical/.swcrc
Normal file
15
packages/richtext-lexical/.swcrc
Normal 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"
|
||||
}
|
||||
}
|
||||
22
packages/richtext-lexical/LICENSE.md
Normal file
22
packages/richtext-lexical/LICENSE.md
Normal 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.
|
||||
65
packages/richtext-lexical/package.json
Normal file
65
packages/richtext-lexical/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
45
packages/richtext-lexical/src/cell/index.tsx
Normal file
45
packages/richtext-lexical/src/cell/index.tsx
Normal 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>
|
||||
}
|
||||
106
packages/richtext-lexical/src/field/Field.tsx
Normal file
106
packages/richtext-lexical/src/field/Field.tsx
Normal 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)
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
112
packages/richtext-lexical/src/field/features/Link/index.tsx
Normal file
112
packages/richtext-lexical/src/field/features/Link/index.tsx
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
@import 'payload/scss';
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
@import 'payload/scss';
|
||||
|
||||
.editor-upload {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)} />
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.floating-select-toolbar-popup__section-dropdown-align {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
59
packages/richtext-lexical/src/field/features/align/index.ts
Normal file
59
packages/richtext-lexical/src/field/features/align/index.ts
Normal 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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.floating-select-toolbar-popup__section-features {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.floating-select-toolbar-popup__section-dropdown-text {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TextFormatTransformer } from '@lexical/markdown'
|
||||
|
||||
export const INLINE_CODE: TextFormatTransformer = {
|
||||
format: ['code'],
|
||||
tag: '`',
|
||||
type: 'text-format',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.floating-select-toolbar-popup__section-format {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { TextFormatTransformer } from '@lexical/markdown'
|
||||
|
||||
export const STRIKETHROUGH: TextFormatTransformer = {
|
||||
format: ['strikethrough'],
|
||||
tag: '~~',
|
||||
type: 'text-format',
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
.floating-select-toolbar-popup__section-indent {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user