dify/web/app/components/base/prompt-editor/hooks.ts

186 lines
5.4 KiB
TypeScript

import {
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import type { Dispatch, RefObject, SetStateAction } from 'react'
import type {
Klass,
LexicalCommand,
LexicalEditor,
TextNode,
} from 'lexical'
import {
$getNodeByKey,
$getSelection,
$isDecoratorNode,
$isNodeSelection,
COMMAND_PRIORITY_LOW,
KEY_BACKSPACE_COMMAND,
KEY_DELETE_COMMAND,
} from 'lexical'
import type { EntityMatch } from '@lexical/text'
import {
mergeRegister,
} from '@lexical/utils'
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { $isContextBlockNode } from './plugins/context-block/node'
import { DELETE_CONTEXT_BLOCK_COMMAND } from './plugins/context-block'
import { $isHistoryBlockNode } from './plugins/history-block/node'
import { DELETE_HISTORY_BLOCK_COMMAND } from './plugins/history-block'
import { $isQueryBlockNode } from './plugins/query-block/node'
import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block'
import type { CustomTextNode } from './plugins/custom-text/node'
import { registerLexicalTextEntity } from './utils'
export type UseSelectOrDeleteHanlder = (nodeKey: string, command?: LexicalCommand<undefined>) => [RefObject<HTMLDivElement>, boolean]
export const useSelectOrDelete: UseSelectOrDeleteHanlder = (nodeKey: string, command?: LexicalCommand<undefined>) => {
const ref = useRef<HTMLDivElement>(null)
const [editor] = useLexicalComposerContext()
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
const handleDelete = useCallback(
(event: KeyboardEvent) => {
const selection = $getSelection()
const nodes = selection?.getNodes()
if (
!isSelected
&& nodes?.length === 1
&& (
($isContextBlockNode(nodes[0]) && command === DELETE_CONTEXT_BLOCK_COMMAND)
|| ($isHistoryBlockNode(nodes[0]) && command === DELETE_HISTORY_BLOCK_COMMAND)
|| ($isQueryBlockNode(nodes[0]) && command === DELETE_QUERY_BLOCK_COMMAND)
)
)
editor.dispatchCommand(command, undefined)
if (isSelected && $isNodeSelection(selection)) {
event.preventDefault()
const node = $getNodeByKey(nodeKey)
if ($isDecoratorNode(node)) {
if (command)
editor.dispatchCommand(command, undefined)
node.remove()
return true
}
}
return false
},
[isSelected, nodeKey, command, editor],
)
const handleSelect = useCallback((e: MouseEvent) => {
e.stopPropagation()
clearSelection()
setSelected(true)
}, [setSelected, clearSelection])
useEffect(() => {
const ele = ref.current
if (ele)
ele.addEventListener('click', handleSelect)
return () => {
if (ele)
ele.removeEventListener('click', handleSelect)
}
}, [handleSelect])
useEffect(() => {
return mergeRegister(
editor.registerCommand(
KEY_DELETE_COMMAND,
handleDelete,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_BACKSPACE_COMMAND,
handleDelete,
COMMAND_PRIORITY_LOW,
),
)
}, [editor, clearSelection, handleDelete])
return [ref, isSelected]
}
export type UseTriggerHandler = () => [RefObject<HTMLDivElement>, boolean, Dispatch<SetStateAction<boolean>>]
export const useTrigger: UseTriggerHandler = () => {
const triggerRef = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const handleOpen = useCallback((e: MouseEvent) => {
e.stopPropagation()
setOpen(v => !v)
}, [])
useEffect(() => {
const trigger = triggerRef.current
if (trigger)
trigger.addEventListener('click', handleOpen)
return () => {
if (trigger)
trigger.removeEventListener('click', handleOpen)
}
}, [handleOpen])
return [triggerRef, open, setOpen]
}
export function useLexicalTextEntity<T extends TextNode>(
getMatch: (text: string) => null | EntityMatch,
targetNode: Klass<T>,
createNode: (textNode: CustomTextNode) => T,
) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
return mergeRegister(...registerLexicalTextEntity(editor, getMatch, targetNode, createNode))
}, [createNode, editor, getMatch, targetNode])
}
export type MenuTextMatch = {
leadOffset: number
matchingString: string
replaceableString: string
}
export type TriggerFn = (
text: string,
editor: LexicalEditor,
) => MenuTextMatch | null
export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
export function useBasicTypeaheadTriggerMatch(
trigger: string,
{ minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
): TriggerFn {
return useCallback(
(text: string) => {
const validChars = `[${PUNCTUATION}\\s]`
const TypeaheadTriggerRegex = new RegExp(
'(.*)('
+ `[${trigger}]`
+ `((?:${validChars}){0,${maxLength}})`
+ ')$',
)
const match = TypeaheadTriggerRegex.exec(text)
if (match !== null) {
const maybeLeadingWhitespace = match[1]
const matchingString = match[3]
if (matchingString.length >= minLength) {
return {
leadOffset: match.index + maybeLeadingWhitespace.length,
matchingString,
replaceableString: match[2],
}
}
}
return null
},
[maxLength, minLength, trigger],
)
}