diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 2e3a61f22e..ea1b62fb6a 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -197,20 +197,22 @@ const PromptEditor: FC = ({ } ErrorBoundary={LexicalErrorBoundary} /> - {floatingAnchorElem && ( - - {closePortal => ( -
-
test content
- -
- )} -
- )} + + {closePortal => ( +
+
test content
+ +
+ )} +
= ({ withContainer = true }) => { + const initialConfig = { + namespace: 'shortcuts-popup-plugin-test', + onError: (e: Error) => { + throw e + }, + } + const [containerEl, setContainerEl] = useState(null) + + return ( + +
+ } + placeholder={null} + ErrorBoundary={LexicalErrorBoundary} + /> + +
+
+ ) +} + +describe('ShortcutsPopupPlugin', () => { + test('opens on hotkey when editor is focused', async () => { + render() + 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() + // 未聚焦 + fireEvent.keyDown(document, { key: '/', ctrlKey: true }) + await waitFor(() => { + expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument() + }) + }) + + test('closes on Escape', async () => { + render() + 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() + 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() + 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() + 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) + }) +}) diff --git a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx index 9a6b160c31..33a23d735f 100644 --- a/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx @@ -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({
- {typeof children === 'function' ? children(closePortal) : (children ?? 'empty')} + {typeof children === 'function' ? children(closePortal) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
, containerEl, )