/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertCircle, Braces, CheckCircle2, Code2, Copy } from 'lucide-react'
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Yace, type Plugin } from 'yace'
import { code } from 'yace/highlighters/code'
import { autoClose, history, tab } from 'yace/plugins'
import {
createScrollLayerSynchronizer,
formatJsonDraft,
getCursorLocation,
getJsonValidationState,
jsonSmartEnter,
type CursorLocation,
} from '@/components/json-code-editor/json-code-editor-utils'
import { Button } from '@/components/ui/button'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { cn } from '@/lib/utils'
export type JsonCodeEditorProps = Omit<
ComponentProps<'div'>,
'name' | 'onBlur' | 'onChange'
> & {
value: string
onChange: (value: string) => void
name?: string
onBlur?: () => void
textareaRef?: (element: HTMLTextAreaElement | null) => void
disabled?: boolean
heightClassName?: string
placeholder?: string
ariaLabel?: string
'data-form-root'?: string
}
export function JsonCodeEditor({
value,
onChange,
name,
onBlur,
textareaRef,
disabled,
heightClassName = 'h-56 min-h-56 max-h-56',
placeholder,
ariaLabel,
className,
id,
'aria-describedby': ariaDescribedBy,
'aria-invalid': ariaInvalid,
'data-form-root': dataFormRoot,
...rootProps
}: JsonCodeEditorProps) {
const { t } = useTranslation()
const mountRef = useRef(null)
const editorRef = useRef(null)
const latestValueRef = useRef(value)
const latestOnChangeRef = useRef(onChange)
const latestOnBlurRef = useRef(onBlur)
const [cursorLocation, setCursorLocation] = useState({
line: 1,
column: 1,
})
const jsonStatus = useMemo(() => getJsonValidationState(value), [value])
const editorPlugins = useMemo(
() => [
history(),
tab(' '),
jsonSmartEnter(),
autoClose({ '"': '"', '{': '}', '[': ']' }),
],
[]
)
latestValueRef.current = value
latestOnChangeRef.current = onChange
latestOnBlurRef.current = onBlur
useEffect(() => {
const mountNode = mountRef.current
if (!mountNode) {
return
}
const editor = new Yace(mountNode, {
value: latestValueRef.current,
lineNumbers: true,
highlighters: [code()],
plugins: editorPlugins,
styles: {
color: 'inherit',
fontSize: '0.75rem',
lineHeight: '1.25rem',
minHeight: '100%',
overflow: 'hidden',
padding: '0.5rem 0.75rem 0.5rem 0.5rem',
},
})
editorRef.current = editor
const handleUpdate = (nextValue: string) => {
if (nextValue !== latestValueRef.current) {
latestOnChangeRef.current(nextValue)
}
}
const updateCursorLocation = () => {
setCursorLocation(
getCursorLocation(editor.value, editor.textarea.selectionStart)
)
}
const lineNumberLayer = [...mountNode.querySelectorAll('pre')].find(
(preLayer) => preLayer !== editor.pre
)
const scrollSynchronizer = lineNumberLayer
? createScrollLayerSynchronizer(editor.textarea, {
contentLayer: editor.pre,
lineNumberLayer,
})
: null
const syncScrollLayers = () => scrollSynchronizer?.sync()
const handleBlur = () => latestOnBlurRef.current?.()
editor.onUpdate(handleUpdate)
editor.textarea.addEventListener('click', updateCursorLocation)
editor.textarea.addEventListener('input', updateCursorLocation)
editor.textarea.addEventListener('keyup', updateCursorLocation)
editor.textarea.addEventListener('select', updateCursorLocation)
editor.textarea.addEventListener('blur', handleBlur)
editor.textarea.addEventListener('scroll', syncScrollLayers, {
passive: true,
})
editor.textarea.classList.add('json-code-editor-textarea')
editor.pre.classList.add('json-code-editor-highlight')
editor.pre.setAttribute('aria-hidden', 'true')
if (lineNumberLayer) {
lineNumberLayer.classList.add('json-code-editor-lines')
lineNumberLayer.setAttribute('aria-hidden', 'true')
}
updateCursorLocation()
return () => {
editor.textarea.removeEventListener('click', updateCursorLocation)
editor.textarea.removeEventListener('input', updateCursorLocation)
editor.textarea.removeEventListener('keyup', updateCursorLocation)
editor.textarea.removeEventListener('select', updateCursorLocation)
editor.textarea.removeEventListener('blur', handleBlur)
editor.textarea.removeEventListener('scroll', syncScrollLayers)
editor.destroy()
editorRef.current = null
}
}, [editorPlugins])
useEffect(() => {
const textarea = editorRef.current?.textarea ?? null
textareaRef?.(textarea)
return () => textareaRef?.(null)
}, [textareaRef])
useEffect(() => {
const editor = editorRef.current
if (!editor || editor.value === value) {
return
}
editor.update({ value })
}, [value])
useEffect(() => {
const editor = editorRef.current
if (!editor) {
return
}
const resolvedAriaInvalid = ariaInvalid ?? !jsonStatus.isValid
editor.textarea.disabled = Boolean(disabled)
editor.textarea.id = id ?? ''
editor.textarea.name = name ?? ''
if (ariaLabel) {
editor.textarea.setAttribute('aria-label', ariaLabel)
} else {
editor.textarea.removeAttribute('aria-label')
}
if (dataFormRoot) {
editor.textarea.setAttribute('data-form-root', String(dataFormRoot))
} else {
editor.textarea.removeAttribute('data-form-root')
}
if (placeholder) {
editor.textarea.placeholder = placeholder
} else {
editor.textarea.removeAttribute('placeholder')
}
if (resolvedAriaInvalid) {
editor.textarea.setAttribute('aria-invalid', String(resolvedAriaInvalid))
} else {
editor.textarea.removeAttribute('aria-invalid')
}
if (ariaDescribedBy) {
editor.textarea.setAttribute('aria-describedby', ariaDescribedBy)
} else {
editor.textarea.removeAttribute('aria-describedby')
}
}, [
ariaDescribedBy,
ariaInvalid,
ariaLabel,
disabled,
dataFormRoot,
id,
jsonStatus.isValid,
name,
placeholder,
])
const formatJson = () => {
const result = formatJsonDraft(value)
if (result.didFormat) {
onChange(result.value)
}
}
const handleCopy = async () => {
const didCopy = await copyToClipboard(value)
if (didCopy) {
toast.success(t('Copied to clipboard'))
return
}
toast.error(t('Failed to copy'))
}
const statusMessage = t(jsonStatus.messageKey)
const cursorText = `${cursorLocation.line}:${cursorLocation.column}`
return (
{t('JSON')}
{cursorText}
{jsonStatus.isValid ? (
) : (
)}
{statusMessage}
)
}