mirror of
https://github.com/langgenius/dify.git
synced 2026-04-30 05:06:29 +08:00
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 { CustomTextNode } from './plugins/custom-text/node'
|
||||||
import OnBlurBlock from './plugins/on-blur-or-focus-block'
|
import OnBlurBlock from './plugins/on-blur-or-focus-block'
|
||||||
import UpdateBlock from './plugins/update-block'
|
import UpdateBlock from './plugins/update-block'
|
||||||
|
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
|
||||||
import { textToEditorState } from './utils'
|
import { textToEditorState } from './utils'
|
||||||
import type {
|
import type {
|
||||||
ContextBlockType,
|
ContextBlockType,
|
||||||
@ -196,6 +197,20 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||||||
}
|
}
|
||||||
ErrorBoundary={LexicalErrorBoundary}
|
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
|
<ComponentPickerBlock
|
||||||
triggerString='/'
|
triggerString='/'
|
||||||
contextBlock={contextBlock}
|
contextBlock={contextBlock}
|
||||||
@ -285,8 +300,8 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||||||
<UpdateBlock instanceId={instanceId} />
|
<UpdateBlock instanceId={instanceId} />
|
||||||
<HistoryPlugin />
|
<HistoryPlugin />
|
||||||
{floatingAnchorElem && (
|
{floatingAnchorElem && (
|
||||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||||
)}
|
)}
|
||||||
{/* <TreeView /> */}
|
{/* <TreeView /> */}
|
||||||
</div>
|
</div>
|
||||||
</LexicalComposer>
|
</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
Block a user