diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 054cfb6f85..2e3a61f22e 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -56,6 +56,7 @@ import { VariableValueBlockNode } from './plugins/variable-value-block/node' import { CustomTextNode } from './plugins/custom-text/node' import OnBlurBlock from './plugins/on-blur-or-focus-block' import UpdateBlock from './plugins/update-block' +import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin' import { textToEditorState } from './utils' import type { ContextBlockType, @@ -196,6 +197,20 @@ const PromptEditor: FC = ({ } ErrorBoundary={LexicalErrorBoundary} /> + {floatingAnchorElem && ( + + {closePortal => ( +
+
test content
+ +
+ )} +
+ )} = ({ {floatingAnchorElem && ( - - )} + + )} {/* */} 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 new file mode 100644 index 0000000000..9a6b160c31 --- /dev/null +++ b/web/app/components/base/prompt-editor/plugins/shortcuts-popup-plugin/index.tsx @@ -0,0 +1,236 @@ +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { + $getSelection, + $isRangeSelection, +} from 'lexical' +import cn from '@/utils/classnames' + +type Hotkey = string | ((e: KeyboardEvent) => boolean) + +type ShortcutPopupPluginProps = { + hotkey?: Hotkey + children?: React.ReactNode | ((close: () => void) => React.ReactNode) + className?: string + style?: React.CSSProperties + container?: Element | null + offset?: { + x?: number + y?: number + } + onOpen?: () => void + onClose?: () => void +} + +type Position = { + top: number + left: number +} + +const META_ALIASES = new Set(['meta', 'cmd', 'command']) +const CTRL_ALIASES = new Set(['ctrl']) +const ALT_ALIASES = new Set(['alt', 'option']) +const SHIFT_ALIASES = new Set(['shift']) + +function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) { + if (!hotkey) + return false + + if (typeof hotkey === 'function') + return hotkey(event) + + const parts = hotkey.toLowerCase().split('+').map(t => t.trim()).filter(Boolean) + let expectedKey: string | null = null + + let needMod = false + let needCtrl = false + let needMeta = false + let needAlt = false + let needShift = false + + for (const p of parts) { + if (p === 'mod') { + needMod = true + continue + } + if (CTRL_ALIASES.has(p)) { + needCtrl = true + continue + } + if (META_ALIASES.has(p)) { + needMeta = true + continue + } + if (ALT_ALIASES.has(p)) { + needAlt = true + continue + } + if (SHIFT_ALIASES.has(p)) { + needShift = true + continue + } + expectedKey = p + } + + if (needMod && !(event.metaKey || event.ctrlKey)) + return false + if (needCtrl && !event.ctrlKey) + return false + if (needMeta && !event.metaKey) + return false + if (needAlt && !event.altKey) + return false + if (needShift && !event.shiftKey) + return false + + if (expectedKey) { + const k = event.key.toLowerCase() + const normalized = k === ' ' ? 'space' : k + if (normalized !== expectedKey) + return false + } + + return true +} + +export default function ShortcutsPopupPlugin({ + hotkey = 'mod+/', + children, + className, + style, + container, + offset, + onOpen, + onClose, +}: ShortcutPopupPluginProps): React.ReactPortal | null { + const [editor] = useLexicalComposerContext() + const [open, setOpen] = useState(false) + const [position, setPosition] = useState({ top: 0, left: 0 }) + const portalRef = useRef(null) + const lastSelectionRef = useRef(null) + + 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(() => { + const selection = $getSelection() + if ($isRangeSelection(selection)) { + const domSelection = window.getSelection() + if (domSelection && domSelection.rangeCount > 0) + lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange() + } + }) + }) + }, [editor]) + + const setPositionFromRange = useCallback((range: Range | null) => { + if (!range) + return + const rect = range.getBoundingClientRect() + const dx = offset?.x ?? 0 + const dy = offset?.y ?? 0 + if (useContainer) { + const el = containerEl as HTMLElement + const crect = el.getBoundingClientRect() + setPosition({ + 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, + }) + } + }, [offset?.x, offset?.y]) + + const isEditorFocused = useCallback(() => { + const root = editor.getRootElement() + if (!root) + return false + return root.contains(document.activeElement) + }, [editor]) + + const openPortal = useCallback(() => { + const domSelection = window.getSelection() + let range: Range | null = null + if (domSelection && domSelection.rangeCount > 0) + range = domSelection.getRangeAt(0).cloneRange() + else + range = lastSelectionRef.current + + setPositionFromRange(range) + setOpen(true) + onOpen?.() + }, [onOpen, setPositionFromRange]) + + const closePortal = useCallback(() => { + setOpen(false) + onClose?.() + }, [onClose]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (open && event.key === 'Escape') { + event.stopPropagation() + event.preventDefault() + closePortal() + return + } + + if (!isEditorFocused()) + return + + if (matchHotkey(event, hotkey)) { + event.preventDefault() + openPortal() + } + } + + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [hotkey, open, isEditorFocused, openPortal, closePortal]) + + useEffect(() => { + if (!open) + return + + const onMouseDown = (e: MouseEvent) => { + if (!portalRef.current) + return + if (!portalRef.current.contains(e.target as Node)) + closePortal() + } + document.addEventListener('mousedown', onMouseDown, true) + return () => document.removeEventListener('mousedown', onMouseDown, true) + }, [open, closePortal]) + + if (!open || !containerEl) + return null + + return createPortal( +
+ {typeof children === 'function' ? children(closePortal) : (children ?? 'empty')} +
, + containerEl, + ) +}