hooks.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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 UseSelectOrDeleteHanlder = (nodeKey: string, command?: LexicalCommand<undefined>) => [RefObject<HTMLDivElement>, boolean]
  38. export const useSelectOrDelete: UseSelectOrDeleteHanlder = (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. }
  64. }
  65. return false
  66. },
  67. [isSelected, nodeKey, command, editor],
  68. )
  69. const handleSelect = useCallback((e: MouseEvent) => {
  70. e.stopPropagation()
  71. clearSelection()
  72. setSelected(true)
  73. }, [setSelected, clearSelection])
  74. useEffect(() => {
  75. const ele = ref.current
  76. if (ele)
  77. ele.addEventListener('click', handleSelect)
  78. return () => {
  79. if (ele)
  80. ele.removeEventListener('click', handleSelect)
  81. }
  82. }, [handleSelect])
  83. useEffect(() => {
  84. return mergeRegister(
  85. editor.registerCommand(
  86. KEY_DELETE_COMMAND,
  87. handleDelete,
  88. COMMAND_PRIORITY_LOW,
  89. ),
  90. editor.registerCommand(
  91. KEY_BACKSPACE_COMMAND,
  92. handleDelete,
  93. COMMAND_PRIORITY_LOW,
  94. ),
  95. )
  96. }, [editor, clearSelection, handleDelete])
  97. return [ref, isSelected]
  98. }
  99. export type UseTriggerHandler = () => [RefObject<HTMLDivElement>, boolean, Dispatch<SetStateAction<boolean>>]
  100. export const useTrigger: UseTriggerHandler = () => {
  101. const triggerRef = useRef<HTMLDivElement>(null)
  102. const [open, setOpen] = useState(false)
  103. const handleOpen = useCallback((e: MouseEvent) => {
  104. e.stopPropagation()
  105. setOpen(v => !v)
  106. }, [])
  107. useEffect(() => {
  108. const trigger = triggerRef.current
  109. if (trigger)
  110. trigger.addEventListener('click', handleOpen)
  111. return () => {
  112. if (trigger)
  113. trigger.removeEventListener('click', handleOpen)
  114. }
  115. }, [handleOpen])
  116. return [triggerRef, open, setOpen]
  117. }
  118. export function useLexicalTextEntity<T extends TextNode>(
  119. getMatch: (text: string) => null | EntityMatch,
  120. targetNode: Klass<T>,
  121. createNode: (textNode: CustomTextNode) => T,
  122. ) {
  123. const [editor] = useLexicalComposerContext()
  124. useEffect(() => {
  125. return mergeRegister(...registerLexicalTextEntity(editor, getMatch, targetNode, createNode))
  126. }, [createNode, editor, getMatch, targetNode])
  127. }
  128. export type MenuTextMatch = {
  129. leadOffset: number
  130. matchingString: string
  131. replaceableString: string
  132. }
  133. export type TriggerFn = (
  134. text: string,
  135. editor: LexicalEditor,
  136. ) => MenuTextMatch | null
  137. export const PUNCTUATION = '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'
  138. export function useBasicTypeaheadTriggerMatch(
  139. trigger: string,
  140. { minLength = 1, maxLength = 75 }: { minLength?: number; maxLength?: number },
  141. ): TriggerFn {
  142. return useCallback(
  143. (text: string) => {
  144. const validChars = `[^${trigger}${PUNCTUATION}\\s]`
  145. const TypeaheadTriggerRegex = new RegExp(
  146. `([^${trigger}]|^)(`
  147. + `[${trigger}]`
  148. + `((?:${validChars}){0,${maxLength}})`
  149. + ')$',
  150. )
  151. const match = TypeaheadTriggerRegex.exec(text)
  152. if (match !== null) {
  153. const maybeLeadingWhitespace = match[1]
  154. const matchingString = match[3]
  155. if (matchingString.length >= minLength) {
  156. return {
  157. leadOffset: match.index + maybeLeadingWhitespace.length,
  158. matchingString,
  159. replaceableString: match[2],
  160. }
  161. }
  162. }
  163. return null
  164. },
  165. [maxLength, minLength, trigger],
  166. )
  167. }