@axzydev/axzy_ui_system 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +6053 -4317
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.css.map +1 -1
- package/dist/index.d.cts +145 -1
- package/dist/index.d.ts +145 -1
- package/dist/index.js +4363 -2612
- package/dist/index.js.map +1 -1
- package/dist/layered.css +1 -1
- package/package.json +1 -1
- package/src/App.tsx +10 -0
- package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
- package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
- package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
- package/src/components/molecules/json-editor/json-editor.tsx +972 -0
- package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
- package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
- package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
- package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
- package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
- package/src/dev.css +24 -1
- package/src/hooks/useJsonRender.tsx +9 -0
- package/src/i18n/locales/en.ts +54 -0
- package/src/i18n/locales/es.ts +54 -0
- package/src/index.css +414 -0
- package/src/index.ts +10 -2
- package/src/sandbox/AxzyLogo.tsx +139 -0
- package/src/sandbox/i18n.ts +2 -0
- package/src/sandbox/navigation.ts +2 -0
- package/src/showcases/FeedbackShowcases.tsx +16 -5
- package/src/showcases/HomeShowcase.tsx +16 -9
- package/src/showcases/JsonEditorShowcase.tsx +150 -0
- package/src/showcases/JsonRendererShowcase.tsx +70 -0
- package/src/showcases/StructureShowcases.tsx +6 -1
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import clsx from "clsx";
|
|
3
|
+
import {
|
|
4
|
+
FaAlignLeft,
|
|
5
|
+
FaCheck,
|
|
6
|
+
FaChevronDown,
|
|
7
|
+
FaChevronRight,
|
|
8
|
+
FaCompressAlt,
|
|
9
|
+
FaCopy,
|
|
10
|
+
FaDownload,
|
|
11
|
+
FaExclamationTriangle,
|
|
12
|
+
FaPlusSquare,
|
|
13
|
+
FaSitemap,
|
|
14
|
+
FaSortAlphaDown,
|
|
15
|
+
FaSortAlphaUp,
|
|
16
|
+
FaTextHeight,
|
|
17
|
+
FaUpload,
|
|
18
|
+
FaMagic,
|
|
19
|
+
} from "react-icons/fa";
|
|
20
|
+
import { useITTranslation } from "@/i18n";
|
|
21
|
+
import type { ITJSONEditorMode, ITJSONEditorProps } from "./json-editor.props";
|
|
22
|
+
import ITCodeTreeView from "./json-editor-tree";
|
|
23
|
+
import { useJsonEditorKeyboard } from "./useJsonEditorKeyboard";
|
|
24
|
+
import {
|
|
25
|
+
analyzeCode,
|
|
26
|
+
findFoldRegions,
|
|
27
|
+
parseJsonError,
|
|
28
|
+
transformValue,
|
|
29
|
+
valueKind,
|
|
30
|
+
type ITJsonTransform,
|
|
31
|
+
} from "./json-editor.utils";
|
|
32
|
+
|
|
33
|
+
/** Filete de 1px que separa las franjas del editor. */
|
|
34
|
+
const FILETE = "border-slate-200 dark:border-slate-800";
|
|
35
|
+
/** Rótulo en mono y mayúsculas de la fila de ajustes. */
|
|
36
|
+
const EYEBROW =
|
|
37
|
+
"font-mono text-[10px] font-medium uppercase tracking-[0.14em] text-slate-500 dark:text-slate-400";
|
|
38
|
+
|
|
39
|
+
/** Clase de resaltado por tipo de token. */
|
|
40
|
+
const TOKEN_CLASS: Record<string, string> = {
|
|
41
|
+
key: "it-json-editor-tk-key",
|
|
42
|
+
string: "it-json-editor-tk-string",
|
|
43
|
+
number: "it-json-editor-tk-number",
|
|
44
|
+
keyword: "it-json-editor-tk-keyword",
|
|
45
|
+
punctuation: "it-json-editor-tk-punct",
|
|
46
|
+
whitespace: "",
|
|
47
|
+
plain: "it-json-editor-tk-plain",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Etiqueta corta del tipo de valor raíz. */
|
|
51
|
+
function describeKind(kind: string, t: (key: string) => string): string {
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case "object":
|
|
54
|
+
return t("jsonEditor.kindObject");
|
|
55
|
+
case "array":
|
|
56
|
+
return t("jsonEditor.kindArray");
|
|
57
|
+
case "string":
|
|
58
|
+
return t("jsonEditor.kindString");
|
|
59
|
+
case "number":
|
|
60
|
+
return t("jsonEditor.kindNumber");
|
|
61
|
+
case "boolean":
|
|
62
|
+
return t("jsonEditor.kindBoolean");
|
|
63
|
+
default:
|
|
64
|
+
return t("jsonEditor.kindNull");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Posición (línea y columna, 1-based) de un offset del documento. */
|
|
69
|
+
function caretPosition(text: string, offset: number, lineStarts: number[]): { line: number; column: number } {
|
|
70
|
+
const safe = Math.max(0, Math.min(offset, text.length));
|
|
71
|
+
let low = 0;
|
|
72
|
+
let high = Math.max(0, lineStarts.length - 1);
|
|
73
|
+
while (low < high) {
|
|
74
|
+
const mid = (low + high + 1) >> 1;
|
|
75
|
+
if (lineStarts[mid] <= safe) low = mid;
|
|
76
|
+
else high = mid - 1;
|
|
77
|
+
}
|
|
78
|
+
return { line: low + 1, column: safe - lineStarts[low] + 1 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Cuenta nodos (claves y elementos) de un valor ya parseado. */
|
|
82
|
+
function countNodes(value: unknown): number {
|
|
83
|
+
if (Array.isArray(value)) return value.reduce<number>((total, item) => total + 1 + countNodes(item), 0);
|
|
84
|
+
if (value && typeof value === "object") {
|
|
85
|
+
return Object.values(value as Record<string, unknown>).reduce<number>(
|
|
86
|
+
(total, nested) => total + 1 + countNodes(nested),
|
|
87
|
+
0
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Tamaño legible (`842 B`, `12.4 kB`). */
|
|
94
|
+
function formatBytes(bytes: number): string {
|
|
95
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
96
|
+
return `${(bytes / 1024).toFixed(1)} kB`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Editor de JSON con resaltado de sintaxis, gutter con números y plegado,
|
|
101
|
+
* errores señalados por línea y columna, árbol navegable y acciones de formato.
|
|
102
|
+
*
|
|
103
|
+
* No añade dependencias: el resaltado se pinta en una capa bajo el `<textarea>`
|
|
104
|
+
* nativo, así que se conservan el deshacer del navegador, el pegado, el dictado
|
|
105
|
+
* y el corrector. La validación la hace `JSON.parse`, de modo que el coloreado
|
|
106
|
+
* funciona igual mientras el documento está a medio escribir.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```tsx
|
|
110
|
+
* <ITJSONEditor value={json} onChange={setJson} onParse={handleParse} height={420} />
|
|
111
|
+
* ```
|
|
112
|
+
*
|
|
113
|
+
* @example
|
|
114
|
+
* ```tsx
|
|
115
|
+
* <ITJSONEditor
|
|
116
|
+
* value={json}
|
|
117
|
+
* onChange={setJson}
|
|
118
|
+
* mode="tree"
|
|
119
|
+
* indentSize={4}
|
|
120
|
+
* showFoldGutter
|
|
121
|
+
* showStatusBar
|
|
122
|
+
* />
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export default function ITJSONEditor({
|
|
126
|
+
value = "",
|
|
127
|
+
onChange,
|
|
128
|
+
onParse,
|
|
129
|
+
placeholder,
|
|
130
|
+
readOnly = false,
|
|
131
|
+
mode,
|
|
132
|
+
showModeSwitch = true,
|
|
133
|
+
showToolbar = true,
|
|
134
|
+
showValidation = true,
|
|
135
|
+
showStatusBar = true,
|
|
136
|
+
showLineNumbers = true,
|
|
137
|
+
showFoldGutter = true,
|
|
138
|
+
showTransformActions = true,
|
|
139
|
+
showCopyButton = true,
|
|
140
|
+
showImportButton = true,
|
|
141
|
+
showDownloadButton = true,
|
|
142
|
+
showViewSettings = true,
|
|
143
|
+
indentSize: indentSizeProp = 2,
|
|
144
|
+
wrapLines: wrapLinesProp = false,
|
|
145
|
+
treeShowLines = false,
|
|
146
|
+
canvasTheme = "ink",
|
|
147
|
+
height = 340,
|
|
148
|
+
minWidth,
|
|
149
|
+
actions,
|
|
150
|
+
className,
|
|
151
|
+
id,
|
|
152
|
+
textareaClassName,
|
|
153
|
+
errorLine,
|
|
154
|
+
}: ITJSONEditorProps) {
|
|
155
|
+
const { t, formatNumber } = useITTranslation();
|
|
156
|
+
|
|
157
|
+
/* ---------------------------------------------------------------- estado */
|
|
158
|
+
|
|
159
|
+
const [viewMode, setViewMode] = useState<ITJSONEditorMode>(mode ?? "text");
|
|
160
|
+
const [wrapLines, setWrapLines] = useState(wrapLinesProp);
|
|
161
|
+
const [indentSize, setIndentSize] = useState(Math.min(8, Math.max(0, indentSizeProp)));
|
|
162
|
+
const [withNumbers, setWithNumbers] = useState(showLineNumbers);
|
|
163
|
+
const [withFolds, setWithFolds] = useState(showFoldGutter);
|
|
164
|
+
const [copied, setCopied] = useState(false);
|
|
165
|
+
const [foldedStarts, setFoldedStarts] = useState<number[]>([]);
|
|
166
|
+
const [caretLine, setCaretLine] = useState(1);
|
|
167
|
+
const [lineHeight, setLineHeight] = useState(21);
|
|
168
|
+
const [notice, setNotice] = useState<string | null>(null);
|
|
169
|
+
|
|
170
|
+
const canvasRef = useRef<HTMLDivElement>(null);
|
|
171
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
172
|
+
const highlightRef = useRef<HTMLDivElement>(null);
|
|
173
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
174
|
+
/** Selección a reponer tras el re-render que provoca el cambio de valor. */
|
|
175
|
+
const pendingSelection = useRef<[number, number] | null>(null);
|
|
176
|
+
const editorId = useId();
|
|
177
|
+
|
|
178
|
+
// Los props de modo y ajustes son el valor inicial, pero si la aplicación los
|
|
179
|
+
// cambia en caliente el editor debe seguirlos.
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
if (mode !== undefined) setViewMode(mode);
|
|
182
|
+
}, [mode]);
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
setWrapLines(wrapLinesProp);
|
|
185
|
+
}, [wrapLinesProp]);
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
setIndentSize(Math.min(8, Math.max(0, indentSizeProp)));
|
|
188
|
+
}, [indentSizeProp]);
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------- derivados */
|
|
191
|
+
|
|
192
|
+
const analysis = useMemo(() => analyzeCode(value), [value]);
|
|
193
|
+
const folds = useMemo(() => findFoldRegions(value), [value]);
|
|
194
|
+
|
|
195
|
+
const validation = useMemo(() => {
|
|
196
|
+
if (!value.trim()) {
|
|
197
|
+
return {
|
|
198
|
+
isValid: true,
|
|
199
|
+
parsed: undefined as unknown,
|
|
200
|
+
error: undefined as ReturnType<typeof parseJsonError> | undefined,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
return { isValid: true, parsed: JSON.parse(value) as unknown, error: undefined };
|
|
205
|
+
} catch (raw) {
|
|
206
|
+
return {
|
|
207
|
+
isValid: false,
|
|
208
|
+
parsed: undefined as unknown,
|
|
209
|
+
error: parseJsonError(raw, value, analysis.lineStarts),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
}, [value, analysis.lineStarts]);
|
|
213
|
+
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
onParse?.(validation.parsed, validation.isValid, validation.error?.message);
|
|
216
|
+
}, [validation, onParse]);
|
|
217
|
+
|
|
218
|
+
/** Regiones plegadas que siguen existiendo en el texto actual. */
|
|
219
|
+
const foldedRegions = useMemo(() => {
|
|
220
|
+
if (!foldedStarts.length || !folds.length) return [];
|
|
221
|
+
return folds.filter((region) => foldedStarts.includes(region.startLine));
|
|
222
|
+
}, [folds, foldedStarts]);
|
|
223
|
+
|
|
224
|
+
/** Líneas ocultas por el plegado (todo lo que no sea la de apertura). */
|
|
225
|
+
const hiddenLines = useMemo(() => {
|
|
226
|
+
const hidden = new Set<number>();
|
|
227
|
+
for (const region of foldedRegions) {
|
|
228
|
+
for (let line = region.startLine + 1; line <= region.endLine; line++) hidden.add(line);
|
|
229
|
+
}
|
|
230
|
+
return hidden;
|
|
231
|
+
}, [foldedRegions]);
|
|
232
|
+
|
|
233
|
+
/** Línea de apertura → línea de cierre, para pintar el marcador `…`. */
|
|
234
|
+
const closedByStart = useMemo(() => {
|
|
235
|
+
const map = new Map<number, number>();
|
|
236
|
+
for (const region of foldedRegions) map.set(region.startLine, region.endLine);
|
|
237
|
+
return map;
|
|
238
|
+
}, [foldedRegions]);
|
|
239
|
+
|
|
240
|
+
const visibleLines = useMemo(
|
|
241
|
+
() => analysis.lines.filter((line) => !hiddenLines.has(line.number)),
|
|
242
|
+
[analysis.lines, hiddenLines]
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const lineStarts = analysis.lineStarts;
|
|
246
|
+
|
|
247
|
+
const nodeCount = useMemo(() => countNodes(validation.parsed), [validation.parsed]);
|
|
248
|
+
const maxDepth = useMemo(
|
|
249
|
+
() => analysis.lines.reduce((deepest, line) => Math.max(deepest, line.depth), 0),
|
|
250
|
+
[analysis.lines]
|
|
251
|
+
);
|
|
252
|
+
const byteSize = useMemo(() => new Blob([value]).size, [value]);
|
|
253
|
+
|
|
254
|
+
/* ------------------------------------------------------------- medición */
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Alto real de una línea, medido sobre la propia fila pintada.
|
|
258
|
+
*
|
|
259
|
+
* Es la única forma de que gutter, capa resaltada y `<textarea>` queden
|
|
260
|
+
* alineados al píxel con cualquier tipografía: el valor se publica como
|
|
261
|
+
* `--it-json-editor-line` y el textarea lo usa como `line-height`. Se re-mide
|
|
262
|
+
* al cambiar de lienzo, al cargar las fuentes y al redimensionar el editor.
|
|
263
|
+
*/
|
|
264
|
+
useEffect(() => {
|
|
265
|
+
const measure = () => {
|
|
266
|
+
const textarea = textareaRef.current;
|
|
267
|
+
if (!textarea) return;
|
|
268
|
+
const computed = window.getComputedStyle(textarea);
|
|
269
|
+
const numeric = parseFloat(computed.lineHeight);
|
|
270
|
+
const fallback = parseFloat(computed.fontSize) * 1.7;
|
|
271
|
+
const measured = Number.isFinite(numeric) ? numeric : fallback;
|
|
272
|
+
if (measured <= 0) return;
|
|
273
|
+
setLineHeight(measured);
|
|
274
|
+
// El valor se publica en el lienzo y lo consumen el textarea, el gutter y
|
|
275
|
+
// la capa pintada, que así quedan alineados al píxel.
|
|
276
|
+
canvasRef.current?.style.setProperty("--it-json-editor-line", `${measured}px`);
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
measure();
|
|
280
|
+
const observer = new ResizeObserver(measure);
|
|
281
|
+
if (highlightRef.current) observer.observe(highlightRef.current);
|
|
282
|
+
document.fonts?.ready.then(measure).catch(() => undefined);
|
|
283
|
+
return () => observer.disconnect();
|
|
284
|
+
}, [canvasTheme, visibleLines.length, wrapLines]);
|
|
285
|
+
|
|
286
|
+
const rowHeight = `${lineHeight}px`;
|
|
287
|
+
const contentHeight = visibleLines.length * lineHeight;
|
|
288
|
+
const activeLine = validation.isValid ? caretLine : validation.error?.line ?? errorLine ?? caretLine;
|
|
289
|
+
const treeValue = validation.isValid ? validation.parsed : undefined;
|
|
290
|
+
|
|
291
|
+
/* -------------------------------------------------------------- edición */
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Repone la selección pedida por `applyEdit`. Hace falta porque el re-render
|
|
295
|
+
* del `<textarea>` controlado mueve el cursor: lo manda al final cuando cambia
|
|
296
|
+
* el texto y lo devuelve a su posición anterior cuando el texto no cambia (caso
|
|
297
|
+
* de "saltar" un cierre que ya estaba delante). React restaura el suyo durante
|
|
298
|
+
* el commit, así que la reposición se reafirma en el layout effect y en un par
|
|
299
|
+
* de turnos más; la siguiente pulsación cancela la intención.
|
|
300
|
+
*/
|
|
301
|
+
const restoreSelection = useCallback(() => {
|
|
302
|
+
const textarea = textareaRef.current;
|
|
303
|
+
const pending = pendingSelection.current;
|
|
304
|
+
if (!textarea || !pending) return;
|
|
305
|
+
const max = textarea.value.length;
|
|
306
|
+
textarea.setSelectionRange(Math.min(pending[0], max), Math.min(pending[1], max));
|
|
307
|
+
}, []);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Aplica la selección pedida y **consume** la intención: solo se repone una vez
|
|
311
|
+
* por edición. Si se dejara viva, cualquier commit posterior (un clic, un
|
|
312
|
+
* cambio de vista) devolvería el cursor a la posición del último atajo.
|
|
313
|
+
*/
|
|
314
|
+
const consumePendingSelection = useCallback(() => {
|
|
315
|
+
if (!pendingSelection.current) return;
|
|
316
|
+
const pending = pendingSelection.current;
|
|
317
|
+
pendingSelection.current = null;
|
|
318
|
+
restoreSelection();
|
|
319
|
+
// React restaura el suyo en el commit; se reafirma justo después.
|
|
320
|
+
queueMicrotask(() => {
|
|
321
|
+
const textarea = textareaRef.current;
|
|
322
|
+
if (!textarea) return;
|
|
323
|
+
const max = textarea.value.length;
|
|
324
|
+
textarea.setSelectionRange(Math.min(pending[0], max), Math.min(pending[1], max));
|
|
325
|
+
});
|
|
326
|
+
}, [restoreSelection]);
|
|
327
|
+
|
|
328
|
+
useLayoutEffect(() => {
|
|
329
|
+
consumePendingSelection();
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const applyEdit = useCallback(
|
|
333
|
+
(result: { value: string; selectionStart: number; selectionEnd: number; handled: boolean }) => {
|
|
334
|
+
pendingSelection.current = [result.selectionStart, result.selectionEnd];
|
|
335
|
+
onChange?.(result.value);
|
|
336
|
+
// Si el valor no cambia no habrá commit y el layout effect no correrá
|
|
337
|
+
// (caso de "saltar" un cierre que ya estaba delante): se aplica aquí y se
|
|
338
|
+
// reafirma tras el commit de React.
|
|
339
|
+
if (!pendingSelection.current) return;
|
|
340
|
+
const pending = pendingSelection.current;
|
|
341
|
+
pendingSelection.current = null;
|
|
342
|
+
restoreSelection();
|
|
343
|
+
queueMicrotask(() => {
|
|
344
|
+
const textarea = textareaRef.current;
|
|
345
|
+
if (!textarea) return;
|
|
346
|
+
const max = textarea.value.length;
|
|
347
|
+
textarea.setSelectionRange(Math.min(pending[0], max), Math.min(pending[1], max));
|
|
348
|
+
});
|
|
349
|
+
},
|
|
350
|
+
[onChange, restoreSelection]
|
|
351
|
+
);
|
|
352
|
+
|
|
353
|
+
const handleKeyDownRaw = useJsonEditorKeyboard({
|
|
354
|
+
textareaRef,
|
|
355
|
+
indentSize,
|
|
356
|
+
readOnly,
|
|
357
|
+
applyEdit,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
const handleKeyDown = useCallback(
|
|
361
|
+
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
362
|
+
// Una pulsación nueva manda: cualquier intención de cursor anterior se
|
|
363
|
+
// descarta para que no pise lo que haga el navegador con esta tecla.
|
|
364
|
+
pendingSelection.current = null;
|
|
365
|
+
handleKeyDownRaw(event);
|
|
366
|
+
},
|
|
367
|
+
[handleKeyDownRaw]
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
const syncCaret = useCallback(() => {
|
|
371
|
+
const textarea = textareaRef.current;
|
|
372
|
+
if (!textarea) return;
|
|
373
|
+
setCaretLine(caretPosition(value, textarea.selectionStart, lineStarts).line);
|
|
374
|
+
}, [value, lineStarts]);
|
|
375
|
+
|
|
376
|
+
const scrollCaretIntoView = useCallback(() => {
|
|
377
|
+
const textarea = textareaRef.current;
|
|
378
|
+
if (!textarea || wrapLines) return;
|
|
379
|
+
const { line } = caretPosition(value, textarea.selectionStart, lineStarts);
|
|
380
|
+
const target = (line - 1) * lineHeight;
|
|
381
|
+
const viewTop = textarea.scrollTop;
|
|
382
|
+
const viewBottom = viewTop + textarea.clientHeight;
|
|
383
|
+
if (target < viewTop || target + lineHeight > viewBottom) {
|
|
384
|
+
textarea.scrollTop = Math.max(0, target - textarea.clientHeight / 2);
|
|
385
|
+
if (highlightRef.current) highlightRef.current.scrollTop = textarea.scrollTop;
|
|
386
|
+
}
|
|
387
|
+
}, [value, lineStarts, lineHeight, wrapLines]);
|
|
388
|
+
|
|
389
|
+
const flash = useCallback((message: string) => {
|
|
390
|
+
setNotice(message);
|
|
391
|
+
window.setTimeout(() => setNotice(null), 1800);
|
|
392
|
+
}, []);
|
|
393
|
+
|
|
394
|
+
/* ------------------------------------------------------------ acciones */
|
|
395
|
+
|
|
396
|
+
const applyTransform = useCallback(
|
|
397
|
+
(transform: ITJsonTransform) => {
|
|
398
|
+
if (!validation.isValid || validation.parsed === undefined) return;
|
|
399
|
+
try {
|
|
400
|
+
const next = transformValue(validation.parsed, transform, indentSize);
|
|
401
|
+
if (next === value) return;
|
|
402
|
+
onChange?.(next);
|
|
403
|
+
setFoldedStarts([]);
|
|
404
|
+
flash(t("jsonEditor.applied"));
|
|
405
|
+
} catch {
|
|
406
|
+
flash(t("jsonEditor.actionFailed"));
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
[validation, indentSize, value, onChange, flash, t]
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
/** Atajos de la barra: Mayús+Alt+F formatea, +M minifica, +S ordena. */
|
|
413
|
+
const handleTransformHotkeys = useCallback(
|
|
414
|
+
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
415
|
+
if (!(event.altKey && event.shiftKey)) return;
|
|
416
|
+
const key = event.key.toLowerCase();
|
|
417
|
+
if (key === "f") applyTransform("format");
|
|
418
|
+
else if (key === "m") applyTransform("minify");
|
|
419
|
+
else if (key === "s") applyTransform("sortAsc");
|
|
420
|
+
},
|
|
421
|
+
[applyTransform]
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const handleCopy = async () => {
|
|
425
|
+
try {
|
|
426
|
+
await navigator.clipboard.writeText(value);
|
|
427
|
+
setCopied(true);
|
|
428
|
+
window.setTimeout(() => setCopied(false), 1600);
|
|
429
|
+
} catch {
|
|
430
|
+
flash(t("jsonEditor.copyFailed"));
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
const handleDownload = () => {
|
|
435
|
+
const blob = new Blob([value], { type: "application/json" });
|
|
436
|
+
const url = URL.createObjectURL(blob);
|
|
437
|
+
const anchor = document.createElement("a");
|
|
438
|
+
anchor.href = url;
|
|
439
|
+
anchor.download = "document.json";
|
|
440
|
+
anchor.click();
|
|
441
|
+
URL.revokeObjectURL(url);
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const handleFile = (file: File | undefined) => {
|
|
445
|
+
if (!file) return;
|
|
446
|
+
const reader = new FileReader();
|
|
447
|
+
reader.onload = () => {
|
|
448
|
+
onChange?.(String(reader.result ?? ""));
|
|
449
|
+
setFoldedStarts([]);
|
|
450
|
+
flash(t("jsonEditor.fileLoaded"));
|
|
451
|
+
};
|
|
452
|
+
reader.onerror = () => flash(t("jsonEditor.fileError"));
|
|
453
|
+
reader.readAsText(file);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
/** Salta a una línea, la deja visible y enfoca el editor. */
|
|
457
|
+
const jumpToLine = useCallback(
|
|
458
|
+
(line: number) => {
|
|
459
|
+
const textarea = textareaRef.current;
|
|
460
|
+
if (!textarea) return;
|
|
461
|
+
setViewMode("text");
|
|
462
|
+
const index = Math.min(lineStarts.length, Math.max(1, line)) - 1;
|
|
463
|
+
const start = lineStarts[index] ?? 0;
|
|
464
|
+
requestAnimationFrame(() => {
|
|
465
|
+
textarea.focus();
|
|
466
|
+
textarea.setSelectionRange(start, start);
|
|
467
|
+
setCaretLine(index + 1);
|
|
468
|
+
scrollCaretIntoView();
|
|
469
|
+
});
|
|
470
|
+
},
|
|
471
|
+
[lineStarts, scrollCaretIntoView]
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
/* ------------------------------------------------------------- plegado */
|
|
475
|
+
|
|
476
|
+
const toggleFold = (startLine: number) => {
|
|
477
|
+
setFoldedStarts((previous) =>
|
|
478
|
+
previous.includes(startLine)
|
|
479
|
+
? previous.filter((line) => line !== startLine)
|
|
480
|
+
: [...previous, startLine]
|
|
481
|
+
);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const toggleAllFolds = () => {
|
|
485
|
+
setFoldedStarts((previous) => (previous.length ? [] : folds.map((region) => region.startLine)));
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
/* ------------------------------------------------------------- pintado */
|
|
489
|
+
|
|
490
|
+
const effectiveFolds = withFolds ? folds : [];
|
|
491
|
+
const canvasHeight = typeof height === "number" ? `${height}px` : height;
|
|
492
|
+
const canvasMinWidth = typeof minWidth === "number" ? `${minWidth}px` : minWidth;
|
|
493
|
+
|
|
494
|
+
const statusText = validation.isValid
|
|
495
|
+
? t("jsonEditor.valid")
|
|
496
|
+
: t("jsonEditor.invalidAt", {
|
|
497
|
+
line: validation.error?.line ?? 1,
|
|
498
|
+
column: validation.error?.column ?? 1,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
return (
|
|
502
|
+
<div
|
|
503
|
+
id={id}
|
|
504
|
+
className={clsx("overflow-hidden rounded-[10px] border", FILETE, className)}
|
|
505
|
+
data-testid="it-json-editor"
|
|
506
|
+
data-editable={!readOnly}
|
|
507
|
+
data-mode={viewMode}
|
|
508
|
+
>
|
|
509
|
+
{/* ─── BARRA SUPERIOR ─── */}
|
|
510
|
+
{showToolbar && (
|
|
511
|
+
<div
|
|
512
|
+
className={clsx(
|
|
513
|
+
"flex flex-wrap items-center justify-between gap-x-3 gap-y-2 border-b bg-white px-3 py-2 dark:bg-slate-950",
|
|
514
|
+
FILETE
|
|
515
|
+
)}
|
|
516
|
+
>
|
|
517
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
518
|
+
{showValidation && (
|
|
519
|
+
<span className="flex shrink-0 items-center gap-1.5">
|
|
520
|
+
<span
|
|
521
|
+
aria-hidden="true"
|
|
522
|
+
className={clsx(
|
|
523
|
+
"inline-block h-1.5 w-1.5 rounded-full",
|
|
524
|
+
validation.isValid ? "bg-success-500" : "bg-danger-500"
|
|
525
|
+
)}
|
|
526
|
+
/>
|
|
527
|
+
<span
|
|
528
|
+
className={clsx(
|
|
529
|
+
"font-mono text-[10px] uppercase tracking-[0.12em]",
|
|
530
|
+
validation.isValid
|
|
531
|
+
? "text-success-700 dark:text-success-400"
|
|
532
|
+
: "text-danger-600 dark:text-danger-400"
|
|
533
|
+
)}
|
|
534
|
+
>
|
|
535
|
+
{validation.isValid ? t("jsonEditor.valid") : t("jsonEditor.invalid")}
|
|
536
|
+
</span>
|
|
537
|
+
</span>
|
|
538
|
+
)}
|
|
539
|
+
{showValidation && !validation.isValid && validation.error && (
|
|
540
|
+
<span
|
|
541
|
+
className="truncate font-mono text-[10.5px] text-danger-600 dark:text-danger-400"
|
|
542
|
+
title={validation.error.message}
|
|
543
|
+
>
|
|
544
|
+
<FaExclamationTriangle size={9} className="mr-1 inline" />
|
|
545
|
+
{`L${validation.error.line}:${validation.error.column} — ${validation.error.message}`}
|
|
546
|
+
</span>
|
|
547
|
+
)}
|
|
548
|
+
</div>
|
|
549
|
+
|
|
550
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
551
|
+
{showTransformActions && (
|
|
552
|
+
<>
|
|
553
|
+
<EditorIconButton
|
|
554
|
+
icon={<FaMagic size={10} />}
|
|
555
|
+
label={t("jsonEditor.format")}
|
|
556
|
+
hint={t("jsonEditor.formatHint")}
|
|
557
|
+
disabled={!validation.isValid || readOnly}
|
|
558
|
+
onClick={() => applyTransform("format")}
|
|
559
|
+
/>
|
|
560
|
+
<EditorIconButton
|
|
561
|
+
icon={<FaCompressAlt size={10} />}
|
|
562
|
+
label={t("jsonEditor.minify")}
|
|
563
|
+
disabled={!validation.isValid || readOnly}
|
|
564
|
+
onClick={() => applyTransform("minify")}
|
|
565
|
+
/>
|
|
566
|
+
<EditorIconButton
|
|
567
|
+
icon={<FaSortAlphaDown size={10} />}
|
|
568
|
+
label={t("jsonEditor.sortAsc")}
|
|
569
|
+
disabled={!validation.isValid || readOnly}
|
|
570
|
+
onClick={() => applyTransform("sortAsc")}
|
|
571
|
+
/>
|
|
572
|
+
<EditorIconButton
|
|
573
|
+
icon={<FaSortAlphaUp size={10} />}
|
|
574
|
+
label={t("jsonEditor.sortDesc")}
|
|
575
|
+
disabled={!validation.isValid || readOnly}
|
|
576
|
+
onClick={() => applyTransform("sortDesc")}
|
|
577
|
+
/>
|
|
578
|
+
<EditorIconButton
|
|
579
|
+
icon={<FaPlusSquare size={10} />}
|
|
580
|
+
label={t("jsonEditor.escape")}
|
|
581
|
+
disabled={!validation.isValid || readOnly}
|
|
582
|
+
onClick={() => applyTransform("escape")}
|
|
583
|
+
/>
|
|
584
|
+
<EditorIconButton
|
|
585
|
+
icon={<FaAlignLeft size={10} />}
|
|
586
|
+
label={t("jsonEditor.unescape")}
|
|
587
|
+
disabled={!validation.isValid || readOnly}
|
|
588
|
+
onClick={() => applyTransform("unescape")}
|
|
589
|
+
/>
|
|
590
|
+
<span className="mx-0.5 h-4 w-px bg-slate-200 dark:bg-slate-800" aria-hidden="true" />
|
|
591
|
+
</>
|
|
592
|
+
)}
|
|
593
|
+
|
|
594
|
+
{showModeSwitch && (
|
|
595
|
+
<div className="it-json-editor-segment" role="group" aria-label={t("jsonEditor.viewMode")}>
|
|
596
|
+
<button
|
|
597
|
+
type="button"
|
|
598
|
+
onClick={() => setViewMode("text")}
|
|
599
|
+
aria-pressed={viewMode === "text"}
|
|
600
|
+
className={clsx(
|
|
601
|
+
"it-json-editor-segment-item",
|
|
602
|
+
viewMode === "text" && "it-json-editor-segment-item--active"
|
|
603
|
+
)}
|
|
604
|
+
>
|
|
605
|
+
<FaTextHeight size={9} />
|
|
606
|
+
<span>{t("jsonEditor.modeText")}</span>
|
|
607
|
+
</button>
|
|
608
|
+
<button
|
|
609
|
+
type="button"
|
|
610
|
+
onClick={() => setViewMode("tree")}
|
|
611
|
+
aria-pressed={viewMode === "tree"}
|
|
612
|
+
className={clsx(
|
|
613
|
+
"it-json-editor-segment-item",
|
|
614
|
+
viewMode === "tree" && "it-json-editor-segment-item--active"
|
|
615
|
+
)}
|
|
616
|
+
>
|
|
617
|
+
<FaSitemap size={9} />
|
|
618
|
+
<span>{t("jsonEditor.modeTree")}</span>
|
|
619
|
+
</button>
|
|
620
|
+
</div>
|
|
621
|
+
)}
|
|
622
|
+
|
|
623
|
+
{actions}
|
|
624
|
+
|
|
625
|
+
{showCopyButton && (
|
|
626
|
+
<EditorIconButton
|
|
627
|
+
icon={copied ? <FaCheck size={10} /> : <FaCopy size={10} />}
|
|
628
|
+
label={copied ? t("jsonEditor.copied") : t("jsonEditor.copy")}
|
|
629
|
+
onClick={handleCopy}
|
|
630
|
+
/>
|
|
631
|
+
)}
|
|
632
|
+
{showImportButton && !readOnly && (
|
|
633
|
+
<EditorIconButton
|
|
634
|
+
icon={<FaUpload size={10} />}
|
|
635
|
+
label={t("jsonEditor.importFile")}
|
|
636
|
+
onClick={() => fileInputRef.current?.click()}
|
|
637
|
+
/>
|
|
638
|
+
)}
|
|
639
|
+
{showDownloadButton && (
|
|
640
|
+
<EditorIconButton
|
|
641
|
+
icon={<FaDownload size={10} />}
|
|
642
|
+
label={t("jsonEditor.download")}
|
|
643
|
+
onClick={handleDownload}
|
|
644
|
+
/>
|
|
645
|
+
)}
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
|
|
650
|
+
{/* ─── FILA DE AJUSTES DE VISTA ─── */}
|
|
651
|
+
{showViewSettings && (
|
|
652
|
+
<div
|
|
653
|
+
className={clsx(
|
|
654
|
+
"flex flex-wrap items-center gap-x-3 gap-y-1.5 border-b bg-slate-50/70 px-3 py-1.5 dark:bg-slate-900/60",
|
|
655
|
+
FILETE
|
|
656
|
+
)}
|
|
657
|
+
>
|
|
658
|
+
<span className={EYEBROW}>{t("jsonEditor.viewSettings")}</span>
|
|
659
|
+
<EditorToggle
|
|
660
|
+
label={t("jsonEditor.wrap")}
|
|
661
|
+
checked={wrapLines}
|
|
662
|
+
onChange={() => setWrapLines((previous) => !previous)}
|
|
663
|
+
/>
|
|
664
|
+
<EditorToggle
|
|
665
|
+
label={t("jsonEditor.lineNumbers")}
|
|
666
|
+
checked={withNumbers}
|
|
667
|
+
onChange={() => setWithNumbers((previous) => !previous)}
|
|
668
|
+
/>
|
|
669
|
+
<EditorToggle
|
|
670
|
+
label={t("jsonEditor.folding")}
|
|
671
|
+
checked={withFolds}
|
|
672
|
+
onChange={() => setWithFolds((previous) => !previous)}
|
|
673
|
+
/>
|
|
674
|
+
<label className="flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-slate-500 dark:text-slate-400">
|
|
675
|
+
<span>{t("jsonEditor.indent")}</span>
|
|
676
|
+
<select
|
|
677
|
+
value={indentSize}
|
|
678
|
+
onChange={(event) => setIndentSize(Number(event.target.value))}
|
|
679
|
+
aria-label={t("jsonEditor.indent")}
|
|
680
|
+
className={clsx(
|
|
681
|
+
"rounded-[6px] border bg-white px-1.5 py-0.5 font-mono text-[10px] focus:outline-none dark:bg-slate-950",
|
|
682
|
+
FILETE
|
|
683
|
+
)}
|
|
684
|
+
>
|
|
685
|
+
{[0, 2, 4, 8].map((size) => (
|
|
686
|
+
<option key={size} value={size}>
|
|
687
|
+
{size === 0 ? t("jsonEditor.indentNone") : `${size}`}
|
|
688
|
+
</option>
|
|
689
|
+
))}
|
|
690
|
+
</select>
|
|
691
|
+
</label>
|
|
692
|
+
{withFolds && effectiveFolds.length > 0 && (
|
|
693
|
+
<button type="button" onClick={toggleAllFolds} className="it-json-editor-tree-jump">
|
|
694
|
+
{foldedStarts.length ? t("jsonEditor.expandAll") : t("jsonEditor.collapseAll")}
|
|
695
|
+
</button>
|
|
696
|
+
)}
|
|
697
|
+
{notice && (
|
|
698
|
+
<span className="ml-auto font-mono text-[10px] uppercase tracking-[0.12em] text-primary-700 dark:text-primary-400">
|
|
699
|
+
{notice}
|
|
700
|
+
</span>
|
|
701
|
+
)}
|
|
702
|
+
</div>
|
|
703
|
+
)}
|
|
704
|
+
|
|
705
|
+
{/* ─── LIENZO ─── */}
|
|
706
|
+
<div
|
|
707
|
+
ref={canvasRef}
|
|
708
|
+
className="it-json-editor-canvas relative overflow-hidden"
|
|
709
|
+
data-canvas={canvasTheme}
|
|
710
|
+
style={{ height: canvasHeight, minWidth: canvasMinWidth }}
|
|
711
|
+
>
|
|
712
|
+
{/* Regla oculta: mide la altura real de una línea para alinear el gutter. */}
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
{viewMode === "tree" ? (
|
|
716
|
+
validation.isValid ? (
|
|
717
|
+
<ITCodeTreeView
|
|
718
|
+
value={treeValue}
|
|
719
|
+
analysis={analysis}
|
|
720
|
+
showLines={treeShowLines}
|
|
721
|
+
onJumpToLine={jumpToLine}
|
|
722
|
+
className="h-full"
|
|
723
|
+
/>
|
|
724
|
+
) : (
|
|
725
|
+
<div className="flex h-full items-center justify-center gap-2 p-4">
|
|
726
|
+
<FaExclamationTriangle size={12} className="text-danger-500" />
|
|
727
|
+
<span className="font-mono text-[11px] text-danger-600 dark:text-danger-400">
|
|
728
|
+
{t("jsonEditor.treeNeedsValid")}
|
|
729
|
+
</span>
|
|
730
|
+
</div>
|
|
731
|
+
)
|
|
732
|
+
) : (
|
|
733
|
+
<div className="flex h-full">
|
|
734
|
+
{/* Gutter de plegado */}
|
|
735
|
+
{withFolds && (
|
|
736
|
+
<div className="it-json-editor-gutter shrink-0" style={{ height: contentHeight }}>
|
|
737
|
+
{visibleLines.map((line) => {
|
|
738
|
+
const isFoldable = effectiveFolds.some((item) => item.startLine === line.number);
|
|
739
|
+
const isFolded = closedByStart.has(line.number);
|
|
740
|
+
return (
|
|
741
|
+
<div
|
|
742
|
+
key={line.number}
|
|
743
|
+
className="it-json-editor-gutter-row"
|
|
744
|
+
style={{ height: rowHeight, paddingLeft: line.depth * 6 }}
|
|
745
|
+
>
|
|
746
|
+
{isFoldable ? (
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
onClick={() => toggleFold(line.number)}
|
|
750
|
+
aria-label={
|
|
751
|
+
isFolded
|
|
752
|
+
? t("jsonEditor.expandBlock", { line: line.number })
|
|
753
|
+
: t("jsonEditor.collapseBlock", { line: line.number })
|
|
754
|
+
}
|
|
755
|
+
aria-expanded={!isFolded}
|
|
756
|
+
className="it-json-editor-fold"
|
|
757
|
+
>
|
|
758
|
+
{isFolded ? <FaChevronRight size={7} /> : <FaChevronDown size={7} />}
|
|
759
|
+
</button>
|
|
760
|
+
) : null}
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
})}
|
|
764
|
+
</div>
|
|
765
|
+
)}
|
|
766
|
+
|
|
767
|
+
{/* Números de línea */}
|
|
768
|
+
{withNumbers && (
|
|
769
|
+
<div className="it-json-editor-linenumbers shrink-0" style={{ height: contentHeight }}>
|
|
770
|
+
{visibleLines.map((line) => (
|
|
771
|
+
<div
|
|
772
|
+
key={line.number}
|
|
773
|
+
className={clsx(
|
|
774
|
+
"it-json-editor-linenumber",
|
|
775
|
+
line.number === activeLine && "it-json-editor-linenumber--active"
|
|
776
|
+
)}
|
|
777
|
+
style={{ height: rowHeight }}
|
|
778
|
+
>
|
|
779
|
+
{formatNumber(line.number)}
|
|
780
|
+
</div>
|
|
781
|
+
))}
|
|
782
|
+
</div>
|
|
783
|
+
)}
|
|
784
|
+
|
|
785
|
+
{/* Superficie de edición */}
|
|
786
|
+
<div className="relative min-w-0 flex-1">
|
|
787
|
+
{/* Fondo de línea vigente y de la línea con error. Con ajuste de
|
|
788
|
+
línea se desactivan: su posición depende de las vueltas que dé
|
|
789
|
+
cada línea, que ya no se pueden calcular por índice. */}
|
|
790
|
+
{!wrapLines && (
|
|
791
|
+
<>
|
|
792
|
+
<div
|
|
793
|
+
className="it-json-editor-active-line pointer-events-none absolute inset-x-0 top-0"
|
|
794
|
+
style={{
|
|
795
|
+
height: rowHeight,
|
|
796
|
+
transform: `translateY(${(activeLine - 1) * lineHeight}px)`,
|
|
797
|
+
}}
|
|
798
|
+
/>
|
|
799
|
+
{!validation.isValid && validation.error && (
|
|
800
|
+
<div
|
|
801
|
+
className="it-json-editor-error-line pointer-events-none absolute inset-x-0 top-0"
|
|
802
|
+
style={{
|
|
803
|
+
height: rowHeight,
|
|
804
|
+
transform: `translateY(${(validation.error.line - 1) * lineHeight}px)`,
|
|
805
|
+
}}
|
|
806
|
+
/>
|
|
807
|
+
)}
|
|
808
|
+
</>
|
|
809
|
+
)}
|
|
810
|
+
|
|
811
|
+
{/* Capa resaltada (debajo) y textarea transparente (encima). */}
|
|
812
|
+
<div
|
|
813
|
+
ref={highlightRef}
|
|
814
|
+
aria-hidden="true"
|
|
815
|
+
className={clsx(
|
|
816
|
+
"it-json-editor-highlight",
|
|
817
|
+
wrapLines ? "overflow-hidden" : "overflow-auto"
|
|
818
|
+
)}
|
|
819
|
+
>
|
|
820
|
+
<div className="it-json-editor-code" style={{ minHeight: contentHeight }}>
|
|
821
|
+
{visibleLines.map((line) => (
|
|
822
|
+
<div
|
|
823
|
+
key={line.number}
|
|
824
|
+
className="it-json-editor-code-line"
|
|
825
|
+
style={{ height: rowHeight }}
|
|
826
|
+
>
|
|
827
|
+
{line.tokens.map((token, index) => (
|
|
828
|
+
<span key={index} className={TOKEN_CLASS[token.type]}>
|
|
829
|
+
{token.text}
|
|
830
|
+
</span>
|
|
831
|
+
))}
|
|
832
|
+
{closedByStart.has(line.number) && (
|
|
833
|
+
<span className="it-json-editor-fold-marker">
|
|
834
|
+
{t("jsonEditor.foldMore", {
|
|
835
|
+
count: closedByStart.get(line.number)! - line.number,
|
|
836
|
+
})}
|
|
837
|
+
</span>
|
|
838
|
+
)}
|
|
839
|
+
</div>
|
|
840
|
+
))}
|
|
841
|
+
</div>
|
|
842
|
+
</div>
|
|
843
|
+
|
|
844
|
+
<textarea
|
|
845
|
+
ref={textareaRef}
|
|
846
|
+
id={editorId}
|
|
847
|
+
value={value}
|
|
848
|
+
onChange={(event) => {
|
|
849
|
+
onChange?.(event.target.value);
|
|
850
|
+
setFoldedStarts([]);
|
|
851
|
+
}}
|
|
852
|
+
onKeyDown={(event) => {
|
|
853
|
+
handleKeyDown(event);
|
|
854
|
+
handleTransformHotkeys(event);
|
|
855
|
+
}}
|
|
856
|
+
onKeyUp={syncCaret}
|
|
857
|
+
onClick={syncCaret}
|
|
858
|
+
onScroll={(event) => {
|
|
859
|
+
const highlight = highlightRef.current;
|
|
860
|
+
if (!highlight) return;
|
|
861
|
+
highlight.scrollTop = event.currentTarget.scrollTop;
|
|
862
|
+
highlight.scrollLeft = event.currentTarget.scrollLeft;
|
|
863
|
+
}}
|
|
864
|
+
readOnly={readOnly}
|
|
865
|
+
spellCheck={false}
|
|
866
|
+
autoComplete="off"
|
|
867
|
+
autoCorrect="off"
|
|
868
|
+
autoCapitalize="off"
|
|
869
|
+
wrap={wrapLines ? "soft" : "off"}
|
|
870
|
+
aria-label={t("jsonEditor.ariaLabel")}
|
|
871
|
+
aria-invalid={!validation.isValid}
|
|
872
|
+
placeholder={placeholder ?? t("jsonEditor.placeholder")}
|
|
873
|
+
className={clsx(
|
|
874
|
+
"it-json-editor-textarea",
|
|
875
|
+
!wrapLines && "it-json-editor-nowrap",
|
|
876
|
+
textareaClassName
|
|
877
|
+
)}
|
|
878
|
+
/>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
)}
|
|
882
|
+
</div>
|
|
883
|
+
|
|
884
|
+
{/* ─── PIE ─── */}
|
|
885
|
+
{showStatusBar && (
|
|
886
|
+
<div
|
|
887
|
+
className={clsx(
|
|
888
|
+
"flex flex-wrap items-center gap-x-4 gap-y-1 border-t bg-slate-50/70 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-slate-500 dark:bg-slate-900/60 dark:text-slate-400",
|
|
889
|
+
FILETE
|
|
890
|
+
)}
|
|
891
|
+
>
|
|
892
|
+
<span>{statusText}</span>
|
|
893
|
+
<span>{t("jsonEditor.statLines", { count: analysis.lineCount })}</span>
|
|
894
|
+
{validation.isValid && (
|
|
895
|
+
<>
|
|
896
|
+
<span>{t("jsonEditor.statNodes", { count: nodeCount })}</span>
|
|
897
|
+
<span>{t("jsonEditor.statDepth", { count: maxDepth })}</span>
|
|
898
|
+
<span>{t("jsonEditor.statRoot", { kind: describeKind(valueKind(validation.parsed), t) })}</span>
|
|
899
|
+
</>
|
|
900
|
+
)}
|
|
901
|
+
<span className="ml-auto">{t("jsonEditor.statSize", { size: formatBytes(byteSize) })}</span>
|
|
902
|
+
</div>
|
|
903
|
+
)}
|
|
904
|
+
|
|
905
|
+
<input
|
|
906
|
+
ref={fileInputRef}
|
|
907
|
+
type="file"
|
|
908
|
+
accept="application/json,.json"
|
|
909
|
+
className="hidden"
|
|
910
|
+
onChange={(event) => {
|
|
911
|
+
handleFile(event.target.files?.[0]);
|
|
912
|
+
event.target.value = "";
|
|
913
|
+
}}
|
|
914
|
+
/>
|
|
915
|
+
</div>
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/* -------------------------------------------------------------- auxiliares */
|
|
920
|
+
|
|
921
|
+
/** Botón de la barra: icono + etiqueta corta, con tooltip nativo. */
|
|
922
|
+
function EditorIconButton({
|
|
923
|
+
icon,
|
|
924
|
+
label,
|
|
925
|
+
hint,
|
|
926
|
+
onClick,
|
|
927
|
+
disabled,
|
|
928
|
+
}: {
|
|
929
|
+
icon: React.ReactNode;
|
|
930
|
+
label: string;
|
|
931
|
+
hint?: string;
|
|
932
|
+
onClick?: () => void;
|
|
933
|
+
disabled?: boolean;
|
|
934
|
+
}) {
|
|
935
|
+
return (
|
|
936
|
+
<button
|
|
937
|
+
type="button"
|
|
938
|
+
onClick={onClick}
|
|
939
|
+
disabled={disabled}
|
|
940
|
+
title={hint ?? label}
|
|
941
|
+
aria-label={label}
|
|
942
|
+
className="it-json-editor-tool"
|
|
943
|
+
>
|
|
944
|
+
{icon}
|
|
945
|
+
<span>{label}</span>
|
|
946
|
+
</button>
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** Interruptor compacto de la fila de ajustes. */
|
|
951
|
+
function EditorToggle({
|
|
952
|
+
label,
|
|
953
|
+
checked,
|
|
954
|
+
onChange,
|
|
955
|
+
}: {
|
|
956
|
+
label: string;
|
|
957
|
+
checked: boolean;
|
|
958
|
+
onChange: () => void;
|
|
959
|
+
}) {
|
|
960
|
+
return (
|
|
961
|
+
<button
|
|
962
|
+
type="button"
|
|
963
|
+
role="switch"
|
|
964
|
+
aria-checked={checked}
|
|
965
|
+
onClick={onChange}
|
|
966
|
+
className={clsx("it-json-editor-toggle", checked && "it-json-editor-toggle--on")}
|
|
967
|
+
>
|
|
968
|
+
<span className="it-json-editor-toggle-dot" aria-hidden="true" />
|
|
969
|
+
<span>{label}</span>
|
|
970
|
+
</button>
|
|
971
|
+
);
|
|
972
|
+
}
|