diff --git a/web/default/src/components/json-code-editor.tsx b/web/default/src/components/json-code-editor.tsx new file mode 100644 index 00000000..a5cc6c9e --- /dev/null +++ b/web/default/src/components/json-code-editor.tsx @@ -0,0 +1,284 @@ +/* +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 { + useMemo, + useRef, + useState, + type ComponentProps, + type KeyboardEvent, +} from 'react' +import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { Textarea } from '@/components/ui/textarea' + +export type JsonCodeEditorProps = Omit, 'onChange'> & { + value: string + onChange: (value: string) => void + disabled?: boolean + heightClassName?: string +} + +export function JsonCodeEditor({ + value, + onChange, + disabled, + heightClassName = 'h-56 min-h-56 max-h-56', + className, + id, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, + ...rootProps +}: JsonCodeEditorProps) { + const { t } = useTranslation() + const textareaRef = useRef(null) + const [scrollTop, setScrollTop] = useState(0) + const lineNumbers = useMemo(() => { + const count = Math.max(1, value.split('\n').length) + return Array.from({ length: count }, (_, index) => index + 1) + }, [value]) + const jsonStatus = useMemo(() => { + const trimmed = value.trim() + if (!trimmed) return { valid: true, message: t('JSON') } + try { + JSON.parse(trimmed) + return { valid: true, message: t('JSON') } + } catch { + return { valid: false, message: t('Invalid JSON') } + } + }, [value, t]) + + const formatJson = () => { + const trimmed = value.trim() + if (!trimmed) return + try { + onChange(JSON.stringify(JSON.parse(trimmed), null, 2)) + } catch { + // Keep invalid drafts untouched; validation feedback remains visible. + } + } + + const updateValueWithSelection = ( + nextValue: string, + selectionStart: number, + selectionEnd = selectionStart + ) => { + onChange(nextValue) + window.requestAnimationFrame(() => { + textareaRef.current?.setSelectionRange(selectionStart, selectionEnd) + }) + } + + const getLineIndent = (text: string, cursor: number) => { + const lineStart = text.lastIndexOf('\n', cursor - 1) + 1 + return text.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? '' + } + + const handleEditorKeyDown = (event: KeyboardEvent) => { + const target = event.currentTarget + const start = target.selectionStart + const end = target.selectionEnd + const selected = value.slice(start, end) + const before = value.slice(0, start) + const after = value.slice(end) + + if (event.key === 'Tab') { + event.preventDefault() + + if (start !== end && selected.includes('\n')) { + const selectionLineStart = value.lastIndexOf('\n', start - 1) + 1 + const selectedBlock = value.slice(selectionLineStart, end) + const lines = selectedBlock.split('\n') + const nextBlock = event.shiftKey + ? lines + .map((line) => + line.startsWith(' ') + ? line.slice(2) + : line.startsWith('\t') + ? line.slice(1) + : line + ) + .join('\n') + : lines.map((line) => ` ${line}`).join('\n') + const nextValue = + value.slice(0, selectionLineStart) + nextBlock + value.slice(end) + updateValueWithSelection( + nextValue, + selectionLineStart, + selectionLineStart + nextBlock.length + ) + return + } + + if (event.shiftKey) { + const lineStart = value.lastIndexOf('\n', start - 1) + 1 + const removable = value.slice(lineStart, lineStart + 2) + if (removable === ' ') { + updateValueWithSelection( + value.slice(0, lineStart) + value.slice(lineStart + 2), + Math.max(lineStart, start - 2), + Math.max(lineStart, end - 2) + ) + } + return + } + + updateValueWithSelection(`${before} ${after}`, start + 2) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + const indent = getLineIndent(value, start) + const previousChar = before.trimEnd().at(-1) + const nextChar = after.trimStart().at(0) + const shouldNest = previousChar === '{' || previousChar === '[' + const shouldClose = + (previousChar === '{' && nextChar === '}') || + (previousChar === '[' && nextChar === ']') + + if (shouldNest && shouldClose) { + const innerIndent = `${indent} ` + const insert = `\n${innerIndent}\n${indent}` + updateValueWithSelection( + `${before}${insert}${after}`, + start + 1 + innerIndent.length + ) + return + } + + const nextIndent = shouldNest ? `${indent} ` : indent + const insert = `\n${nextIndent}` + updateValueWithSelection( + `${before}${insert}${after}`, + start + insert.length + ) + return + } + + const pairs: Record = { + '"': '"', + '{': '}', + '[': ']', + } + const closingChars = new Set(Object.values(pairs)) + + if (closingChars.has(event.key) && value[start] === event.key) { + event.preventDefault() + textareaRef.current?.setSelectionRange(start + 1, start + 1) + return + } + + if (pairs[event.key]) { + event.preventDefault() + const close = pairs[event.key] + const wrapped = `${event.key}${selected}${close}` + updateValueWithSelection( + `${before}${wrapped}${after}`, + start + 1, + start + 1 + selected.length + ) + return + } + + if (event.key === 'Backspace' && start === end && start > 0) { + const previousChar = value[start - 1] + const nextChar = value[start] + if (pairs[previousChar] === nextChar) { + event.preventDefault() + updateValueWithSelection( + value.slice(0, start - 1) + value.slice(start + 1), + start - 1 + ) + } + } + } + + return ( +
+
+
+ + {t('JSON')} +
+
+ + {jsonStatus.valid ? ( + + ) : ( + + )} + {jsonStatus.message} + + +
+
+
+
+
+ {lineNumbers.map((lineNumber) => ( +
{lineNumber}
+ ))} +
+
+