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 UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => [RefObject, boolean] export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand) => { const ref = useRef(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, boolean, Dispatch>] export const useTrigger: UseTriggerHandler = () => { const triggerRef = useRef(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( getMatch: (text: string) => null | EntityMatch, targetNode: Klass, 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], ) }