@axzydev/axzy_ui_system 1.5.3 → 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.
Files changed (35) hide show
  1. package/dist/index.cjs +6071 -4316
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +1 -1
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.cts +145 -1
  6. package/dist/index.d.ts +145 -1
  7. package/dist/index.js +4493 -2723
  8. package/dist/index.js.map +1 -1
  9. package/dist/layered.css +1 -1
  10. package/package.json +1 -1
  11. package/src/App.tsx +10 -0
  12. package/src/components/molecules/json-editor/json-editor-tree.tsx +235 -0
  13. package/src/components/molecules/json-editor/json-editor.index.ts +2 -0
  14. package/src/components/molecules/json-editor/json-editor.props.ts +82 -0
  15. package/src/components/molecules/json-editor/json-editor.tsx +972 -0
  16. package/src/components/molecules/json-editor/json-editor.utils.ts +717 -0
  17. package/src/components/molecules/json-editor/useJsonEditorKeyboard.ts +256 -0
  18. package/src/components/molecules/json-renderer/json-renderer.index.ts +2 -0
  19. package/src/components/molecules/json-renderer/json-renderer.props.ts +31 -0
  20. package/src/components/molecules/json-renderer/json-renderer.tsx +79 -0
  21. package/src/components/organisms/sidebar/sidebar.tsx +51 -14
  22. package/src/dev.css +24 -1
  23. package/src/hooks/useJsonRender.tsx +9 -0
  24. package/src/i18n/locales/en.ts +54 -0
  25. package/src/i18n/locales/es.ts +54 -0
  26. package/src/index.css +414 -0
  27. package/src/index.ts +10 -2
  28. package/src/sandbox/AxzyLogo.tsx +139 -0
  29. package/src/sandbox/i18n.ts +2 -0
  30. package/src/sandbox/navigation.ts +2 -0
  31. package/src/showcases/FeedbackShowcases.tsx +16 -5
  32. package/src/showcases/HomeShowcase.tsx +16 -9
  33. package/src/showcases/JsonEditorShowcase.tsx +150 -0
  34. package/src/showcases/JsonRendererShowcase.tsx +70 -0
  35. package/src/showcases/StructureShowcases.tsx +9 -3
@@ -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
+ }