index.tsx 5.4 KB

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