Compare commits

..

9 Commits

Author SHA1 Message Date
Dan Ribbens
fddeb621ca chore: optimize db adapter calls 2024-02-15 15:00:55 -05:00
Elliot DeNolf
399e606b34 chore: use ref for pnpm overrides (#5081) 2024-02-13 12:37:43 -05:00
Alessio Gravili
0d18822062 feat(richtext-lexical)!: Update lexical from 0.12.6 to 0.13.1, port over all useful changes from playground (#5066)
* feat(richtext-lexical): Update lexical from 0.12.6 to 0.13.1, port over all useful changes from playground

* chore: upgrade lexical version used in monorepo
2024-02-12 17:54:50 +01:00
Alessio Gravili
00fc0343da feat(richtext-lexical): AddBlock handle for all nodes, even if they aren't empty paragraphs (#5063) 2024-02-12 16:11:41 +01:00
Alessio Gravili
6323965c65 fix(richtext-lexical): do not remove adjacent paragraph node when inserting certain nodes in empty editor (#5061) 2024-02-12 14:27:58 +01:00
Máté Tallósi
6d6823c3e5 feat(richtext-lexical): add justify aligment to AlignFeature (#4035) (#4868) 2024-02-12 14:27:12 +01:00
Alessio Gravili
ca70298436 chore: upgrade nodemon versions (#5059) 2024-02-12 14:11:57 +01:00
Elliot DeNolf
4f565759f6 chore(release): payload/2.11.0 [skip ci] 2024-02-09 16:12:03 -05:00
Jarrod Flesch
df39602758 feat: exposes collapsible provider with more functionality (#5043) 2024-02-09 10:38:30 -05:00
39 changed files with 582 additions and 678 deletions

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev Fields" type="NodeJSConfigurationType" application-parameters="fields" path-to-js-file="node_modules/.pnpm/nodemon@3.0.1/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
<configuration default="false" name="Run Dev Fields" type="NodeJSConfigurationType" application-parameters="fields" path-to-js-file="node_modules/.pnpm/nodemon@3.0.3/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
<method v="2" />
</configuration>
</component>

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev _community" type="NodeJSConfigurationType" application-parameters="_community" path-to-js-file="node_modules/.pnpm/nodemon@3.0.1/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
<configuration default="false" name="Run Dev _community" type="NodeJSConfigurationType" application-parameters="_community" path-to-js-file="node_modules/.pnpm/nodemon@3.0.3/node_modules/nodemon/bin/nodemon.js" working-dir="$PROJECT_DIR$">
<method v="2" />
</configuration>
</component>

View File

@@ -1,3 +1,10 @@
## [2.11.0](https://github.com/payloadcms/payload/compare/v2.10.1...v2.11.0) (2024-02-09)
### Features
* exposes collapsible provider with more functionality ([#5043](https://github.com/payloadcms/payload/issues/5043)) ([df39602](https://github.com/payloadcms/payload/commit/df39602758ae8dc3765bb48e51f7a657babfa559))
## [2.10.1](https://github.com/payloadcms/payload/compare/v2.10.0...v2.10.1) (2024-02-09)

View File

@@ -635,6 +635,37 @@ export const CustomArrayManager = () => {
]}
/>
### useCollapsible
The `useCollapsible` hook allows you to control parent collapsibles:
| Property | Description |
|---------------------------|--------------------------------------------------------------------------------------------------------------------|
| **`collapsed`** | State of the collapsible. `true` if open, `false` if collapsed |
| **`isVisible`** | If nested, determine if the nearest collapsible is visible. `true` if no parent is closed, `false` otherwise |
| **`toggle`** | Toggles the state of the nearest collapsible |
| **`withinCollapsible`** | Determine when you are within another collaspible | |
**Example:**
```tsx
import React from 'react'
import { useCollapsible } from 'payload/components/utilities'
const CustomComponent: React.FC = () => {
const { collapsed, toggle } = useCollapsible()
return (
<div>
<p className="field-type">I am {collapsed ? 'closed' : 'open'}</p>
<button onClick={toggle} type="button">
Toggle
</button>
</div>
)
}
```
### useDocumentInfo
The `useDocumentInfo` hook provides lots of information about the document currently being edited, including the following:
@@ -774,8 +805,8 @@ const MyComponent: React.FC = () => {
return (
<>
<span>The current theme is {theme} and autoMode is {autoMode}</span>
<button
type="button"
<button
type="button"
onClick={() => setTheme(prev => prev === "light" ? "dark" : "light")}
>
Toggle theme

View File

@@ -77,12 +77,12 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jwt-decode": "3.1.2",
"lexical": "0.12.5",
"lexical": "0.13.1",
"lint-staged": "^14.0.1",
"minimist": "1.2.8",
"mongodb-memory-server": "^9",
"node-fetch": "2.6.12",
"nodemon": "3.0.2",
"nodemon": "3.0.3",
"prettier": "^3.0.3",
"prompts": "2.4.2",
"qs": "6.11.2",
@@ -106,12 +106,12 @@
},
"pnpm": {
"overrides": {
"copyfiles": "2.4.1",
"cross-env": "7.0.3",
"dotenv": "8.6.0",
"drizzle-orm": "0.29.3",
"ts-node": "10.9.2",
"typescript": "5.2.2"
"copyfiles": "$copyfiles",
"cross-env": "$cross-env",
"dotenv": "$dotenv",
"drizzle-orm": "$drizzle-orm",
"ts-node": "$ts-node",
"typescript": "$typescript"
}
},
"engines": {

View File

@@ -1,6 +1,6 @@
{
"name": "payload",
"version": "2.10.1",
"version": "2.11.0",
"description": "Node, React and MongoDB Headless CMS and Application Framework",
"license": "MIT",
"main": "./dist/index.js",
@@ -193,7 +193,7 @@
"get-port": "5.1.1",
"mini-css-extract-plugin": "1.6.2",
"node-fetch": "2.6.12",
"nodemon": "3.0.1",
"nodemon": "3.0.3",
"object.assign": "4.1.4",
"object.entries": "1.1.6",
"passport-strategy": "1.0.0",

View File

@@ -24,11 +24,16 @@ export const Collapsible: React.FC<Props> = ({
}) => {
const [collapsedLocal, setCollapsedLocal] = useState(Boolean(initCollapsed))
const [hoveringToggle, setHoveringToggle] = useState(false)
const isNested = useCollapsible()
const { withinCollapsible } = useCollapsible()
const { t } = useTranslation('fields')
const collapsed = typeof collapsedFromProps === 'boolean' ? collapsedFromProps : collapsedLocal
const toggleCollapsible = React.useCallback(() => {
if (typeof onToggle === 'function') onToggle(!collapsed)
setCollapsedLocal(!collapsed)
}, [onToggle, collapsed])
return (
<div
className={[
@@ -36,14 +41,14 @@ export const Collapsible: React.FC<Props> = ({
className,
dragHandleProps && `${baseClass}--has-drag-handle`,
collapsed && `${baseClass}--collapsed`,
isNested && `${baseClass}--nested`,
withinCollapsible && `${baseClass}--nested`,
hoveringToggle && `${baseClass}--hovered`,
`${baseClass}--style-${collapsibleStyle}`,
]
.filter(Boolean)
.join(' ')}
>
<CollapsibleProvider>
<CollapsibleProvider collapsed={collapsed} toggle={toggleCollapsible}>
<div
className={`${baseClass}__toggle-wrap`}
onMouseEnter={() => setHoveringToggle(true)}
@@ -65,10 +70,7 @@ export const Collapsible: React.FC<Props> = ({
]
.filter(Boolean)
.join(' ')}
onClick={() => {
if (typeof onToggle === 'function') onToggle(!collapsed)
setCollapsedLocal(!collapsed)
}}
onClick={toggleCollapsible}
type="button"
>
<span>{t('toggleBlock')}</span>

View File

@@ -1,14 +1,35 @@
import React, { createContext, useContext } from 'react'
const Context = createContext(false)
type ContextType = {
collapsed: boolean
isVisible: boolean
toggle: () => void
withinCollapsible: boolean
}
const Context = createContext({
collapsed: false,
isVisible: true,
toggle: () => {},
withinCollapsible: true,
})
export const CollapsibleProvider: React.FC<{
children?: React.ReactNode
collapsed?: boolean
toggle: () => void
withinCollapsible?: boolean
}> = ({ children, withinCollapsible = true }) => {
return <Context.Provider value={withinCollapsible}>{children}</Context.Provider>
}> = ({ children, collapsed, toggle, withinCollapsible = true }) => {
const { collapsed: parentIsCollapsed, isVisible } = useCollapsible()
const contextValue = React.useMemo((): ContextType => {
return {
collapsed: Boolean(collapsed),
isVisible: isVisible && !parentIsCollapsed,
toggle,
withinCollapsible,
}
}, [collapsed, withinCollapsible, toggle, parentIsCollapsed, isVisible])
return <Context.Provider value={contextValue}>{children}</Context.Provider>
}
export const useCollapsible = (): boolean => useContext(Context)
export default Context
export const useCollapsible = (): ContextType => useContext(Context)

View File

@@ -33,7 +33,7 @@ const Group: React.FC<Props> = (props) => {
permissions,
} = props
const isWithinCollapsible = useCollapsible()
const { withinCollapsible } = useCollapsible()
const isWithinGroup = useGroup()
const isWithinRow = useRow()
const isWithinTab = useTabs()
@@ -43,7 +43,7 @@ const Group: React.FC<Props> = (props) => {
const groupHasErrors = submitted && errorCount > 0
const path = pathFromProps || name
const isTopLevel = !(isWithinCollapsible || isWithinGroup || isWithinRow)
const isTopLevel = !(withinCollapsible || isWithinGroup || isWithinRow)
return (
<div
@@ -51,7 +51,7 @@ const Group: React.FC<Props> = (props) => {
fieldBaseClass,
baseClass,
isTopLevel && `${baseClass}--top-level`,
isWithinCollapsible && `${baseClass}--within-collapsible`,
withinCollapsible && `${baseClass}--within-collapsible`,
isWithinGroup && `${baseClass}--within-group`,
isWithinRow && `${baseClass}--within-row`,
isWithinTab && `${baseClass}--within-tab`,

View File

@@ -83,7 +83,7 @@ const TabsField: React.FC<Props> = (props) => {
const { preferencesKey } = useDocumentInfo()
const { i18n } = useTranslation()
const isWithinCollapsible = useCollapsible()
const { withinCollapsible } = useCollapsible()
const [activeTabIndex, setActiveTabIndex] = useState<number>(0)
const tabsPrefKey = `tabs-${indexPath}`
@@ -138,7 +138,7 @@ const TabsField: React.FC<Props> = (props) => {
fieldBaseClass,
className,
baseClass,
isWithinCollapsible && `${baseClass}--within-collapsible`,
withinCollapsible && `${baseClass}--within-collapsible`,
]
.filter(Boolean)
.join(' ')}

View File

@@ -31,6 +31,7 @@ export const incrementLoginAttempts = async ({
lockUntil: null,
loginAttempts: 1,
},
depth: 0,
req,
})
}
@@ -52,6 +53,7 @@ export const incrementLoginAttempts = async ({
id: doc.id,
collection: collection.slug,
data,
depth: 0,
req,
})
}

View File

@@ -23,6 +23,8 @@ export const registerLocalStrategy = async ({
const existingUser = await payload.find({
collection: collection.slug,
depth: 0,
limit: 1,
pagination: false,
where: {
email: {
equals: doc.email,

View File

@@ -23,6 +23,7 @@ export const resetLoginAttempts = async ({
lockUntil: null,
loginAttempts: 0,
},
depth: 0,
overrideAccess: true,
req,
})

View File

@@ -1,3 +1,4 @@
export { useCollapsible } from '../../admin/components/elements/Collapsible/provider'
export { default as buildStateFromSchema } from '../../admin/components/forms/Form/buildStateFromSchema'
export { useAuth } from '../../admin/components/utilities/Auth'
export { useConfig } from '../../admin/components/utilities/Config'

View File

@@ -31,16 +31,14 @@ async function deleteOperation(args: PreferenceRequest): Promise<Document> {
],
}
const result = await payload.delete({
const result = await payload.db.deleteOne({
collection: 'payload-preferences',
depth: 0,
user,
req,
where,
})
// @ts-expect-error // TODO: fix later
if (result.docs.length === 1) {
return result.docs[0]
if (result) {
return result
}
throw new NotFound()
}

View File

@@ -8,6 +8,7 @@ async function findOne(
const {
key,
req: { payload },
req,
user,
} = args
@@ -21,17 +22,11 @@ async function findOne(
],
}
const { docs } = await payload.find({
return await payload.db.findOne({
collection: 'payload-preferences',
depth: 0,
pagination: false,
user,
req,
where,
})
if (docs.length === 0) return null
return docs[0]
}
export default findOne

View File

@@ -65,6 +65,7 @@ const replaceWithDraftIfAvailable = async <T extends TypeWithID>({
global: entity.slug,
limit: 1,
locale,
pagination: false,
req,
sort: '-updatedAt',
where: combineQueries(queryToBuild, versionAccessResult),

View File

@@ -1,8 +1,7 @@
import type { SanitizedCollectionConfig } from '../collections/config/types'
import type { SanitizedGlobalConfig } from '../globals/config/types'
import type { Payload } from '../payload'
import type { PayloadRequest } from '../types'
import type { Where } from '../types'
import type { PayloadRequest, Where } from '../types'
type Args = {
collection?: SanitizedCollectionConfig
@@ -46,6 +45,7 @@ export const enforceMaxVersions = async ({
} else if (global) {
const query = await payload.db.findGlobalVersions({
global: global.slug,
pagination: false,
req,
skip: max,
sort: '-updatedAt',

View File

@@ -26,6 +26,8 @@ export const getLatestCollectionVersion = async <T extends TypeWithID = any>({
if (config.versions?.drafts) {
const { docs } = await payload.db.findVersions<T>({
collection: config.slug,
limit: 1,
pagination: false,
req,
sort: '-updatedAt',
where: { parent: { equals: id } },

View File

@@ -14,11 +14,11 @@ type Args = {
}
export const getLatestGlobalVersion = async ({
slug,
config,
locale,
payload,
req,
slug,
where,
}: Args): Promise<{ global: Document; globalExists: boolean }> => {
let latestVersion
@@ -30,6 +30,7 @@ export const getLatestGlobalVersion = async ({
global: slug,
limit: 1,
locale,
pagination: false,
req,
sort: '-updatedAt',
})
@@ -37,9 +38,9 @@ export const getLatestGlobalVersion = async ({
}
const global = await payload.db.findGlobal({
slug,
locale,
req,
slug,
where,
})
const globalExists = Boolean(global)

View File

@@ -40,6 +40,7 @@ export const saveVersion = async ({
let docs
const findVersionArgs = {
limit: 1,
pagination: false,
req,
sort: '-updatedAt',
}

View File

@@ -52,7 +52,7 @@
"@types/find-node-modules": "^2.1.2",
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"nodemon": "^2.0.6",
"nodemon": "3.0.3",
"payload": "workspace:*",
"rimraf": "^4.1.2",
"ts-node": "^9.1.1",

View File

@@ -31,7 +31,7 @@
"@types/react": "18.2.15",
"copyfiles": "^2.4.1",
"cross-env": "^7.0.3",
"nodemon": "^3.0.2",
"nodemon": "3.0.3",
"payload": "workspace:*",
"react": "^18.0.0",
"ts-node": "10.9.1"

View File

@@ -45,7 +45,7 @@
"cross-env": "^7.0.3",
"dotenv": "^8.2.0",
"jest": "^29.5.0",
"nodemon": "^2.0.6",
"nodemon": "3.0.3",
"payload": "workspace:*",
"ts-jest": "^29.1.0",
"webpack": "^5.78.0"

View File

@@ -19,31 +19,31 @@
},
"dependencies": {
"@faceless-ui/modal": "2.0.1",
"@lexical/headless": "0.12.6",
"@lexical/link": "0.12.6",
"@lexical/list": "0.12.6",
"@lexical/mark": "0.12.6",
"@lexical/markdown": "0.12.6",
"@lexical/react": "0.12.6",
"@lexical/rich-text": "0.12.6",
"@lexical/selection": "0.12.6",
"@lexical/utils": "0.12.6",
"@lexical/headless": "0.13.1",
"@lexical/link": "0.13.1",
"@lexical/list": "0.13.1",
"@lexical/mark": "0.13.1",
"@lexical/markdown": "0.13.1",
"@lexical/react": "0.13.1",
"@lexical/rich-text": "0.13.1",
"@lexical/selection": "0.13.1",
"@lexical/utils": "0.13.1",
"bson-objectid": "2.0.4",
"classnames": "^2.3.2",
"deep-equal": "2.2.3",
"i18next": "22.5.1",
"json-schema": "^0.4.0",
"lexical": "0.12.6",
"lexical": "0.13.1",
"lodash": "4.17.21",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.11",
"react-error-boundary": "4.0.12",
"react-i18next": "11.18.6",
"ts-essentials": "7.0.3"
},
"devDependencies": {
"@payloadcms/eslint-config": "workspace:*",
"@types/json-schema": "7.0.12",
"@types/json-schema": "7.0.15",
"@types/node": "20.6.2",
"@types/react": "18.2.15",
"payload": "workspace:*"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,6 +34,8 @@ import { useEditorConfigContext } from '../../../../../lexical/config/EditorConf
import { getSelectedNode } from '../../../../../lexical/utils/getSelectedNode'
import { setFloatingElemPositionForLinkEditor } from '../../../../../lexical/utils/setFloatingElemPositionForLinkEditor'
import { LinkDrawer } from '../../../drawer'
import { $isAutoLinkNode } from '../../../nodes/AutoLinkNode'
import { $createLinkNode } from '../../../nodes/LinkNode'
import { $isLinkNode, TOGGLE_LINK_COMMAND } from '../../../nodes/LinkNode'
import { transformExtraFields } from '../utilities'
import { TOGGLE_LINK_WITH_MODAL_COMMAND } from './commands'
@@ -73,7 +75,7 @@ export function LinkEditor({
// Sanitize custom fields here
const validRelationships = config.collections.map((c) => c.slug) || []
const fields = sanitizeFields({
config: config,
config,
fields: fieldsUnsanitized,
validRelationships,
})
@@ -84,10 +86,11 @@ export function LinkEditor({
const { closeModal, toggleModal } = useModal()
const editDepth = useEditDepth()
const [isLink, setIsLink] = useState(false)
const [isAutoLink, setIsAutoLink] = useState(false)
const drawerSlug = formatDrawerSlug({
depth: editDepth,
slug: `lexical-rich-text-link-` + uuid,
depth: editDepth,
})
const updateLinkEditor = useCallback(async () => {
@@ -98,9 +101,10 @@ export function LinkEditor({
if ($isRangeSelection(selection)) {
const node = getSelectedNode(selection)
selectedNodeDomRect = editor.getElementByKey(node.getKey())?.getBoundingClientRect()
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode) as LinkNode
const linkParent: LinkNode = $findMatchingParent(node, $isLinkNode)
if (linkParent == null) {
setIsLink(false)
setIsAutoLink(false)
setLinkUrl('')
setLinkLabel('')
return
@@ -152,6 +156,11 @@ export function LinkEditor({
})
setInitialState(state)
setIsLink(true)
if ($isAutoLinkNode(linkParent)) {
setIsAutoLink(true)
} else {
setIsAutoLink(false)
}
}
const editorElem = editorRef.current
@@ -265,6 +274,7 @@ export function LinkEditor({
() => {
if (isLink) {
setIsLink(false)
setIsAutoLink(false)
return true
}
return false
@@ -301,18 +311,20 @@ export function LinkEditor({
tabIndex={0}
type="button"
/>
<button
aria-label="Remove link"
className="link-trash"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
onMouseDown={(event) => {
event.preventDefault()
}}
tabIndex={0}
type="button"
/>
{!isAutoLink && (
<button
aria-label="Remove link"
className="link-trash"
onClick={() => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)
}}
onMouseDown={(event) => {
event.preventDefault()
}}
tabIndex={0}
type="button"
/>
)}
</React.Fragment>
)}
</div>
@@ -325,6 +337,22 @@ export function LinkEditor({
const newLinkPayload: LinkPayload = data as LinkPayload
// See: https://github.com/facebook/lexical/pull/5536. This updates autolink nodes to link nodes whenever a change was made (which is good!).
editor.update(() => {
const selection = $getSelection()
if ($isRangeSelection(selection)) {
const parent = getSelectedNode(selection).getParent()
if ($isAutoLinkNode(parent)) {
const linkNode = $createLinkNode({
fields: newLinkPayload.fields,
})
parent.replace(linkNode, true)
}
}
})
// Needs to happen AFTER a potential auto link => link node conversion, as otherwise, the updated text to display may be lost due to
// it being applied to the auto link node instead of the link node.
editor.dispatchCommand(TOGGLE_LINK_COMMAND, newLinkPayload)
}}
initialState={initialState}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

771
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff