mirror of https://github.com/langgenius/dify.git
feat: shortcut popup unit test
This commit is contained in:
parent
c771f4dbc7
commit
a9ea8cfd1c
|
|
@ -197,20 +197,22 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{floatingAnchorElem && (
|
||||
<ShortcutsPopupPlugin
|
||||
container={floatingAnchorElem}
|
||||
>
|
||||
{closePortal => (
|
||||
<div>
|
||||
<div>test content</div>
|
||||
<button className='text-xs text-text-primary' onClick={closePortal}>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ShortcutsPopupPlugin>
|
||||
)}
|
||||
<ShortcutsPopupPlugin>
|
||||
{closePortal => (
|
||||
<div>
|
||||
<div>test content</div>
|
||||
<button
|
||||
className='rounded border border-text-secondary text-xs text-text-primary'
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault() // necessary, otherwise the editor will lose focus
|
||||
closePortal()
|
||||
}}
|
||||
>
|
||||
close
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ShortcutsPopupPlugin>
|
||||
<ComponentPickerBlock
|
||||
triggerString='/'
|
||||
contextBlock={contextBlock}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
|
||||
|
||||
const CONTAINER_ID = 'host'
|
||||
const CONTENT_EDITABLE_ID = 'ce'
|
||||
|
||||
const MinimalEditor: React.FC<{
|
||||
withContainer?: boolean
|
||||
}> = ({ withContainer = true }) => {
|
||||
const initialConfig = {
|
||||
namespace: 'shortcuts-popup-plugin-test',
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
}
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ShortcutsPopupPlugin', () => {
|
||||
test('opens on hotkey when editor is focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('does not open when editor is not focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
// 未聚焦
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('closes on Escape', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('closes on click outside', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(ce)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
test('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(host).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
test('falls back to document.body when container is not provided', async () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
})
|
||||
})
|
||||
|
|
@ -13,6 +13,8 @@ import {
|
|||
} from 'lexical'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
|
||||
|
||||
type Hotkey = string | ((e: KeyboardEvent) => boolean)
|
||||
|
||||
type ShortcutPopupPluginProps = {
|
||||
|
|
@ -119,7 +121,6 @@ export default function ShortcutsPopupPlugin({
|
|||
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
|
||||
const useContainer = !!containerEl && containerEl !== document.body
|
||||
|
||||
// 记录最近一次的 DOM 选择范围
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
|
|
@ -134,26 +135,44 @@ export default function ShortcutsPopupPlugin({
|
|||
}, [editor])
|
||||
|
||||
const setPositionFromRange = useCallback((range: Range | null) => {
|
||||
if (!range)
|
||||
return
|
||||
const rect = range.getBoundingClientRect()
|
||||
if (!range) return
|
||||
const dx = offset?.x ?? 0
|
||||
const dy = offset?.y ?? 0
|
||||
|
||||
let rect: DOMRect | null = null
|
||||
const rects = range.getClientRects()
|
||||
if (rects && rects.length) {
|
||||
rect = rects[rects.length - 1]
|
||||
}
|
||||
else {
|
||||
const r = range.getBoundingClientRect()
|
||||
if (!(r.top === 0 && r.left === 0 && r.width === 0 && r.height === 0))
|
||||
rect = r
|
||||
}
|
||||
|
||||
if (!rect) {
|
||||
const root = editor.getRootElement()
|
||||
const sc = range.startContainer
|
||||
const anchorEl = (sc.nodeType === Node.ELEMENT_NODE ? sc as Element : (sc.parentElement || root)) as Element | null
|
||||
if (!anchorEl) return
|
||||
const ar = anchorEl.getBoundingClientRect()
|
||||
rect = new DOMRect(ar.left, ar.top, ar.width, ar.height)
|
||||
}
|
||||
|
||||
if (useContainer) {
|
||||
const el = containerEl as HTMLElement
|
||||
const crect = el.getBoundingClientRect()
|
||||
const crect = (containerEl as HTMLElement).getBoundingClientRect()
|
||||
setPosition({
|
||||
top: rect.bottom - crect.top + dy,
|
||||
left: rect.left - crect.left + dx,
|
||||
top: rect!.bottom - crect.top + dy,
|
||||
left: rect!.left - crect.left + dx,
|
||||
})
|
||||
}
|
||||
else {
|
||||
setPosition({
|
||||
top: rect.bottom + window.scrollY + dy,
|
||||
left: rect.left + window.scrollX + dx,
|
||||
top: rect!.bottom + window.scrollY + dy,
|
||||
left: rect!.left + window.scrollX + dx,
|
||||
})
|
||||
}
|
||||
}, [offset?.x, offset?.y])
|
||||
}, [editor, containerEl, useContainer, offset?.x, offset?.y])
|
||||
|
||||
const isEditorFocused = useCallback(() => {
|
||||
const root = editor.getRootElement()
|
||||
|
|
@ -223,13 +242,13 @@ export default function ShortcutsPopupPlugin({
|
|||
<div
|
||||
ref={portalRef}
|
||||
className={cn(
|
||||
useContainer ? 'absolute' : 'fixed z-[999999]',
|
||||
'rounded-md bg-white shadow-lg',
|
||||
useContainer ? '' : 'z-[999999]',
|
||||
'absolute rounded-md bg-slate-50 shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px`, ...style }}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal) : (children ?? 'empty')}
|
||||
{typeof children === 'function' ? children(closePortal) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue