feat: shortcut popup unit test

This commit is contained in:
yessenia 2025-08-21 15:35:14 +08:00
parent c771f4dbc7
commit a9ea8cfd1c
3 changed files with 156 additions and 28 deletions

View File

@ -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}

View File

@ -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)
})
})

View File

@ -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,
)