diff --git a/web/bun.lock b/web/bun.lock index 783276cb..42ab7a23 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "newapi-web", @@ -59,6 +60,7 @@ "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", + "yace": "^1.1.0", "zod": "^4.4.3", "zustand": "^5.0.14", }, @@ -75,6 +77,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260702.3", "@xyflow/react": "^12.11.1", "embla-carousel-react": "^8.6.0", + "happy-dom": "^20.11.1", "knip": "^6.24.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", @@ -926,6 +929,10 @@ "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260707.2", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260707.2", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260707.2" }, "bin": { "tsgo": "bin/tsgo" } }, "sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg=="], "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg=="], @@ -1054,6 +1061,8 @@ "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1288,7 +1297,7 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], @@ -1438,6 +1447,8 @@ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + "happy-dom": ["happy-dom@20.11.1", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -2382,12 +2393,18 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="], + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "yace": ["yace@1.1.0", "", {}, "sha512-jB29trAxPBvTIR/lXsgh97q22n/Rq5ZHbDT4DT6WUvXVlJLR7jdwiBKD+0zi3XZAD5N+YKa3GrdIrelqIXj2oQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], @@ -2528,6 +2545,8 @@ "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "markdown-it-ts/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="], diff --git a/web/package.json b/web/package.json index d802510b..0901b396 100644 --- a/web/package.json +++ b/web/package.json @@ -74,6 +74,7 @@ "tw-animate-css": "^1.4.0", "use-stick-to-bottom": "^1.1.6", "vaul": "^1.1.2", + "yace": "^1.1.0", "zod": "^4.4.3", "zustand": "^5.0.14" }, @@ -90,6 +91,7 @@ "@typescript/native-preview": "^7.0.0-dev.20260702.3", "@xyflow/react": "^12.11.1", "embla-carousel-react": "^8.6.0", + "happy-dom": "^20.11.1", "knip": "^6.24.0", "oxfmt": "^0.57.0", "oxlint": "^1.72.0", diff --git a/web/src/components/json-code-editor.tsx b/web/src/components/json-code-editor.tsx index e15d53d6..dcb3718d 100644 --- a/web/src/components/json-code-editor.tsx +++ b/web/src/components/json-code-editor.tsx @@ -16,201 +16,252 @@ along with this program. If not, see . 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, '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(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(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 } - }, [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) => { - 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 - ) - } - } - } + const statusMessage = t(jsonStatus.messageKey) + const cursorText = `${cursorLocation.line}:${cursorLocation.column}` return (
- +
- {jsonStatus.valid ? ( - + {jsonStatus.isValid ? ( + +
-
-
-
- {lineNumbers.map((lineNumber) => ( -
{lineNumber}
- ))} -
-
-