import type { EntityMatch } from '@lexical/text' import type { Klass, LexicalCommand, LexicalEditor, TextNode } from 'lexical' import type { Dispatch, RefObject, SetStateAction } from 'react' import type { CustomTextNode } from './plugins/custom-text/node' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection' import { mergeRegister } from '@lexical/utils' import { $getNodeByKey, $getSelection, $isDecoratorNode, $isNodeSelection, COMMAND_PRIORITY_LOW, KEY_BACKSPACE_COMMAND, KEY_DELETE_COMMAND, } from 'lexical' import { useCallback, useEffect, useRef, useState } from 'react' import { DELETE_CONTEXT_BLOCK_COMMAND } from './plugins/context-block' import { $isContextBlockNode } from './plugins/context-block/node' import { DELETE_HISTORY_BLOCK_COMMAND } from './plugins/history-block' import { $isHistoryBlockNode } from './plugins/history-block/node' import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block' import { $isQueryBlockNode } from './plugins/query-block/node' import { registerLexicalTextEntity } from './utils' 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) => { if (!e.metaKey && !e.ctrlKey) { 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] } 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]) } type MenuTextMatch = { leadOffset: number matchingString: string replaceableString: string } type TriggerFn = (text: string, editor: LexicalEditor) => MenuTextMatch | null const escapeForCharacterClass = (value: string) => value.replace(/[[\]\\^-]/g, '\\$&') export function useBasicTypeaheadTriggerMatch( trigger: string, { minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number }, ): TriggerFn { return useCallback( (text: string) => { const escapedTrigger = escapeForCharacterClass(trigger) const validChars = `[^${escapedTrigger}\\n\\r]` const TypeaheadTriggerRegex = new RegExp( '(.*)(' + `[${escapedTrigger}]` + `((?:${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: matchingString!, replaceableString: match[2]!, } } } return null }, [maxLength, minLength, trigger], ) }