index.tsx 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. 'use client'
  2. import type { ChangeEvent, FC } from 'react'
  3. import React, { useCallback, useEffect, useRef, useState } from 'react'
  4. import { useTranslation } from 'react-i18next'
  5. import { varHighlightHTML } from '../../app/configuration/base/var-highlight'
  6. import Toast from '../toast'
  7. import classNames from '@/utils/classnames'
  8. import { checkKeys } from '@/utils/var'
  9. // regex to match the {{}} and replace it with a span
  10. const regex = /\{\{([^}]+)\}\}/g
  11. export const getInputKeys = (value: string) => {
  12. const keys = value.match(regex)?.map((item) => {
  13. return item.replace('{{', '').replace('}}', '')
  14. }) || []
  15. const keyObj: Record<string, boolean> = {}
  16. // remove duplicate keys
  17. const res: string[] = []
  18. keys.forEach((key) => {
  19. if (keyObj[key])
  20. return
  21. keyObj[key] = true
  22. res.push(key)
  23. })
  24. return res
  25. }
  26. export type IBlockInputProps = {
  27. value: string
  28. className?: string // wrapper class
  29. highLightClassName?: string // class for the highlighted text default is text-blue-500
  30. readonly?: boolean
  31. onConfirm?: (value: string, keys: string[]) => void
  32. }
  33. const BlockInput: FC<IBlockInputProps> = ({
  34. value = '',
  35. className,
  36. readonly = false,
  37. onConfirm,
  38. }) => {
  39. const { t } = useTranslation()
  40. // current is used to store the current value of the contentEditable element
  41. const [currentValue, setCurrentValue] = useState<string>(value)
  42. useEffect(() => {
  43. setCurrentValue(value)
  44. }, [value])
  45. const contentEditableRef = useRef<HTMLTextAreaElement>(null)
  46. const [isEditing, setIsEditing] = useState<boolean>(false)
  47. useEffect(() => {
  48. if (isEditing && contentEditableRef.current) {
  49. // TODO: Focus at the click position
  50. if (currentValue)
  51. contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
  52. contentEditableRef.current.focus()
  53. }
  54. }, [isEditing])
  55. const style = classNames({
  56. 'block px-4 py-2 w-full h-full text-sm text-gray-900 outline-0 border-0 break-all': true,
  57. 'block-input--editing': isEditing,
  58. })
  59. const coloredContent = (currentValue || '')
  60. .replace(/</g, '&lt;')
  61. .replace(/>/g, '&gt;')
  62. .replace(regex, varHighlightHTML({ name: '$1' })) // `<span class="${highLightClassName}">{{$1}}</span>`
  63. .replace(/\n/g, '<br />')
  64. // Not use useCallback. That will cause out callback get old data.
  65. const handleSubmit = (value: string) => {
  66. if (onConfirm) {
  67. const keys = getInputKeys(value)
  68. const { isValid, errorKey, errorMessageKey } = checkKeys(keys)
  69. if (!isValid) {
  70. Toast.notify({
  71. type: 'error',
  72. message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
  73. })
  74. return
  75. }
  76. onConfirm(value, keys)
  77. }
  78. }
  79. const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
  80. const value = e.target.value
  81. setCurrentValue(value)
  82. handleSubmit(value)
  83. }, [])
  84. // Prevent rerendering caused cursor to jump to the start of the contentEditable element
  85. const TextAreaContentView = () => {
  86. return <div
  87. className={classNames(style, className)}
  88. dangerouslySetInnerHTML={{ __html: coloredContent }}
  89. suppressContentEditableWarning={true}
  90. />
  91. }
  92. const placeholder = ''
  93. const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
  94. const textAreaContent = (
  95. <div className={classNames(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
  96. {isEditing
  97. ? <div className='h-full px-4 py-2'>
  98. <textarea
  99. ref={contentEditableRef}
  100. className={classNames(editAreaClassName, 'block w-full h-full resize-none')}
  101. placeholder={placeholder}
  102. onChange={onValueChange}
  103. value={currentValue}
  104. onBlur={() => {
  105. blur()
  106. setIsEditing(false)
  107. // click confirm also make blur. Then outer value is change. So below code has problem.
  108. // setTimeout(() => {
  109. // handleCancel()
  110. // }, 1000)
  111. }}
  112. />
  113. </div>
  114. : <TextAreaContentView />}
  115. </div>)
  116. return (
  117. <div className={classNames('block-input w-full overflow-y-auto bg-white border-none rounded-xl')}>
  118. {textAreaContent}
  119. {/* footer */}
  120. {!readonly && (
  121. <div className='pl-4 pb-2 flex'>
  122. <div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{currentValue?.length}</div>
  123. </div>
  124. )}
  125. </div>
  126. )
  127. }
  128. export default React.memo(BlockInput)