hooks.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import {
  2. useCallback,
  3. useEffect,
  4. useRef,
  5. useState,
  6. } from 'react'
  7. import type { Dispatch, RefObject, SetStateAction } from 'react'
  8. import type {
  9. Klass,
  10. LexicalCommand,
  11. LexicalEditor,
  12. TextNode,
  13. } from 'lexical'
  14. import {
  15. $getNodeByKey,
  16. $getSelection,
  17. $isDecoratorNode,
  18. $isNodeSelection,
  19. COMMAND_PRIORITY_LOW,
  20. KEY_BACKSPACE_COMMAND,
  21. KEY_DELETE_COMMAND,
  22. } from 'lexical'
  23. import type { EntityMatch } from '@lexical/text'
  24. import {
  25. mergeRegister,
  26. } from '@lexical/utils'
  27. import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection'
  28. import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
  29. import { $isContextBlockNode } from './plugins/context-block/node'
  30. import { DELETE_CONTEXT_BLOCK_COMMAND } from './plugins/context-block'
  31. import { $isHistoryBlockNode } from './plugins/history-block/node'
  32. import { DELETE_HISTORY_BLOCK_COMMAND } from './plugins/history-block'
  33. import { $isQueryBlockNode } from './plugins/query-block/node'
  34. import { DELETE_QUERY_BLOCK_COMMAND } from './plugins/query-block'
  35. import type { CustomTextNode } from './plugins/custom-text/node'
  36. import { registerLexicalTextEntity } from './utils'
  37. export type UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => [RefObject<HTMLDivElement>, boolean]
  38. export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, command?: LexicalCommand<undefined>) => {
  39. const ref = useRef<HTMLDivElement>(null)
  40. const [editor] = useLexicalComposerContext()
  41. const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey)
  42. const handleDelete = useCallback(
  43. (event: KeyboardEvent) => {
  44. const selection = $getSelection()
  45. const nodes = selection?.getNodes()
  46. if (
  47. !isSelected
  48. && nodes?.length === 1
  49. && (
  50. ($isContextBlockNode(nodes[0]) && command === DELETE_CONTEXT_BLOCK_COMMAND)
  51. || ($isHistoryBlockNode(nodes[0]) && command === DELETE_HISTORY_BLOCK_COMMAND)
  52. || ($isQueryBlockNode(nodes[0]) && command === DELETE_QUERY_BLOCK_COMMAND)
  53. )
  54. )
  55. editor.dispatchCommand(command, undefined)
  56. if (isSelected && $isNodeSelection(selection)) {
  57. event.preventDefault()
  58. const node = $getNodeByKey(nodeKey)
  59. if ($isDecoratorNode(node)) {
  60. if (command)
  61. editor.dispatchCommand(command, undefined)
  62. node.remove()
  63. return true
  64. }
  65. }
  66. return false
  67. },
  68. [isSelected, nodeKey, command, editor],
  69. )
  70. const handleSelect = useCallback((e: MouseEvent) => {
  71. e.stopPropagation()
  72. clearSelection()
  73. setSelected(true)
  74. }, [setSelected, clearSelection])
  75. useEffect(() => {
  76. const ele = ref.current
  77. if (ele)
  78. ele.addEventListener('click', handleSelect)
  79. return () => {
  80. if (ele)
  81. ele.removeEventListener('click', handleSelect)
  82. }
  83. }, [handleSelect])
  84. useEffect(() => {
  85. return mergeRegister(
  86. editor.registerCommand(
  87. KEY_DELETE_COMMAND,
  88. handleDelete,
  89. COMMAND_PRIORITY_LOW,
  90. ),
  91. editor.registerCommand(
  92. KEY_BACKSPACE_COMMAND,
  93. handleDelete,
  94. COMMAND_PRIORITY_LOW,
  95. ),
  96. )
  97. }, [editor, clearSelection, handleDelete])
  98. return [ref, isSelected]
  99. }
  100. export type UseTriggerHandler = () => [RefObject<HTMLDivElement>, boolean, Dispatch<SetStateAction<boolean>>]
  101. export const useTrigger: UseTriggerHandler = () => {
  102. const triggerRef = useRef<HTMLDivElement>(null)
  103. const [open, setOpen] = useState(false)
  104. const handleOpen = useCallback((e: MouseEvent) => {
  105. e.stopPropagation()
  106. setOpen(v => !v)
  107. }, [])
  108. useEffect(() => {
  109. const trigger = triggerRef.current
  110. if (trigger)
  111. trigger.addEventListener('click', handleOpen)
  112. return () => {
  113. if (trigger)
  114. trigger.removeEventListener('click', handleOpen)
  115. }
  116. }, [handleOpen])
  117. return [triggerRef, open, setOpen]
  118. }
  119. export function useLexicalTextEntity<T extends TextNode>(
  120. getMatch: (text: string) => null | EntityMatch,
  121. targetNode: Klass<T>,
  122. createNode: (textNode: CustomTextNode) => T,
  123. ) {
  124. const [editor] = useLexicalComposerContext()
  125. useEffect(() => {
  126. return mergeRegister(...registerLexicalTextEntity(editor, getMatch, targetNode, createNode))
  127. }, [createNode, editor, getMatch, targetNode])
  128. }
  129. export type MenuTextMatch = {
  130. leadOffset: number
  131. matchingString: string
  132. replaceableString: string
  133. }
  134. export type TriggerFn = (
  135. text: string,
  136. editor: LexicalEditor,
  137. ) => MenuTextMatch | null
  138. export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
  139. export function useBasicTypeaheadTriggerMatch(
  140. trigger: string,
  141. { minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
  142. ): TriggerFn {
  143. return useCallback(
  144. (text: string) => {
  145. const validChars = `[${PUNCTUATION}\\s]`
  146. const TypeaheadTriggerRegex = new RegExp(
  147. '(.*)('
  148. + `[${trigger}]`
  149. + `((?:${validChars}){0,${maxLength}})`
  150. + ')$',
  151. )
  152. const match = TypeaheadTriggerRegex.exec(text)
  153. if (match !== null) {
  154. const maybeLeadingWhitespace = match[1]
  155. const matchingString = match[3]
  156. if (matchingString.length >= minLength) {
  157. return {
  158. leadOffset: match.index + maybeLeadingWhitespace.length,
  159. matchingString,
  160. replaceableString: match[2],
  161. }
  162. }
  163. }
  164. return null
  165. },
  166. [maxLength, minLength, trigger],
  167. )
  168. }