/*
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 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
}
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] ?? ''
}