mirror of https://github.com/langgenius/dify.git
feat: shortcut popup
This commit is contained in:
parent
ebbed8f863
commit
c771f4dbc7
|
|
@ -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<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>
|
||||
)}
|
||||
<ComponentPickerBlock
|
||||
triggerString='/'
|
||||
contextBlock={contextBlock}
|
||||
|
|
@ -285,8 +300,8 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
<UpdateBlock instanceId={instanceId} />
|
||||
<HistoryPlugin />
|
||||
{floatingAnchorElem && (
|
||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||
)}
|
||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||
)}
|
||||
{/* <TreeView /> */}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
|
|
|||
|
|
@ -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<Position>({ top: 0, left: 0 })
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(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(
|
||||
<div
|
||||
ref={portalRef}
|
||||
className={cn(
|
||||
useContainer ? 'absolute' : 'fixed z-[999999]',
|
||||
'rounded-md bg-white shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{ top: `${position.top}px`, left: `${position.left}px`, ...style }}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal) : (children ?? 'empty')}
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue