index.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149
  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 isContentChanged = value !== currentValue
  46. const contentEditableRef = useRef<HTMLTextAreaElement>(null)
  47. const [isEditing, setIsEditing] = useState<boolean>(false)
  48. useEffect(() => {
  49. if (isEditing && contentEditableRef.current) {
  50. // TODO: Focus at the click positon
  51. if (currentValue)
  52. contentEditableRef.current.setSelectionRange(currentValue.length, currentValue.length)
  53. contentEditableRef.current.focus()
  54. }
  55. }, [isEditing])
  56. const style = classNames({
  57. 'block px-4 py-2 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 = (value: string) => {
  67. if (onConfirm) {
  68. const keys = getInputKeys(value)
  69. const { isValid, errorKey, errorMessageKey } = checkKeys(keys)
  70. if (!isValid) {
  71. Toast.notify({
  72. type: 'error',
  73. message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
  74. })
  75. return
  76. }
  77. onConfirm(value, keys)
  78. }
  79. }
  80. const onValueChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
  81. const value = e.target.value
  82. setCurrentValue(value)
  83. handleSubmit(value)
  84. }, [])
  85. // Prevent rerendering caused cursor to jump to the start of the contentEditable element
  86. const TextAreaContentView = () => {
  87. return <div
  88. className={classNames(style, className)}
  89. dangerouslySetInnerHTML={{ __html: coloredContent }}
  90. suppressContentEditableWarning={true}
  91. />
  92. }
  93. const placeholder = ''
  94. const editAreaClassName = 'focus:outline-none bg-transparent text-sm'
  95. const textAreaContent = (
  96. <div className={classNames(readonly ? 'max-h-[180px] pb-5' : 'h-[180px]', ' overflow-y-auto')} onClick={() => !readonly && setIsEditing(true)}>
  97. {isEditing
  98. ? <div className='h-full px-4 py-2'>
  99. <textarea
  100. ref={contentEditableRef}
  101. className={classNames(editAreaClassName, 'block w-full h-full resize-none')}
  102. placeholder={placeholder}
  103. onChange={onValueChange}
  104. value={currentValue}
  105. onBlur={() => {
  106. blur()
  107. setIsEditing(false)
  108. // click confirm also make blur. Then outter value is change. So below code has problem.
  109. // setTimeout(() => {
  110. // handleCancel()
  111. // }, 1000)
  112. }}
  113. />
  114. </div>
  115. : <TextAreaContentView />}
  116. </div>)
  117. return (
  118. <div className={classNames('block-input w-full overflow-y-auto bg-white border-none rounded-xl')}>
  119. {textAreaContent}
  120. {/* footer */}
  121. {!readonly && (
  122. <div className='pl-4 pb-2 flex'>
  123. <div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{currentValue?.length}</div>
  124. </div>
  125. )}
  126. </div>
  127. )
  128. }
  129. export default React.memo(BlockInput)