返回 AiToEarn
1 /**
2 * ConfigJsonPanel - JSON 配置编辑区
3 * 在可编辑 Textarea 上叠加字段定位按钮与行级高亮。
4 */
5 'use client'
6
7 import type { ConfigPath, ConfigPathFocusRequest } from '../../types'
8 import { SlidersHorizontal } from 'lucide-react'
9 import { useEffect, useMemo, useRef, useState } from 'react'
10 import { useTransClient } from '@/app/i18n/client'
11 import { Button } from '@/components/ui/button'
12 import { Textarea } from '@/components/ui/textarea'
13 import { cn } from '@/utils/className'
14 import { joinPath } from '../../utils/configPath'
15
16 interface JsonLineAction {
17 charEnd: number
18 charStart: number
19 lineIndex: number
20 path: ConfigPath
21 pathKey: string
22 }
23
24 interface JsonContext {
25 childLevel: number
26 nextIndex: number
27 path: ConfigPath
28 type: 'array' | 'object'
29 }
30
31 export interface ConfigJsonPanelProps {
32 disabled: boolean
33 focusRequest: ConfigPathFocusRequest | null
34 hasConfig: boolean
35 highlightedPathKey: string
36 initialScrollTop: number
37 jsonText: string
38 onFocusRequestHandled: (requestId: number) => void
39 onJsonTextChange: (value: string) => void
40 onNavigateToVisual: (path: ConfigPath) => void
41 onScrollTopChange: (scrollTop: number) => void
42 }
43
44 const jsonLineHeight = 24
45 const jsonPaddingTop = 8
46
47 function readJsonKey(rawKey: string) {
48 try {
49 return JSON.parse(`"${rawKey}"`) as string
50 }
51 catch {
52 return rawKey
53 }
54 }
55
56 function getLineLevel(line: string) {
57 const indentLength = line.match(/^\s*/)?.[0].length ?? 0
58 return Math.floor(indentLength / 2)
59 }
60
61 function getCurrentContext(contexts: JsonContext[], level: number) {
62 return [...contexts].reverse().find(context => context.childLevel === level)
63 }
64
65 function getJsonObjectLineEntry(line: string) {
66 const quoteIndex = line.indexOf('"')
67 if (quoteIndex < 0 || line.slice(0, quoteIndex).trim())
68 return null
69
70 let escaped = false
71 for (let index = quoteIndex + 1; index < line.length; index += 1) {
72 const char = line[index]
73 if (escaped) {
74 escaped = false
75 continue
76 }
77 if (char === '\\') {
78 escaped = true
79 continue
80 }
81 if (char !== '"')
82 continue
83
84 let colonIndex = index + 1
85 while (colonIndex < line.length && line[colonIndex].trim() === '') {
86 colonIndex += 1
87 }
88
89 if (line[colonIndex] !== ':')
90 return null
91
92 return {
93 rawKey: line.slice(quoteIndex + 1, index),
94 valueStart: line.slice(colonIndex + 1).trim(),
95 }
96 }
97
98 return null
99 }
100
101 function getPathLineAction(
102 line: string,
103 lineIndex: number,
104 charStart: number,
105 charEnd: number,
106 contexts: JsonContext[],
107 ): JsonLineAction | null {
108 const level = getLineLevel(line)
109 const trimmed = line.trim()
110
111 while (contexts.length > 1 && contexts[contexts.length - 1].childLevel > level) {
112 contexts.pop()
113 }
114
115 const entry = getJsonObjectLineEntry(line)
116 if (entry) {
117 const parentContext = getCurrentContext(contexts, level)
118 const key = readJsonKey(entry.rawKey)
119 const path = [...(parentContext?.path ?? []), key]
120 const valueStart = entry.valueStart
121
122 if (valueStart.startsWith('{')) {
123 contexts.push({ childLevel: level + 1, nextIndex: 0, path, type: 'object' })
124 }
125 else if (valueStart.startsWith('[')) {
126 contexts.push({ childLevel: level + 1, nextIndex: 0, path, type: 'array' })
127 }
128
129 return { charEnd, charStart, lineIndex, path, pathKey: joinPath(path) }
130 }
131
132 const parentContext = getCurrentContext(contexts, level)
133 if (parentContext?.type !== 'array' || !trimmed || trimmed.startsWith(']') || trimmed.startsWith('}')) {
134 return null
135 }
136
137 const path = [...parentContext.path, parentContext.nextIndex]
138 parentContext.nextIndex += 1
139
140 if (trimmed.startsWith('{')) {
141 contexts.push({ childLevel: level + 1, nextIndex: 0, path, type: 'object' })
142 }
143 else if (trimmed.startsWith('[')) {
144 contexts.push({ childLevel: level + 1, nextIndex: 0, path, type: 'array' })
145 }
146
147 return { charEnd, charStart, lineIndex, path, pathKey: joinPath(path) }
148 }
149
150 function getJsonLineActions(jsonText: string) {
151 const contexts: JsonContext[] = [{ childLevel: 1, nextIndex: 0, path: [], type: 'object' }]
152 const lines = jsonText.split('\n')
153 const actions: JsonLineAction[] = []
154 let charOffset = 0
155
156 lines.forEach((line, lineIndex) => {
157 const charStart = charOffset
158 const charEnd = charStart + line.length
159 const action = getPathLineAction(line, lineIndex, charStart, charEnd, contexts)
160 if (action) {
161 actions.push(action)
162 }
163 charOffset = charEnd + 1
164 })
165
166 return actions
167 }
168
169 function findLineAction(actions: JsonLineAction[], pathKey: string) {
170 const exactAction = actions.find(action => action.pathKey === pathKey)
171 if (exactAction)
172 return exactAction
173
174 return actions
175 .filter(action => pathKey.startsWith(`${action.pathKey}.`))
176 .sort((first, second) => second.pathKey.length - first.pathKey.length)[0]
177 }
178
179 export function ConfigJsonPanel({
180 disabled,
181 focusRequest,
182 hasConfig,
183 highlightedPathKey,
184 initialScrollTop,
185 jsonText,
186 onFocusRequestHandled,
187 onJsonTextChange,
188 onNavigateToVisual,
189 onScrollTopChange,
190 }: ConfigJsonPanelProps) {
191 const { t } = useTransClient('configManager')
192 const textareaRef = useRef<HTMLTextAreaElement | null>(null)
193 const handledFocusRequestIdRef = useRef<number | null>(null)
194 const [hoveredLineIndex, setHoveredLineIndex] = useState<number | null>(null)
195 const [scrollTop, setScrollTop] = useState(0)
196 const lineActions = useMemo(() => getJsonLineActions(jsonText), [jsonText])
197 const lineCount = useMemo(() => jsonText.split('\n').length, [jsonText])
198 const hoveredLineTop = hoveredLineIndex === null
199 ? null
200 : jsonPaddingTop + hoveredLineIndex * jsonLineHeight - scrollTop
201 const highlightedAction = highlightedPathKey ? findLineAction(lineActions, highlightedPathKey) : undefined
202
203 useEffect(() => {
204 const textarea = textareaRef.current
205 if (!textarea || focusRequest)
206 return
207
208 textarea.scrollTop = initialScrollTop
209 setScrollTop(initialScrollTop)
210 }, [focusRequest, initialScrollTop])
211
212 useEffect(() => {
213 if (!focusRequest)
214 return
215 if (handledFocusRequestIdRef.current === focusRequest.id)
216 return
217
218 const targetAction = findLineAction(lineActions, joinPath(focusRequest.path))
219 const textarea = textareaRef.current
220 if (!targetAction || !textarea)
221 return
222
223 let firstFrame = 0
224 let secondFrame = 0
225 handledFocusRequestIdRef.current = focusRequest.id
226
227 firstFrame = window.requestAnimationFrame(() => {
228 const nextScrollTop = Math.max(0, targetAction.lineIndex * jsonLineHeight - jsonLineHeight * 4)
229 textarea.setSelectionRange(targetAction.charStart, targetAction.charEnd)
230 textarea.focus({ preventScroll: true })
231
232 secondFrame = window.requestAnimationFrame(() => {
233 textarea.scrollTop = nextScrollTop
234 setScrollTop(nextScrollTop)
235 onScrollTopChange(nextScrollTop)
236 onFocusRequestHandled(focusRequest.id)
237 })
238 })
239
240 return () => {
241 window.cancelAnimationFrame(firstFrame)
242 window.cancelAnimationFrame(secondFrame)
243 }
244 }, [focusRequest, lineActions, onFocusRequestHandled, onScrollTopChange])
245
246 return (
247 <div className="relative h-full min-h-[420px] rounded-md bg-muted/30">
248 <div className="pointer-events-none absolute inset-0 z-0 overflow-hidden rounded-md">
249 {hoveredLineTop !== null && (
250 <div
251 className="absolute left-3 right-3 h-6 rounded-sm ring-1 ring-primary/25"
252 style={{
253 top: `${hoveredLineTop}px`,
254 backgroundColor: 'color-mix(in oklab, var(--primary) 16%, transparent)',
255 }}
256 />
257 )}
258
259 {highlightedAction && (
260 <div
261 key={`${highlightedAction.pathKey}-${focusRequest?.id ?? 0}`}
262 className="absolute left-2 right-2 h-6 animate-pulse rounded-sm ring-1 ring-primary/40"
263 style={{
264 top: `${jsonPaddingTop + highlightedAction.lineIndex * jsonLineHeight - scrollTop}px`,
265 backgroundColor: 'color-mix(in oklab, var(--primary) 18%, transparent)',
266 }}
267 />
268 )}
269 </div>
270
271 <Textarea
272 ref={textareaRef}
273 value={jsonText}
274 disabled={disabled || !hasConfig}
275 spellCheck={false}
276 className="relative z-10 h-full min-h-[420px] resize-none border-border bg-transparent pr-12 font-mono text-sm leading-6 shadow-none"
277 onMouseLeave={() => setHoveredLineIndex(null)}
278 onMouseMove={(event) => {
279 const textarea = event.currentTarget
280 const rect = textarea.getBoundingClientRect()
281 const lineIndex = Math.floor((event.clientY - rect.top + textarea.scrollTop - jsonPaddingTop) / jsonLineHeight)
282 setHoveredLineIndex(lineIndex >= 0 && lineIndex < lineCount ? lineIndex : null)
283 }}
284 onChange={(event) => {
285 onJsonTextChange(event.target.value)
286 }}
287 onScroll={(event) => {
288 const nextScrollTop = event.currentTarget.scrollTop
289 setScrollTop(nextScrollTop)
290 onScrollTopChange(nextScrollTop)
291 }}
292 />
293
294 <div className="pointer-events-none absolute inset-0 z-20 overflow-hidden rounded-md">
295 {lineActions.map(action => (
296 <Button
297 key={`${action.pathKey}-${action.lineIndex}`}
298 type="button"
299 variant="ghost"
300 size="icon"
301 className={cn(
302 'pointer-events-auto absolute right-2 h-5 w-5 cursor-pointer text-muted-foreground opacity-0 transition-opacity hover:bg-background/80 hover:text-foreground focus-visible:opacity-100',
303 hoveredLineIndex === action.lineIndex && 'opacity-100',
304 )}
305 style={{ top: `${jsonPaddingTop + action.lineIndex * jsonLineHeight + 1 - scrollTop}px` }}
306 aria-label={t('actions.goToVisualField')}
307 disabled={disabled || !hasConfig}
308 onClick={() => onNavigateToVisual(action.path)}
309 >
310 <SlidersHorizontal className="h-3.5 w-3.5" />
311 </Button>
312 ))}
313 </div>
314 </div>
315 )
316 }
317
317 lines Plain Text