feat(richtext-lexical)!: configurable fixed toolbar (#6560)

**BREAKING**: useEditorFocusProvider has been removed and merged with
useEditorConfigContext. You can now find information about the focused
editor, parent editors and child editors within useEditorConfigContext
This commit is contained in:
Alessio Gravili
2024-05-30 16:08:22 -04:00
committed by GitHub
parent c68189788c
commit f41bb05c70
17 changed files with 324 additions and 200 deletions

View File

@@ -42,6 +42,7 @@
},
"dependencies": {
"@faceless-ui/modal": "2.0.2",
"@faceless-ui/scroll-info": "1.3.0",
"@lexical/headless": "0.15.0",
"@lexical/link": "0.15.0",
"@lexical/list": "0.15.0",
@@ -75,6 +76,7 @@
},
"peerDependencies": {
"@faceless-ui/modal": "2.0.2",
"@faceless-ui/scroll-info": "1.3.0",
"@lexical/headless": "0.15.0",
"@lexical/link": "0.15.0",
"@lexical/list": "0.15.0",

View File

@@ -9,9 +9,5 @@ export { ToolbarButton } from '../field/features/toolbars/shared/ToolbarButton/i
export { ToolbarDropdown } from '../field/features/toolbars/shared/ToolbarDropdown/index.js'
export { RichTextField } from '../field/index.js'
export {
type EditorFocusContextType,
EditorFocusProvider,
useEditorFocus,
} from '../field/lexical/EditorFocusProvider.js'
export { defaultEditorLexicalConfig } from '../field/lexical/config/client/default.js'

View File

@@ -44,6 +44,13 @@ html[data-theme='dark'] {
}
}
.fixed-toolbar.fixed-toolbar--hide {
visibility: hidden; // Still needs to take up space so content does not jump, thus we cannot use display: none
// make sure you cant interact with it
pointer-events: none;
user-select: none;
}
.fixed-toolbar {
@include blur-bg(var(--theme-elevation-0));
display: flex;

View File

@@ -1,16 +1,19 @@
'use client'
import type { LexicalEditor } from 'lexical'
import * as scrollInfoImport from '@faceless-ui/scroll-info'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import useThrottledEffect from '@payloadcms/ui/hooks/useThrottledEffect'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import * as React from 'react'
import { useMemo } from 'react'
import type { EditorFocusContextType } from '../../../../lexical/EditorFocusProvider.js'
import type { EditorConfigContextType } from '../../../../lexical/config/client/EditorConfigProvider.js'
import type { SanitizedClientEditorConfig } from '../../../../lexical/config/types.js'
import type { PluginComponentWithAnchor } from '../../../types.js'
import type { ToolbarGroup, ToolbarGroupItem } from '../../types.js'
import type { FixedToolbarFeatureProps } from '../feature.server.js'
import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { ToolbarButton } from '../../shared/ToolbarButton/index.js'
import { ToolbarDropdown } from '../../shared/ToolbarDropdown/index.js'
@@ -133,13 +136,74 @@ function ToolbarGroupComponent({
function FixedToolbar({
anchorElem,
clientProps,
editor,
editorConfig,
parentWithFixedToolbar,
}: {
anchorElem: HTMLElement
clientProps?: FixedToolbarFeatureProps
editor: LexicalEditor
editorConfig: SanitizedClientEditorConfig
parentWithFixedToolbar: EditorConfigContextType | false
}): React.ReactNode {
const { useScrollInfo } = scrollInfoImport
const currentToolbarRef = React.useRef<HTMLDivElement>(null)
const { y } = useScrollInfo()
// Memoize the parent toolbar element
const parentToolbarElem = useMemo(() => {
if (!parentWithFixedToolbar || clientProps?.disableIfParentHasFixedToolbar) {
return null
}
const parentEditorElem = parentWithFixedToolbar.editorContainerRef.current
let sibling = parentEditorElem.previousElementSibling
while (sibling) {
if (sibling.classList.contains('fixed-toolbar')) {
return sibling
}
sibling = sibling.previousElementSibling
}
return null
}, [clientProps?.disableIfParentHasFixedToolbar, parentWithFixedToolbar])
useThrottledEffect(
() => {
if (!parentToolbarElem) {
// this also checks for clientProps?.disableIfParentHasFixedToolbar indirectly, see the parentToolbarElem useMemo
return
}
const currentToolbarElem = currentToolbarRef.current
if (!currentToolbarElem) {
return
}
const currentRect = currentToolbarElem.getBoundingClientRect()
const parentRect = parentToolbarElem.getBoundingClientRect()
// we only need to check for vertical overlap
const overlapping = !(
currentRect.bottom < parentRect.top || currentRect.top > parentRect.bottom
)
if (overlapping) {
currentToolbarRef.current.className = 'fixed-toolbar fixed-toolbar--overlapping'
parentToolbarElem.className = 'fixed-toolbar fixed-toolbar--hide'
} else {
if (!currentToolbarRef.current.classList.contains('fixed-toolbar--overlapping')) {
return
}
currentToolbarRef.current.className = 'fixed-toolbar'
parentToolbarElem.className = 'fixed-toolbar'
}
},
50,
[currentToolbarRef, parentToolbarElem, y],
)
return (
<div
className="fixed-toolbar"
@@ -148,6 +212,7 @@ function FixedToolbar({
// the parent editor will be focused, and the child editor will lose focus.
event.stopPropagation()
}}
ref={currentToolbarRef}
>
{editor.isEditable() && (
<React.Fragment>
@@ -170,40 +235,56 @@ function FixedToolbar({
)
}
const checkParentEditor = (editorFocus: EditorFocusContextType): boolean => {
if (editorFocus.parentEditorConfigContext?.editorConfig) {
if (
editorFocus.parentEditorConfigContext?.editorConfig.resolvedFeatureMap.has('toolbarFixed')
) {
return true
const getParentEditorWithFixedToolbar = (
editorConfigContext: EditorConfigContextType,
): EditorConfigContextType | false => {
if (editorConfigContext.parentEditor?.editorConfig) {
if (editorConfigContext.parentEditor?.editorConfig.resolvedFeatureMap.has('toolbarFixed')) {
return editorConfigContext.parentEditor
} else {
if (editorFocus.parentEditorFocus) {
return checkParentEditor(editorFocus.parentEditorFocus)
if (editorConfigContext.parentEditor) {
return getParentEditorWithFixedToolbar(editorConfigContext.parentEditor)
}
}
}
return false
}
export const FixedToolbarPlugin: PluginComponentWithAnchor<undefined> = ({ anchorElem }) => {
export const FixedToolbarPlugin: PluginComponentWithAnchor<FixedToolbarFeatureProps> = ({
anchorElem,
clientProps,
}) => {
const [currentEditor] = useLexicalComposerContext()
const { editorConfig: currentEditorConfig, uuid } = useEditorConfigContext()
const editorConfigContext = useEditorConfigContext()
const editorFocus = useEditorFocus()
const editor = editorFocus.focusedEditor || currentEditor
const { editorConfig: currentEditorConfig } = editorConfigContext
const editorConfig = editorFocus.focusedEditorConfigContext?.editorConfig || currentEditorConfig
const editor = clientProps.applyToFocusedEditor
? editorConfigContext.focusedEditor?.editor || currentEditor
: currentEditor
// Check if there is a parent editor with a fixed toolbar already
const hasParentWithFixedToolbar = checkParentEditor(editorFocus)
const editorConfig = clientProps.applyToFocusedEditor
? editorConfigContext.focusedEditor?.editorConfig || currentEditorConfig
: currentEditorConfig
if (hasParentWithFixedToolbar) {
return null
const parentWithFixedToolbar = getParentEditorWithFixedToolbar(editorConfigContext)
if (clientProps?.disableIfParentHasFixedToolbar) {
if (parentWithFixedToolbar) {
return null
}
}
if (!editorConfig?.features?.toolbarFixed?.groups?.length) {
return null
}
return <FixedToolbar anchorElem={anchorElem} editor={editor} editorConfig={editorConfig} />
return (
<FixedToolbar
anchorElem={anchorElem}
editor={editor}
editorConfig={editorConfig}
parentWithFixedToolbar={parentWithFixedToolbar}
/>
)
}

View File

@@ -1,11 +1,14 @@
'use client'
import type { FeatureProviderProviderClient } from '../../types.js'
import type { FixedToolbarFeatureProps } from './feature.server.js'
import { createClientComponent } from '../../createClientComponent.js'
import { FixedToolbarPlugin } from './Toolbar/index.js'
const FixedToolbarFeatureClient: FeatureProviderProviderClient<undefined> = (props) => {
const FixedToolbarFeatureClient: FeatureProviderProviderClient<FixedToolbarFeatureProps> = (
props,
) => {
return {
clientFeatureProps: props,
feature: () => ({

View File

@@ -2,12 +2,42 @@ import type { FeatureProviderProviderServer } from '../../types.js'
import { FixedToolbarFeatureClientComponent } from './feature.client.js'
export const FixedToolbarFeature: FeatureProviderProviderServer<undefined, undefined> = (props) => {
export type FixedToolbarFeatureProps = {
/**
* @default false
*
* If this is enabled, the toolbar will apply to the focused editor, not the editor with the FixedToolbarFeature.
*
* This means that if the editor has a child-editor, and the child-editor is focused, the toolbar will apply to the child-editor, not the parent editor with this feature added.
*/
applyToFocusedEditor?: boolean
/**
* @default false
*
* If there is a parent editor with a fixed toolbar, this will disable the toolbar for this editor.
*/
disableIfParentHasFixedToolbar?: boolean
}
export const FixedToolbarFeature: FeatureProviderProviderServer<
FixedToolbarFeatureProps,
FixedToolbarFeatureProps
> = (props) => {
return {
feature: () => {
const sanitizedProps: FixedToolbarFeatureProps = {
applyToFocusedEditor:
props?.applyToFocusedEditor === undefined ? false : props.applyToFocusedEditor,
disableIfParentHasFixedToolbar:
props?.disableIfParentHasFixedToolbar === undefined
? false
: props.disableIfParentHasFixedToolbar,
}
return {
ClientComponent: FixedToolbarFeatureClientComponent,
serverFeatureProps: props,
clientFeatureProps: sanitizedProps,
serverFeatureProps: sanitizedProps,
}
},
key: 'toolbarFixed',

View File

@@ -7,7 +7,7 @@ import React, { useCallback, useEffect, useState } from 'react'
import type { ToolbarGroupItem } from '../../types.js'
import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import './index.scss'
const baseClass = 'toolbar-popup__button'
@@ -24,25 +24,25 @@ export const ToolbarButton = ({
const [enabled, setEnabled] = useState<boolean>(true)
const [active, setActive] = useState<boolean>(false)
const [className, setClassName] = useState<string>(baseClass)
const editorFocusContext = useEditorFocus()
const editorConfigContext = useEditorConfigContext()
const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
const selection = $getSelection()
if (item.isActive) {
const isActive = item.isActive({ editor, editorFocusContext, selection })
const isActive = item.isActive({ editor, editorConfigContext, selection })
if (active !== isActive) {
setActive(isActive)
}
}
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, editorFocusContext, selection })
const isEnabled = item.isEnabled({ editor, editorConfigContext, selection })
if (enabled !== isEnabled) {
setEnabled(isEnabled)
}
}
})
}, [active, editor, editorFocusContext, enabled, item])
}, [active, editor, editorConfigContext, enabled, item])
useEffect(() => {
updateStates()

View File

@@ -11,7 +11,7 @@ import { $getSelection } from 'lexical'
import type { ToolbarGroupItem } from '../../types.js'
import { useEditorFocus } from '../../../../lexical/EditorFocusProvider.js'
import { useEditorConfigContext } from '../../../../lexical/config/client/EditorConfigProvider.js'
import { DropDown, DropDownItem } from './DropDown.js'
import './index.scss'
@@ -91,7 +91,7 @@ export const ToolbarDropdown = ({
}) => {
const [activeItemKeys, setActiveItemKeys] = React.useState<string[]>([])
const [enabledItemKeys, setEnabledItemKeys] = React.useState<string[]>([])
const editorFocusContext = useEditorFocus()
const editorConfigContext = useEditorConfigContext()
const updateStates = useCallback(() => {
editor.getEditorState().read(() => {
@@ -103,14 +103,14 @@ export const ToolbarDropdown = ({
for (const item of items) {
if (item.isActive && (!maxActiveItems || _activeItemKeys.length < maxActiveItems)) {
const isActive = item.isActive({ editor, editorFocusContext, selection })
const isActive = item.isActive({ editor, editorConfigContext, selection })
if (isActive) {
_activeItemKeys.push(item.key)
_activeItems.push(item)
}
}
if (item.isEnabled) {
const isEnabled = item.isEnabled({ editor, editorFocusContext, selection })
const isEnabled = item.isEnabled({ editor, editorConfigContext, selection })
if (isEnabled) {
_enabledItemKeys.push(item.key)
}
@@ -125,7 +125,7 @@ export const ToolbarDropdown = ({
onActiveChange({ activeItems: _activeItems })
}
})
}, [editor, editorFocusContext, items, maxActiveItems, onActiveChange])
}, [editor, editorConfigContext, items, maxActiveItems, onActiveChange])
useEffect(() => {
updateStates()

View File

@@ -2,7 +2,7 @@ import type { I18nClient } from '@payloadcms/translations'
import type { BaseSelection, LexicalEditor } from 'lexical'
import type React from 'react'
import type { EditorFocusContextType } from '../../lexical/EditorFocusProvider.js'
import type { EditorConfigContextType } from '../../lexical/config/client/EditorConfigProvider.js'
export type ToolbarGroup =
| {
@@ -31,20 +31,20 @@ export type ToolbarGroupItem = {
}>
isActive?: ({
editor,
editorFocusContext,
editorConfigContext,
selection,
}: {
editor: LexicalEditor
editorFocusContext: EditorFocusContextType
editorConfigContext: EditorConfigContextType
selection: BaseSelection
}) => boolean
isEnabled?: ({
editor,
editorFocusContext,
editorConfigContext,
selection,
}: {
editor: LexicalEditor
editorFocusContext: EditorFocusContextType
editorConfigContext: EditorConfigContextType
selection: BaseSelection
}) => boolean
key: string

View File

@@ -1,100 +0,0 @@
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import React, { createContext, useCallback, useContext, useState } from 'react'
import type { EditorConfigContextType } from './config/client/EditorConfigProvider.js'
import { useEditorConfigContext } from './config/client/EditorConfigProvider.js'
export type EditorFocusContextType = {
blurEditor: () => void
editor: LexicalEditor
focusEditor: (_editor?: LexicalEditor, _editorConfigContext?: EditorConfigContextType) => void
focusedEditor: LexicalEditor | null
focusedEditorConfigContext: EditorConfigContextType
isChildEditorFocused: () => boolean
isEditorFocused: () => boolean
isParentEditorFocused: () => boolean
parentEditorConfigContext: EditorConfigContextType
parentEditorFocus: EditorFocusContextType
}
const EditorFocusContext = createContext<EditorFocusContextType>({
blurEditor: null,
editor: null,
focusEditor: null,
focusedEditor: null,
focusedEditorConfigContext: null,
isChildEditorFocused: null,
isEditorFocused: null,
isParentEditorFocused: null,
parentEditorConfigContext: null,
parentEditorFocus: null,
})
export const useEditorFocus = (): EditorFocusContextType => {
return useContext(EditorFocusContext)
}
export const EditorFocusProvider = ({ children }) => {
const parentEditorFocus = useEditorFocus()
const parentEditorConfigContext = useEditorConfigContext() // Is parent, as this EditorFocusProvider sits outside the EditorConfigProvider
const [editor] = useLexicalComposerContext()
const [focusedEditor, setFocusedEditor] = useState<LexicalEditor | null>(null)
const [focusedEditorConfigContext, setFocusedEditorConfigContext] =
useState<EditorConfigContextType | null>(null)
const focusEditor = useCallback(
(_editor: LexicalEditor, _editorConfigContext: EditorConfigContextType) => {
setFocusedEditor(_editor !== undefined ? _editor : editor)
setFocusedEditorConfigContext(_editorConfigContext)
if (parentEditorFocus.focusEditor) {
parentEditorFocus.focusEditor(
_editor !== undefined ? _editor : editor,
_editorConfigContext,
)
}
},
[editor, parentEditorFocus],
)
const blurEditor = useCallback(() => {
if (focusedEditor === editor) {
setFocusedEditor(null)
setFocusedEditorConfigContext(null)
}
}, [editor, focusedEditor])
const isEditorFocused = useCallback(() => {
return focusedEditor === editor
}, [editor, focusedEditor])
const isParentEditorFocused = useCallback(() => {
return parentEditorFocus?.isEditorFocused ? parentEditorFocus.isEditorFocused() : false
}, [parentEditorFocus])
const isChildEditorFocused = useCallback(() => {
return focusedEditor !== editor && !!focusedEditor
}, [editor, focusedEditor])
return (
<EditorFocusContext.Provider
value={{
blurEditor,
editor,
focusEditor,
focusedEditor,
focusedEditorConfigContext,
isChildEditorFocused,
isEditorFocused,
isParentEditorFocused,
parentEditorConfigContext,
parentEditorFocus,
}}
>
{children}
</EditorFocusContext.Provider>
)
}

View File

@@ -5,7 +5,6 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin.js'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin.js'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin.js'
import { TabIndentationPlugin } from '@lexical/react/LexicalTabIndentationPlugin.js'
import { mergeRegister } from '@lexical/utils'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from 'lexical'
import * as React from 'react'
@@ -13,7 +12,6 @@ import { useEffect, useState } from 'react'
import type { LexicalProviderProps } from './LexicalProvider.js'
import { useEditorFocus } from './EditorFocusProvider.js'
import { EditorPlugin } from './EditorPlugin.js'
import './LexicalEditor.scss'
import { useEditorConfigContext } from './config/client/EditorConfigProvider.js'
@@ -23,13 +21,14 @@ import { AddBlockHandlePlugin } from './plugins/handles/AddBlockHandlePlugin/ind
import { DraggableBlockPlugin } from './plugins/handles/DraggableBlockPlugin/index.js'
import { LexicalContentEditable } from './ui/ContentEditable.js'
export const LexicalEditor: React.FC<Pick<LexicalProviderProps, 'editorConfig' | 'onChange'>> = (
props,
) => {
const { editorConfig, onChange } = props
export const LexicalEditor: React.FC<
Pick<LexicalProviderProps, 'editorConfig' | 'onChange'> & {
editorContainerRef: React.RefObject<HTMLDivElement>
}
> = (props) => {
const { editorConfig, editorContainerRef, onChange } = props
const editorConfigContext = useEditorConfigContext()
const [editor] = useLexicalComposerContext()
const editorFocus = useEditorFocus()
const { t } = useTranslation<{}, string>()
const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null)
@@ -40,27 +39,46 @@ export const LexicalEditor: React.FC<Pick<LexicalProviderProps, 'editorConfig' |
}
useEffect(() => {
return mergeRegister(
editor.registerCommand<MouseEvent>(
FOCUS_COMMAND,
() => {
editorFocus.focusEditor(editor, editorConfigContext)
return true
},
if (!editorConfigContext?.uuid) {
console.error('Lexical Editor must be used within an EditorConfigProvider')
return
}
if (editorConfigContext?.parentEditor?.uuid) {
editorConfigContext.parentEditor?.registerChild(editorConfigContext.uuid, editorConfigContext)
}
COMMAND_PRIORITY_LOW,
),
editor.registerCommand<MouseEvent>(
BLUR_COMMAND,
() => {
editorFocus.blurEditor()
return true
},
const handleFocus = () => {
editorConfigContext.focusEditor(editorConfigContext)
}
COMMAND_PRIORITY_LOW,
),
const handleBlur = () => {
editorConfigContext.blurEditor(editorConfigContext)
}
const unregisterFocus = editor.registerCommand<MouseEvent>(
FOCUS_COMMAND,
() => {
handleFocus()
return true
},
COMMAND_PRIORITY_LOW,
)
}, [editor, editorConfig, editorConfigContext, editorFocus])
const unregisterBlur = editor.registerCommand<MouseEvent>(
BLUR_COMMAND,
() => {
handleBlur()
return true
},
COMMAND_PRIORITY_LOW,
)
return () => {
unregisterFocus()
unregisterBlur()
editorConfigContext.parentEditor?.unregisterChild?.(editorConfigContext.uuid)
}
}, [editor, editorConfigContext])
const [isSmallWidthViewport, setIsSmallWidthViewport] = useState<boolean>(false)
@@ -89,7 +107,7 @@ export const LexicalEditor: React.FC<Pick<LexicalProviderProps, 'editorConfig' |
return <EditorPlugin clientProps={plugin.clientProps} key={plugin.key} plugin={plugin} />
}
})}
<div className="editor-container">
<div className="editor-container" ref={editorContainerRef}>
{editorConfig.features.plugins.map((plugin) => {
if (plugin.position === 'top') {
return (

View File

@@ -9,9 +9,11 @@ import * as React from 'react'
import type { SanitizedClientEditorConfig } from './config/types.js'
import { EditorFocusProvider } from './EditorFocusProvider.js'
import { LexicalEditor as LexicalEditorComponent } from './LexicalEditor.js'
import { EditorConfigProvider } from './config/client/EditorConfigProvider.js'
import {
EditorConfigProvider,
useEditorConfigContext,
} from './config/client/EditorConfigProvider.js'
import { getEnabledNodes } from './nodes/index.js'
export type LexicalProviderProps = {
@@ -29,6 +31,9 @@ export type LexicalProviderProps = {
export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
const { editorConfig, fieldProps, onChange, path, readOnly } = props
let { value } = props
const parentContext = useEditorConfigContext()
const editorContainerRef = React.useRef<HTMLDivElement>(null)
const [initialConfig, setInitialConfig] = React.useState<InitialConfigType | null>(null)
@@ -78,11 +83,18 @@ export const LexicalProvider: React.FC<LexicalProviderProps> = (props) => {
return (
<LexicalComposer initialConfig={initialConfig} key={path}>
<EditorFocusProvider>
<EditorConfigProvider editorConfig={editorConfig} fieldProps={fieldProps}>
<LexicalEditorComponent editorConfig={editorConfig} onChange={onChange} />
</EditorConfigProvider>
</EditorFocusProvider>
<EditorConfigProvider
editorConfig={editorConfig}
editorContainerRef={editorContainerRef}
fieldProps={fieldProps}
parentContext={parentContext}
>
<LexicalEditorComponent
editorConfig={editorConfig}
editorContainerRef={editorContainerRef}
onChange={onChange}
/>
</EditorConfigProvider>
</LexicalComposer>
)
}

View File

@@ -1,9 +1,11 @@
'use client'
import type { FormFieldBase } from '@payloadcms/ui/fields/shared'
import type { LexicalEditor } from 'lexical'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext.js'
import * as React from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
import { createContext, useContext, useMemo, useRef, useState } from 'react'
import type { SanitizedClientEditorConfig } from '../types.js'
@@ -11,46 +13,122 @@ import type { SanitizedClientEditorConfig } from '../types.js'
function generateQuickGuid(): string {
return Math.random().toString(36).substring(2, 12) + Math.random().toString(36).substring(2, 12)
}
export interface EditorConfigContextType {
// Editor focus handling
blurEditor: (editorContext: EditorConfigContextType) => void
childrenEditors: React.RefObject<Map<string, EditorConfigContextType>>
editor: LexicalEditor
editorConfig: SanitizedClientEditorConfig
editorContainerRef: React.RefObject<HTMLDivElement>
field: FormFieldBase & {
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
// Editor focus handling
focusEditor: (editorContext: EditorConfigContextType) => void
focusedEditor: EditorConfigContextType | null
parentEditor: EditorConfigContextType
registerChild: (uuid: string, editorContext: EditorConfigContextType) => void
unregisterChild?: (uuid: string) => void
uuid: string
}
const Context: React.Context<EditorConfigContextType> = createContext({
editorConfig: null,
field: null,
uuid: generateQuickGuid(),
uuid: null,
})
export const EditorConfigProvider = ({
children,
editorConfig,
editorContainerRef,
fieldProps,
parentContext,
}: {
children: React.ReactNode
editorConfig: SanitizedClientEditorConfig
editorContainerRef: React.RefObject<HTMLDivElement>
fieldProps: FormFieldBase & {
editorConfig: SanitizedClientEditorConfig // With rendered features n stuff
name: string
richTextComponentMap: Map<string, React.ReactNode>
}
parentContext?: EditorConfigContextType
}): React.ReactNode => {
const [editor] = useLexicalComposerContext()
// State to store the UUID
const [uuid, setUuid] = useState(generateQuickGuid())
const [uuid] = useState(generateQuickGuid())
// When the component mounts, generate a new UUID only once
useEffect(() => {
setUuid(generateQuickGuid())
}, [])
const childrenEditors = useRef<Map<string, EditorConfigContextType>>(new Map())
const [focusedEditor, setFocusedEditor] = useState<EditorConfigContextType | null>(null)
const focusHistory = useRef<Set<string>>(new Set())
const editorContext = useMemo(
() => ({ editorConfig, field: fieldProps, uuid }),
[editorConfig, fieldProps, uuid],
() =>
({
blurEditor: (editorContext: EditorConfigContextType) => {
//setFocusedEditor(null) // Clear focused editor
focusHistory.current.clear() // Reset focus history when focus is lost
},
childrenEditors,
editor,
editorConfig,
editorContainerRef,
field: fieldProps,
focusEditor: (editorContext: EditorConfigContextType) => {
const editorUUID = editorContext.uuid
// Avoid recursion by checking if this editor is already focused in this cycle
if (focusHistory.current.has(editorUUID)) {
return
}
// Add this editor to the history to prevent future recursions in this cycle
focusHistory.current.add(editorUUID)
setFocusedEditor(editorContext)
// Propagate focus event to parent and children, ensuring they do not refocus this editor
if (parentContext?.uuid) {
parentContext.focusEditor(editorContext)
}
childrenEditors.current.forEach((childEditor, childUUID) => {
childEditor.focusEditor(editorContext)
})
focusHistory.current.clear()
},
focusedEditor,
parentEditor: parentContext,
registerChild: (childUUID, childEditorContext) => {
if (!childrenEditors.current.has(childUUID)) {
const newMap = new Map(childrenEditors.current)
newMap.set(childUUID, childEditorContext)
childrenEditors.current = newMap
}
},
unregisterChild: (childUUID) => {
if (childrenEditors.current.has(childUUID)) {
const newMap = new Map(childrenEditors.current)
newMap.delete(childUUID)
childrenEditors.current = newMap
}
},
uuid,
}) as EditorConfigContextType,
[
editor,
childrenEditors,
editorConfig,
editorContainerRef,
fieldProps,
focusedEditor,
parentContext,
uuid,
],
)
return <Context.Provider value={editorContext}>{children}</Context.Provider>

View File

@@ -5,24 +5,16 @@ export const LinkIcon: React.FC = () => (
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"
>
<g clipPath="url(#clip0_4397_10813)">
<path
d="M8.50006 11.5003L11.5001 8.50034M8.50006 7.00034L9.62506 5.87534C10.8677 4.6327 12.8824 4.6327 14.1251 5.87534C15.3677 7.11798 15.3677 9.1327 14.1251 10.3753L13.0001 11.5003M7.00006 8.50034L5.7463 9.7541C4.56015 10.9402 4.51865 12.8502 5.65216 14.0867C6.81388 15.3541 8.77984 15.4486 10.0577 14.2984L11.5001 13.0003"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_4397_10813">
<rect fill="currentColor" height="12" transform="translate(4 4)" width="12" />
</clipPath>
</defs>
<path
d="M8.5 11.5L11.5 8.5M8.5 7L9.625 5.875C10.868 4.633 12.882 4.633 14.125 5.875C15.368 7.118 15.368 9.133 14.125 10.375L13 11.5M7 8.5L5.746 9.754C4.56 10.94 4.519 12.85 5.652 14.087C6.814 15.354 8.78 15.449 10.058 14.298L11.5 13"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)

View File

@@ -489,6 +489,7 @@ export {
} from './field/features/upload/nodes/UploadNode.js'
export {
type EditorConfigContextType,
EditorConfigProvider,
useEditorConfigContext,
} from './field/lexical/config/client/EditorConfigProvider.js'

3
pnpm-lock.yaml generated
View File

@@ -1261,6 +1261,9 @@ importers:
'@faceless-ui/modal':
specifier: 2.0.2
version: 2.0.2(react-dom@19.0.0-rc-f994737d14-20240522)(react@19.0.0-rc-f994737d14-20240522)
'@faceless-ui/scroll-info':
specifier: 1.3.0
version: 1.3.0(react-dom@19.0.0-rc-f994737d14-20240522)(react@19.0.0-rc-f994737d14-20240522)
'@lexical/headless':
specifier: 0.15.0
version: 0.15.0

View File

@@ -1,6 +1,6 @@
import type { ArrayField, Block } from 'payload/types'
import { BlocksFeature } from '@payloadcms/richtext-lexical'
import { BlocksFeature, FixedToolbarFeature } from '@payloadcms/richtext-lexical'
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { textFieldsSlug } from '../Text/shared.js'
@@ -118,6 +118,7 @@ export const RichTextBlock: Block = {
editor: lexicalEditor({
features: ({ defaultFeatures }) => [
...defaultFeatures,
FixedToolbarFeature(),
BlocksFeature({
blocks: [
{