perf(json-editor): unify admin JSON editing experience (#6421)

* perf(json-editor): improve JSON editing experience

- integrate Yace for syntax highlighting, history, indentation, auto-closing, and smart line breaks.
- add copy support, cursor location feedback, and synchronized content and line-number scrolling.
- extract JSON editor utilities and cover key interactions with unit tests.

* perf(system-settings): improve JSON configuration editing

- replace raw JSON textareas with the shared editor for highlighting, validation, copy, and formatting.
- preserve field-specific examples and make placeholders visible through the transparent editor layer.
- remove duplicate formatting controls while keeping existing form validation and save behavior.

* perf(json-editor): standardize JSON inputs across admin settings

- replace pure JSON textareas with the shared editor across system settings and channel workflows.
- preserve form focus, validation, placeholders, and visual or JSON editing modes.
- add happy-dom component coverage for form bindings, controlled updates, and formatting.

* fix(json-code-editor): address accessibility review findings

- Drop the unconditional aria-label that overrode every field's
  label-derived accessible name; add an optional ariaLabel prop and
  set it at call sites without an associated label
- Associate standalone Labels via htmlFor/id in channel-affinity views
- Hide the highlight mirror and line-number layers from the
  accessibility tree (aria-hidden)
- Give the line-number gutter an opaque background so horizontally
  scrolled code no longer slides under it
- Degrade to no scroll sync instead of destroying the editor when the
  line-number layer is not found
This commit is contained in:
QuentinHsu
2026-07-25 19:10:28 +08:00
committed by GitHub
parent 18b0b7631a
commit eb4a1bd193
28 changed files with 1089 additions and 419 deletions
+243 -193
View File
@@ -16,201 +16,252 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { AlertCircle, Braces, CheckCircle2, Code2 } from 'lucide-react'
import { AlertCircle, Braces, CheckCircle2, Code2, Copy } from 'lucide-react'
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
} 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 { Textarea } from '@/components/ui/textarea'
import { copyToClipboard } from '@/lib/copy-to-clipboard'
import { cn } from '@/lib/utils'
export type JsonCodeEditorProps = Omit<ComponentProps<'div'>, 'onChange'> & {
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 textareaRef = useRef<HTMLTextAreaElement>(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') }
const mountRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Yace | null>(null)
const latestValueRef = useRef(value)
const latestOnChangeRef = useRef(onChange)
const latestOnBlurRef = useRef(onBlur)
const [cursorLocation, setCursorLocation] = useState<CursorLocation>({
line: 1,
column: 1,
})
const jsonStatus = useMemo(() => getJsonValidationState(value), [value])
const editorPlugins = useMemo<Plugin[]>(
() => [
history(),
tab(' '),
jsonSmartEnter(),
autoClose({ '"': '"', '{': '}', '[': ']' }),
],
[]
)
latestValueRef.current = value
latestOnChangeRef.current = onChange
latestOnBlurRef.current = onBlur
useEffect(() => {
const mountNode = mountRef.current
if (!mountNode) {
return
}
}, [value, t])
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 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 result = formatJsonDraft(value)
if (result.didFormat) {
onChange(result.value)
}
}
const updateValueWithSelection = (
nextValue: string,
selectionStart: number,
selectionEnd = selectionStart
) => {
onChange(nextValue)
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(selectionStart, selectionEnd)
})
const handleCopy = async () => {
const didCopy = await copyToClipboard(value)
if (didCopy) {
toast.success(t('Copied to clipboard'))
return
}
toast.error(t('Failed to copy'))
}
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<HTMLTextAreaElement>) => {
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<string, string> = {
'"': '"',
'{': '}',
'[': ']',
}
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
)
}
}
}
const statusMessage = t(jsonStatus.messageKey)
const cursorText = `${cursorLocation.line}:${cursorLocation.column}`
return (
<div
@@ -218,66 +269,65 @@ export function JsonCodeEditor({
'border-input bg-background focus-within:border-ring focus-within:ring-ring/50 overflow-hidden rounded-lg border transition-colors focus-within:ring-3',
className
)}
data-form-root={dataFormRoot}
{...rootProps}
>
<div className='bg-muted/30 flex h-8 items-center justify-between border-b px-2'>
<div className='text-muted-foreground flex min-w-0 items-center gap-1.5 text-xs font-medium'>
<Braces className='h-3.5 w-3.5' />
<Braces className='h-3.5 w-3.5' aria-hidden='true' />
<span>{t('JSON')}</span>
<span className='text-muted-foreground/70 font-mono'>
{cursorText}
</span>
</div>
<div className='flex items-center gap-2'>
<span
className={cn(
'flex items-center gap-1 text-xs',
jsonStatus.valid ? 'text-emerald-600' : 'text-destructive'
jsonStatus.isValid ? 'text-emerald-600' : 'text-destructive'
)}
>
{jsonStatus.valid ? (
<CheckCircle2 className='h-3.5 w-3.5' />
{jsonStatus.isValid ? (
<CheckCircle2 className='h-3.5 w-3.5' aria-hidden='true' />
) : (
<AlertCircle className='h-3.5 w-3.5' />
<AlertCircle className='h-3.5 w-3.5' aria-hidden='true' />
)}
{jsonStatus.message}
{statusMessage}
</span>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={formatJson}
disabled={disabled || !jsonStatus.valid || !value.trim()}
onClick={handleCopy}
disabled={disabled || !value}
>
<Code2 className='mr-1 h-3.5 w-3.5' />
<Copy className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
{t('Copy')}
</Button>
<Button
type='button'
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={formatJson}
disabled={disabled || !jsonStatus.isValid || !value.trim()}
>
<Code2 className='mr-1 h-3.5 w-3.5' aria-hidden='true' />
{t('Format JSON')}
</Button>
</div>
</div>
<div className={cn('relative flex overflow-hidden', heightClassName)}>
<div className='bg-muted/20 text-muted-foreground/70 relative w-10 shrink-0 overflow-hidden border-r font-mono text-xs leading-5 select-none'>
<div
className='px-2 py-2 text-right'
style={{ transform: `translateY(-${scrollTop}px)` }}
>
{lineNumbers.map((lineNumber) => (
<div key={lineNumber}>{lineNumber}</div>
))}
</div>
</div>
<Textarea
ref={textareaRef}
id={id}
aria-describedby={ariaDescribedBy}
aria-invalid={ariaInvalid}
value={value}
disabled={disabled}
onChange={(event) => onChange(event.target.value)}
onKeyDown={handleEditorKeyDown}
onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)}
className={cn(
'[field-sizing:fixed] resize-none overflow-auto rounded-none border-0 bg-transparent px-3 py-2 font-mono text-xs leading-5 shadow-none ring-0 outline-none focus-visible:ring-0',
heightClassName
)}
spellCheck={false}
<div
className={cn(
'bg-background relative overflow-hidden pl-2',
'has-[textarea:disabled]:bg-input/30 has-[textarea:disabled]:opacity-70',
heightClassName
)}
>
<div
ref={mountRef}
className='json-code-editor-yace text-foreground h-full font-mono text-xs leading-5'
/>
</div>
</div>
@@ -0,0 +1,100 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { describe, test } from 'node:test'
import {
applyJsonSmartEnter,
createScrollLayerSynchronizer,
formatJsonDraft,
getCursorLocation,
getJsonValidationState,
} from '../json-code-editor-utils'
describe('json code editor utils', () => {
test('treats empty drafts as valid editable JSON drafts', () => {
assert.deepEqual(getJsonValidationState(' \n'), {
isValid: true,
messageKey: 'JSON',
})
})
test('reports invalid JSON without throwing away the draft', () => {
assert.deepEqual(getJsonValidationState('{"model": }'), {
isValid: false,
messageKey: 'Invalid JSON',
})
})
test('formats valid JSON with stable two-space indentation', () => {
assert.deepEqual(formatJsonDraft('{"model":{"ratio":2}}'), {
didFormat: true,
value: '{\n "model": {\n "ratio": 2\n }\n}',
})
})
test('keeps invalid JSON drafts unchanged when formatting is requested', () => {
assert.deepEqual(formatJsonDraft('{"model": }'), {
didFormat: false,
value: '{"model": }',
})
})
test('derives the one-based cursor line and column from text offsets', () => {
assert.deepEqual(getCursorLocation('{\n "model": 1\n}', 5), {
line: 2,
column: 4,
})
})
test('expands paired JSON brackets with a nested indentation line', () => {
assert.deepEqual(applyJsonSmartEnter('{}', 1, 1), {
value: '{\n \n}',
selectionStart: 4,
selectionEnd: 4,
})
})
test('coalesces scroll updates while keeping line numbers horizontally fixed', () => {
const source = { scrollLeft: 12, scrollTop: 40 }
const contentLayer = { style: { transform: '' } }
const lineNumberLayer = { style: { transform: '' } }
const queuedFrames: Array<() => void> = []
const synchronizer = createScrollLayerSynchronizer(
source,
{ contentLayer, lineNumberLayer },
(callback) => {
queuedFrames.push(callback)
return queuedFrames.length
}
)
synchronizer.sync()
source.scrollLeft = 24
source.scrollTop = 80
synchronizer.sync()
assert.equal(queuedFrames.length, 1)
queuedFrames[0]()
assert.equal(contentLayer.style.transform, 'translate3d(-24px, -80px, 0)')
assert.equal(lineNumberLayer.style.transform, 'translate3d(0, -80px, 0)')
})
})
@@ -0,0 +1,180 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import assert from 'node:assert/strict'
import { after, describe, test } from 'node:test'
import { Window } from 'happy-dom'
const domWindow = new Window()
const domGlobals = [
'window',
'document',
'navigator',
'HTMLElement',
'HTMLTextAreaElement',
'Node',
'Element',
'Event',
'CustomEvent',
'MutationObserver',
'requestAnimationFrame',
'cancelAnimationFrame',
'getComputedStyle',
] as const
for (const key of domGlobals) {
Object.defineProperty(globalThis, key, {
configurable: true,
value: domWindow[key],
})
}
const { act } = await import('react')
const { createRoot } = await import('react-dom/client')
const i18next = (await import('i18next')).default
const { initReactI18next } = await import('react-i18next')
await i18next.use(initReactI18next).init({
lng: 'en',
resources: {
en: {
translation: {
JSON: 'JSON',
'Invalid JSON': 'Invalid JSON',
'Copied to clipboard': 'Copied to clipboard',
'Failed to copy': 'Failed to copy',
'Format JSON': 'Format JSON',
},
},
},
})
const { JsonCodeEditor } = await import('../../json-code-editor')
const reactTestGlobals = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean
}
reactTestGlobals.IS_REACT_ACT_ENVIRONMENT = true
type RenderedEditor = {
container: HTMLDivElement
root: ReturnType<typeof createRoot>
}
async function renderEditor(
props: React.ComponentProps<typeof JsonCodeEditor>
): Promise<RenderedEditor> {
const container = document.createElement('div')
document.body.append(container)
const root = createRoot(container)
await act(async () => {
root.render(<JsonCodeEditor {...props} />)
})
return { container, root }
}
async function unmountEditor(rendered: RenderedEditor) {
await act(async () => rendered.root.unmount())
rendered.container.remove()
}
describe('JsonCodeEditor component', () => {
after(() => {
domWindow.close()
})
test('forwards form attributes and lifecycle callbacks to the textarea', async () => {
const blurCalls: number[] = []
const refValues: Array<HTMLTextAreaElement | null> = []
const rendered = await renderEditor({
value: '{"model":"gpt"}',
onChange: () => undefined,
id: 'json-input',
name: 'model_config',
placeholder: '{"model":"gpt"}',
disabled: true,
'aria-describedby': 'model-help',
'aria-invalid': true,
'data-form-root': 'settings-form',
onBlur: () => blurCalls.push(1),
textareaRef: (element) => refValues.push(element),
})
const textarea = rendered.container.querySelector('textarea')
assert.ok(textarea)
assert.equal(textarea.id, 'json-input')
assert.equal(textarea.name, 'model_config')
assert.equal(textarea.placeholder, '{"model":"gpt"}')
assert.equal(textarea.disabled, true)
assert.equal(textarea.getAttribute('aria-describedby'), 'model-help')
assert.equal(textarea.getAttribute('aria-invalid'), 'true')
assert.equal(textarea.getAttribute('data-form-root'), 'settings-form')
await act(async () => textarea.dispatchEvent(new Event('blur')))
assert.deepEqual(blurCalls, [1])
assert.equal(refValues[0], textarea)
await unmountEditor(rendered)
assert.equal(refValues.at(-1), null)
})
test('emits user edits and synchronizes a controlled value', async () => {
const changes: string[] = []
const rendered = await renderEditor({
value: '{"count":1}',
onChange: (value) => changes.push(value),
})
const textarea = rendered.container.querySelector('textarea')
assert.ok(textarea)
await act(async () => {
textarea.value = '{"count":2}'
textarea.dispatchEvent(new Event('input', { bubbles: true }))
})
assert.deepEqual(changes, ['{"count":2}'])
await act(async () => {
rendered.root.render(
<JsonCodeEditor
value='{"count":3}'
onChange={(value) => changes.push(value)}
/>
)
})
assert.equal(textarea.value, '{"count":3}')
await unmountEditor(rendered)
})
test('formats valid JSON through the public toolbar action', async () => {
const changes: string[] = []
const rendered = await renderEditor({
value: '{"model":{"ratio":2}}',
onChange: (value) => changes.push(value),
})
const formatButton = [
...rendered.container.querySelectorAll('button'),
].find((button) => button.textContent?.includes('Format JSON'))
assert.ok(formatButton)
await act(async () => formatButton.click())
assert.deepEqual(changes, ['{\n "model": {\n "ratio": 2\n }\n}'])
await unmountEditor(rendered)
})
})
@@ -0,0 +1,198 @@
/*
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 <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import type { Plugin, TextareaProps } from 'yace'
export type JsonValidationState = {
isValid: boolean
messageKey: 'JSON' | 'Invalid JSON'
}
export type JsonFormatResult = {
didFormat: boolean
value: string
}
export type CursorLocation = {
line: number
column: number
}
type ScrollSource = {
scrollLeft: number
scrollTop: number
}
type TransformLayer = {
style: Pick<CSSStyleDeclaration, 'transform'>
}
type ScrollLayers = {
contentLayer: TransformLayer
lineNumberLayer: TransformLayer
}
type RequestFrame = (callback: () => void) => number
export type ScrollLayerSynchronizer = {
sync: () => void
}
export function getJsonValidationState(value: string): JsonValidationState {
const trimmed = value.trim()
if (!trimmed) {
return { isValid: true, messageKey: 'JSON' }
}
try {
JSON.parse(trimmed)
return { isValid: true, messageKey: 'JSON' }
} catch {
return { isValid: false, messageKey: 'Invalid JSON' }
}
}
export function formatJsonDraft(value: string): JsonFormatResult {
const trimmed = value.trim()
if (!trimmed) {
return { didFormat: false, value }
}
try {
return {
didFormat: true,
value: JSON.stringify(JSON.parse(trimmed), null, 2),
}
} catch {
return { didFormat: false, value }
}
}
export function getCursorLocation(
value: string,
selectionStart: number
): CursorLocation {
const boundedSelectionStart = Math.min(
Math.max(selectionStart, 0),
value.length
)
const linesBeforeCursor = value.slice(0, boundedSelectionStart).split('\n')
const currentLine = linesBeforeCursor.at(-1) ?? ''
return {
line: linesBeforeCursor.length,
column: currentLine.length + 1,
}
}
export function createScrollLayerSynchronizer(
source: ScrollSource,
layers: ScrollLayers,
requestFrame: RequestFrame = window.requestAnimationFrame
): ScrollLayerSynchronizer {
let hasPendingFrame = false
return {
sync: () => {
if (hasPendingFrame) {
return
}
hasPendingFrame = true
requestFrame(() => {
hasPendingFrame = false
layers.contentLayer.style.transform = `translate3d(-${source.scrollLeft}px, -${source.scrollTop}px, 0)`
layers.lineNumberLayer.style.transform = `translate3d(0, -${source.scrollTop}px, 0)`
})
},
}
}
export function applyJsonSmartEnter(
value: string,
selectionStart: number,
selectionEnd: number
): TextareaProps | undefined {
const before = value.slice(0, selectionStart)
const after = value.slice(selectionEnd)
const indent = getLineIndent(value, selectionStart)
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}`
const nextSelection = selectionStart + 1 + innerIndent.length
return {
value: `${before}${insert}${after}`,
selectionStart: nextSelection,
selectionEnd: nextSelection,
}
}
if (!indent && !shouldNest) {
return undefined
}
const nextIndent = shouldNest ? `${indent} ` : indent
const insert = `\n${nextIndent}`
const nextSelection = selectionStart + insert.length
return {
value: `${before}${insert}${after}`,
selectionStart: nextSelection,
selectionEnd: nextSelection,
}
}
export function jsonSmartEnter(): Plugin {
return (props, event) => {
if (event.type !== 'keydown') {
return undefined
}
const keyboardEvent = event as KeyboardEvent
if (keyboardEvent.key !== 'Enter') {
return undefined
}
const nextProps = applyJsonSmartEnter(
props.value,
props.selectionStart,
props.selectionEnd
)
if (!nextProps) {
return undefined
}
event.preventDefault()
return nextProps
}
}
function getLineIndent(value: string, cursor: number): string {
const lineStart = value.lastIndexOf('\n', cursor - 1) + 1
return value.slice(lineStart, cursor).match(/^\s*/)?.[0] ?? ''
}
+4 -6
View File
@@ -20,10 +20,9 @@ import { Code, Table, Plus, Trash2 } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { JsonCodeEditor } from '@/components/json-code-editor'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
type JsonEditorProps = {
value: string
@@ -285,15 +284,14 @@ export function JsonEditor({
</Button>
</div>
) : (
<Textarea
<JsonCodeEditor
value={jsonValue}
onChange={(e) => handleJsonChange(e.target.value)}
onChange={handleJsonChange}
placeholder={
template ? JSON.stringify(template, null, 2) : '{"key": "value"}'
}
disabled={disabled}
rows={8}
className={cn('font-mono text-sm')}
ariaLabel={t('JSON')}
/>
)}
</div>