* 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
199 lines
4.8 KiB
TypeScript
199 lines
4.8 KiB
TypeScript
/*
|
|
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] ?? ''
|
|
}
|