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:
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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: () => ({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user