* chore(richtext-lexical): lazy import all React things * chore(richtext-lexical): use useMemo for lazy-loaded React Components to prevent lag and flashes when parent component re-renders * chore: make exportPointerFiles.ts script usable for other packages as well by hoisting it up to the workspace root and making it configurable * chore(richtext-lexical): make sure no client-side code is imported by default from Features * chore(richtext-lexical): remove unnecessary scss files * chore(richtext-lexical): adjust package.json exports * chore(richtext-*): lazy-import Field & Cell Components, move Client-only exports to /components subpath export * chore(richtext-lexical): make sure nothing client-side is directly exported from the / subpath export anymore * add missing imports * chore: remove breaking changes for Slate * LazyCellComponent & LazyFieldComponent
399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
'use client'
|
|
import type { LexicalEditor } from 'lexical'
|
|
|
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
import { mergeRegister } from '@lexical/utils'
|
|
import {
|
|
$getSelection,
|
|
$isRangeSelection,
|
|
$isTextNode,
|
|
COMMAND_PRIORITY_LOW,
|
|
SELECTION_CHANGE_COMMAND,
|
|
} from 'lexical'
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
import * as React from 'react'
|
|
import { createPortal } from 'react-dom'
|
|
|
|
import type { FloatingToolbarSection, FloatingToolbarSectionEntry } from './types'
|
|
|
|
import { useEditorConfigContext } from '../../config/EditorConfigProvider'
|
|
import { getDOMRangeRect } from '../../utils/getDOMRangeRect'
|
|
import { setFloatingElemPosition } from '../../utils/setFloatingElemPosition'
|
|
import { ToolbarButton } from './ToolbarButton'
|
|
import { ToolbarDropdown } from './ToolbarDropdown'
|
|
import './index.scss'
|
|
|
|
function ButtonSectionEntry({
|
|
anchorElem,
|
|
editor,
|
|
entry,
|
|
}: {
|
|
anchorElem: HTMLElement
|
|
editor: LexicalEditor
|
|
entry: FloatingToolbarSectionEntry
|
|
}): JSX.Element {
|
|
const Component = useMemo(() => {
|
|
return entry?.Component
|
|
? React.lazy(() =>
|
|
entry.Component().then((resolvedComponent) => ({
|
|
default: resolvedComponent,
|
|
})),
|
|
)
|
|
: null
|
|
}, [entry])
|
|
|
|
const ChildComponent = useMemo(() => {
|
|
return entry?.ChildComponent
|
|
? React.lazy(() =>
|
|
entry.ChildComponent().then((resolvedChildComponent) => ({
|
|
default: resolvedChildComponent,
|
|
})),
|
|
)
|
|
: null
|
|
}, [entry])
|
|
|
|
if (entry.Component) {
|
|
return (
|
|
Component && (
|
|
<React.Suspense>
|
|
<Component anchorElem={anchorElem} editor={editor} entry={entry} key={entry.key} />{' '}
|
|
</React.Suspense>
|
|
)
|
|
)
|
|
}
|
|
|
|
return (
|
|
<ToolbarButton entry={entry} key={entry.key}>
|
|
{ChildComponent && (
|
|
<React.Suspense>
|
|
<ChildComponent />
|
|
</React.Suspense>
|
|
)}
|
|
</ToolbarButton>
|
|
)
|
|
}
|
|
|
|
function ToolbarSection({
|
|
anchorElem,
|
|
editor,
|
|
index,
|
|
section,
|
|
}: {
|
|
anchorElem: HTMLElement
|
|
editor: LexicalEditor
|
|
index: number
|
|
section: FloatingToolbarSection
|
|
}): JSX.Element {
|
|
const { editorConfig } = useEditorConfigContext()
|
|
|
|
const Icon = useMemo(() => {
|
|
return section?.type === 'dropdown' && section.entries.length && section.ChildComponent
|
|
? React.lazy(() =>
|
|
section.ChildComponent().then((resolvedComponent) => ({
|
|
default: resolvedComponent,
|
|
})),
|
|
)
|
|
: null
|
|
}, [section])
|
|
|
|
return (
|
|
<div
|
|
className={`floating-select-toolbar-popup__section floating-select-toolbar-popup__section-${section.key}`}
|
|
key={section.key}
|
|
>
|
|
{section.type === 'dropdown' &&
|
|
section.entries.length &&
|
|
(Icon ? (
|
|
<React.Suspense>
|
|
<ToolbarDropdown
|
|
Icon={Icon}
|
|
anchorElem={anchorElem}
|
|
editor={editor}
|
|
entries={section.entries}
|
|
/>
|
|
</React.Suspense>
|
|
) : (
|
|
<ToolbarDropdown anchorElem={anchorElem} editor={editor} entries={section.entries} />
|
|
))}
|
|
{section.type === 'buttons' &&
|
|
section.entries.length &&
|
|
section.entries.map((entry) => {
|
|
return (
|
|
<ButtonSectionEntry
|
|
anchorElem={anchorElem}
|
|
editor={editor}
|
|
entry={entry}
|
|
key={entry.key}
|
|
/>
|
|
)
|
|
})}
|
|
{index < editorConfig.features.floatingSelectToolbar?.sections.length - 1 && (
|
|
<div className="divider" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FloatingSelectToolbar({
|
|
anchorElem,
|
|
editor,
|
|
}: {
|
|
anchorElem: HTMLElement
|
|
editor: LexicalEditor
|
|
}): JSX.Element {
|
|
const popupCharStylesEditorRef = useRef<HTMLDivElement | null>(null)
|
|
const caretRef = useRef<HTMLDivElement | null>(null)
|
|
|
|
const { editorConfig } = useEditorConfigContext()
|
|
|
|
const closeFloatingToolbar = useCallback(() => {
|
|
if (popupCharStylesEditorRef?.current) {
|
|
const isOpacityZero = popupCharStylesEditorRef.current.style.opacity === '0'
|
|
const isPointerEventsNone = popupCharStylesEditorRef.current.style.pointerEvents === 'none'
|
|
|
|
if (!isOpacityZero) {
|
|
popupCharStylesEditorRef.current.style.opacity = '0'
|
|
}
|
|
if (!isPointerEventsNone) {
|
|
popupCharStylesEditorRef.current.style.pointerEvents = 'none'
|
|
}
|
|
}
|
|
}, [popupCharStylesEditorRef])
|
|
|
|
const mouseMoveListener = useCallback(
|
|
(e: MouseEvent) => {
|
|
if (popupCharStylesEditorRef?.current && (e.buttons === 1 || e.buttons === 3)) {
|
|
const isOpacityZero = popupCharStylesEditorRef.current.style.opacity === '0'
|
|
const isPointerEventsNone = popupCharStylesEditorRef.current.style.pointerEvents === 'none'
|
|
if (!isOpacityZero || !isPointerEventsNone) {
|
|
// Check if the mouse is not over the popup
|
|
const x = e.clientX
|
|
const y = e.clientY
|
|
const elementUnderMouse = document.elementFromPoint(x, y)
|
|
if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) {
|
|
// Mouse is not over the target element => not a normal click, but probably a drag
|
|
closeFloatingToolbar()
|
|
}
|
|
}
|
|
}
|
|
},
|
|
[closeFloatingToolbar],
|
|
)
|
|
|
|
const mouseUpListener = useCallback(() => {
|
|
if (popupCharStylesEditorRef?.current) {
|
|
if (popupCharStylesEditorRef.current.style.opacity !== '1') {
|
|
popupCharStylesEditorRef.current.style.opacity = '1'
|
|
}
|
|
if (popupCharStylesEditorRef.current.style.pointerEvents !== 'auto') {
|
|
popupCharStylesEditorRef.current.style.pointerEvents = 'auto'
|
|
}
|
|
}
|
|
}, [popupCharStylesEditorRef])
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('mousemove', mouseMoveListener)
|
|
document.addEventListener('mouseup', mouseUpListener)
|
|
|
|
return () => {
|
|
document.removeEventListener('mousemove', mouseMoveListener)
|
|
document.removeEventListener('mouseup', mouseUpListener)
|
|
}
|
|
}, [popupCharStylesEditorRef, mouseMoveListener, mouseUpListener])
|
|
|
|
const updateTextFormatFloatingToolbar = useCallback(() => {
|
|
const selection = $getSelection()
|
|
|
|
const popupCharStylesEditorElem = popupCharStylesEditorRef.current
|
|
const nativeSelection = window.getSelection()
|
|
|
|
if (popupCharStylesEditorElem === null) {
|
|
return
|
|
}
|
|
|
|
const rootElement = editor.getRootElement()
|
|
if (
|
|
selection !== null &&
|
|
nativeSelection !== null &&
|
|
!nativeSelection.isCollapsed &&
|
|
rootElement !== null &&
|
|
rootElement.contains(nativeSelection.anchorNode)
|
|
) {
|
|
const rangeRect = getDOMRangeRect(nativeSelection, rootElement)
|
|
|
|
setFloatingElemPosition(rangeRect, popupCharStylesEditorElem, anchorElem, 'center')
|
|
|
|
if (caretRef.current) {
|
|
setFloatingElemPosition(
|
|
rangeRect, // selection to position around
|
|
caretRef.current, // what to position
|
|
popupCharStylesEditorElem, // anchor elem
|
|
'center',
|
|
10,
|
|
5,
|
|
true,
|
|
)
|
|
}
|
|
}
|
|
}, [editor, anchorElem])
|
|
|
|
useEffect(() => {
|
|
const scrollerElem = anchorElem.parentElement
|
|
|
|
const update = () => {
|
|
editor.getEditorState().read(() => {
|
|
updateTextFormatFloatingToolbar()
|
|
})
|
|
}
|
|
|
|
window.addEventListener('resize', update)
|
|
if (scrollerElem) {
|
|
scrollerElem.addEventListener('scroll', update)
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', update)
|
|
if (scrollerElem) {
|
|
scrollerElem.removeEventListener('scroll', update)
|
|
}
|
|
}
|
|
}, [editor, updateTextFormatFloatingToolbar, anchorElem])
|
|
|
|
useEffect(() => {
|
|
editor.getEditorState().read(() => {
|
|
updateTextFormatFloatingToolbar()
|
|
})
|
|
return mergeRegister(
|
|
editor.registerUpdateListener(({ editorState }) => {
|
|
editorState.read(() => {
|
|
updateTextFormatFloatingToolbar()
|
|
})
|
|
}),
|
|
|
|
editor.registerCommand(
|
|
SELECTION_CHANGE_COMMAND,
|
|
() => {
|
|
updateTextFormatFloatingToolbar()
|
|
return false
|
|
},
|
|
COMMAND_PRIORITY_LOW,
|
|
),
|
|
)
|
|
}, [editor, updateTextFormatFloatingToolbar])
|
|
|
|
return (
|
|
<div className="floating-select-toolbar-popup" ref={popupCharStylesEditorRef}>
|
|
<div className="caret" ref={caretRef} />
|
|
{editor.isEditable() && (
|
|
<React.Fragment>
|
|
{editorConfig?.features &&
|
|
editorConfig.features?.floatingSelectToolbar?.sections.map((section, i) => {
|
|
return (
|
|
<ToolbarSection
|
|
anchorElem={anchorElem}
|
|
editor={editor}
|
|
index={i}
|
|
key={section.key}
|
|
section={section}
|
|
/>
|
|
)
|
|
})}
|
|
</React.Fragment>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function useFloatingTextFormatToolbar(
|
|
editor: LexicalEditor,
|
|
anchorElem: HTMLElement,
|
|
): JSX.Element | null {
|
|
const [isText, setIsText] = useState(false)
|
|
|
|
const updatePopup = useCallback(() => {
|
|
editor.getEditorState().read(() => {
|
|
// Should not to pop up the floating toolbar when using IME input
|
|
if (editor.isComposing()) {
|
|
return
|
|
}
|
|
const selection = $getSelection()
|
|
const nativeSelection = window.getSelection()
|
|
const rootElement = editor.getRootElement()
|
|
|
|
if (
|
|
nativeSelection !== null &&
|
|
(!$isRangeSelection(selection) ||
|
|
rootElement === null ||
|
|
!rootElement.contains(nativeSelection.anchorNode))
|
|
) {
|
|
setIsText(false)
|
|
return
|
|
}
|
|
|
|
if (!$isRangeSelection(selection)) {
|
|
return
|
|
}
|
|
|
|
if (selection.getTextContent() !== '') {
|
|
const nodes = selection.getNodes()
|
|
let foundNodeWithText = false
|
|
for (const node of nodes) {
|
|
if ($isTextNode(node)) {
|
|
setIsText(true)
|
|
foundNodeWithText = true
|
|
break
|
|
}
|
|
}
|
|
if (!foundNodeWithText) {
|
|
setIsText(false)
|
|
}
|
|
} else {
|
|
setIsText(false)
|
|
}
|
|
|
|
const rawTextContent = selection.getTextContent().replace(/\n/g, '')
|
|
if (!selection.isCollapsed() && rawTextContent === '') {
|
|
setIsText(false)
|
|
return
|
|
}
|
|
})
|
|
}, [editor])
|
|
|
|
useEffect(() => {
|
|
document.addEventListener('selectionchange', updatePopup)
|
|
document.addEventListener('mouseup', updatePopup)
|
|
return () => {
|
|
document.removeEventListener('selectionchange', updatePopup)
|
|
document.removeEventListener('mouseup', updatePopup)
|
|
}
|
|
}, [updatePopup])
|
|
|
|
useEffect(() => {
|
|
return mergeRegister(
|
|
editor.registerUpdateListener(() => {
|
|
updatePopup()
|
|
}),
|
|
editor.registerRootListener(() => {
|
|
if (editor.getRootElement() === null) {
|
|
setIsText(false)
|
|
}
|
|
}),
|
|
)
|
|
}, [editor, updatePopup])
|
|
|
|
if (!isText) {
|
|
return null
|
|
}
|
|
|
|
return createPortal(<FloatingSelectToolbar anchorElem={anchorElem} editor={editor} />, anchorElem)
|
|
}
|
|
|
|
export function FloatingSelectToolbarPlugin({
|
|
anchorElem = document.body,
|
|
}: {
|
|
anchorElem?: HTMLElement
|
|
}): JSX.Element | null {
|
|
const [editor] = useLexicalComposerContext()
|
|
return useFloatingTextFormatToolbar(editor, anchorElem)
|
|
}
|