millas 0.2.12-beta → 0.2.12-beta-2

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 (116) hide show
  1. package/package.json +3 -16
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +31 -10
  31. package/src/auth/AuthController.js +3 -1
  32. package/src/auth/AuthUser.js +119 -0
  33. package/src/cli.js +4 -2
  34. package/src/commands/createsuperuser.js +254 -0
  35. package/src/commands/lang.js +589 -0
  36. package/src/commands/migrate.js +154 -81
  37. package/src/commands/serve.js +82 -110
  38. package/src/container/AppInitializer.js +215 -0
  39. package/src/container/Application.js +278 -253
  40. package/src/container/HttpServer.js +156 -0
  41. package/src/container/MillasApp.js +29 -279
  42. package/src/container/MillasConfig.js +192 -0
  43. package/src/core/admin.js +5 -0
  44. package/src/core/auth.js +9 -0
  45. package/src/core/db.js +9 -0
  46. package/src/core/foundation.js +59 -0
  47. package/src/core/http.js +11 -0
  48. package/src/core/lang.js +1 -0
  49. package/src/core/mail.js +6 -0
  50. package/src/core/queue.js +7 -0
  51. package/src/core/validation.js +29 -0
  52. package/src/facades/Admin.js +1 -1
  53. package/src/facades/Auth.js +22 -39
  54. package/src/facades/Cache.js +21 -10
  55. package/src/facades/Database.js +1 -1
  56. package/src/facades/Events.js +18 -17
  57. package/src/facades/Facade.js +197 -0
  58. package/src/facades/Http.js +42 -45
  59. package/src/facades/Log.js +25 -49
  60. package/src/facades/Mail.js +27 -32
  61. package/src/facades/Queue.js +22 -15
  62. package/src/facades/Storage.js +18 -10
  63. package/src/facades/Url.js +53 -0
  64. package/src/http/HttpClient.js +673 -0
  65. package/src/http/ResponseDispatcher.js +18 -111
  66. package/src/http/UrlGenerator.js +375 -0
  67. package/src/http/WelcomePage.js +273 -0
  68. package/src/http/adapters/ExpressAdapter.js +315 -0
  69. package/src/http/adapters/HttpAdapter.js +168 -0
  70. package/src/http/adapters/index.js +9 -0
  71. package/src/i18n/I18nServiceProvider.js +91 -0
  72. package/src/i18n/Translator.js +635 -0
  73. package/src/i18n/defaults.js +122 -0
  74. package/src/i18n/index.js +164 -0
  75. package/src/i18n/locales/en.js +55 -0
  76. package/src/i18n/locales/sw.js +48 -0
  77. package/src/index.js +5 -144
  78. package/src/logger/formatters/PrettyFormatter.js +103 -57
  79. package/src/logger/internal.js +2 -2
  80. package/src/logger/patchConsole.js +91 -81
  81. package/src/middleware/MiddlewareRegistry.js +62 -82
  82. package/src/migrations/system/0001_users.js +21 -0
  83. package/src/migrations/system/0002_admin_log.js +25 -0
  84. package/src/migrations/system/0003_sessions.js +23 -0
  85. package/src/orm/fields/index.js +210 -188
  86. package/src/orm/migration/DefaultValueParser.js +325 -0
  87. package/src/orm/migration/InteractiveResolver.js +191 -0
  88. package/src/orm/migration/Makemigrations.js +312 -0
  89. package/src/orm/migration/MigrationGraph.js +227 -0
  90. package/src/orm/migration/MigrationRunner.js +202 -108
  91. package/src/orm/migration/MigrationWriter.js +463 -0
  92. package/src/orm/migration/ModelInspector.js +412 -344
  93. package/src/orm/migration/ModelScanner.js +225 -0
  94. package/src/orm/migration/ProjectState.js +213 -0
  95. package/src/orm/migration/RenameDetector.js +175 -0
  96. package/src/orm/migration/SchemaBuilder.js +8 -81
  97. package/src/orm/migration/operations/base.js +57 -0
  98. package/src/orm/migration/operations/column.js +191 -0
  99. package/src/orm/migration/operations/fields.js +252 -0
  100. package/src/orm/migration/operations/index.js +55 -0
  101. package/src/orm/migration/operations/models.js +152 -0
  102. package/src/orm/migration/operations/registry.js +131 -0
  103. package/src/orm/migration/operations/special.js +51 -0
  104. package/src/orm/migration/utils.js +208 -0
  105. package/src/orm/model/Model.js +81 -13
  106. package/src/providers/AdminServiceProvider.js +66 -9
  107. package/src/providers/AuthServiceProvider.js +46 -7
  108. package/src/providers/CacheStorageServiceProvider.js +5 -3
  109. package/src/providers/DatabaseServiceProvider.js +3 -2
  110. package/src/providers/EventServiceProvider.js +2 -1
  111. package/src/providers/LogServiceProvider.js +7 -3
  112. package/src/providers/MailServiceProvider.js +4 -3
  113. package/src/providers/QueueServiceProvider.js +4 -3
  114. package/src/router/Router.js +119 -152
  115. package/src/scaffold/templates.js +83 -26
  116. package/src/facades/Validation.js +0 -69
@@ -0,0 +1,1429 @@
1
+ /**
2
+ * json-editor.js — Phase 7: Polish
3
+ *
4
+ * PHASE 1: Stable DOM, incremental mutations, focus preserved
5
+ * PHASE 2: Explicit root type toggle, array index badges, no heuristic serialisation
6
+ * PHASE 3: row._depth/_isArray stored on row, _detachSubtree, infinite nesting
7
+ * PHASE 4: Type switching with inline safety banner, serialize-to-string escape hatch
8
+ * PHASE 5: Drag to reorder — same-level, drop indicator, no re-render
9
+ * PHASE 6: Undo / redo — snapshot stack, Ctrl+Z/Y, toolbar buttons
10
+ * PHASE 7 (new):
11
+ * - Keyboard navigation: Arrow Up/Down moves focus between rows,
12
+ * Arrow Right expands a collapsed container, Arrow Left collapses it,
13
+ * Tab/Shift+Tab moves between editable fields within a row,
14
+ * Delete/Backspace on a focused empty row removes it
15
+ * - Paste at every level: value inputs now also detect JSON paste,
16
+ * not just key inputs. Pasting a plain value into a string field
17
+ * still works normally — only valid JSON objects/arrays are intercepted
18
+ * - Empty state: #je-tree shows a friendly placeholder when _tree is empty,
19
+ * with a quick-add button
20
+ * - Error state: _fullRender wraps in try/catch and shows an inline error
21
+ * message if something goes wrong rather than silently breaking
22
+ * - Number input: type="number" on number rows so mobile gets a numpad
23
+ * and browser validates; value is still stored as JS number
24
+ * - Key dedup warning: duplicate keys in the same object are highlighted
25
+ * in red with a tooltip — they will overwrite each other on serialise
26
+ * - Snapshot dedup: consecutive identical snapshots are not pushed
27
+ * (prevents undo stack filling up from rapid type-ahead in key/value)
28
+ * - _renderTree alias removed — was only kept for legacy; gone cleanly
29
+ */
30
+ (function (root) {
31
+ 'use strict';
32
+
33
+ var PREVIEW_ROWS = 3;
34
+ // Phase 3: TREE_COLLAPSE removed — all rows always visible at every depth.
35
+ var TYPE_COLORS = {
36
+ string: 'var(--success,#16a34a)',
37
+ number: 'var(--info,#0284c7)',
38
+ boolean: 'var(--warning,#d97706)',
39
+ null: 'var(--text-xmuted,#9ca3af)',
40
+ object: 'var(--primary,#2563eb)',
41
+ array: 'var(--primary,#2563eb)',
42
+ };
43
+
44
+ // ── State ──────────────────────────────────────────────────────────────────
45
+ var _fieldName = null;
46
+ var _modal = null;
47
+ var _view = 'pretty';
48
+ var _tree = [];
49
+ var _rootType = 'object';
50
+ var _template = null;
51
+ var _nextId = 1;
52
+
53
+ // Phase 5: drag state
54
+ var _dragRow = null;
55
+ var _dragIndicator = null;
56
+
57
+ // Phase 6: undo / redo
58
+ var _undoStack = []; // array of snapshot strings (oldest first)
59
+ var _redoStack = []; // array of snapshot strings
60
+ var UNDO_LIMIT = 50;
61
+
62
+ function _id() { return _nextId++; }
63
+
64
+ // ── Phase 6: Undo / Redo ───────────────────────────────────────────────────
65
+
66
+ /**
67
+ * Take a snapshot of the current tree BEFORE a mutation.
68
+ * Call this at the top of every function that changes _tree or _rootType.
69
+ * Clears the redo stack — a new action forks the history.
70
+ */
71
+ function _snapshot() {
72
+ var snap = JSON.stringify({ rootType: _rootType, tree: _serialiseForHistory() });
73
+ // Phase 7: dedup — don't push if identical to the last snapshot
74
+ if (_undoStack.length && _undoStack[_undoStack.length - 1] === snap) return;
75
+ _undoStack.push(snap);
76
+ if (_undoStack.length > UNDO_LIMIT) _undoStack.shift();
77
+ _redoStack = [];
78
+ _updateUndoButtons();
79
+ }
80
+
81
+ function _undo() {
82
+ if (!_undoStack.length) return;
83
+ // Push current state onto redo stack before restoring
84
+ _redoStack.push(JSON.stringify({ rootType: _rootType, tree: _serialiseForHistory() }));
85
+ var snap = _undoStack.pop();
86
+ _restoreSnapshot(snap);
87
+ _updateUndoButtons();
88
+ }
89
+
90
+ function _redo() {
91
+ if (!_redoStack.length) return;
92
+ _undoStack.push(JSON.stringify({ rootType: _rootType, tree: _serialiseForHistory() }));
93
+ var snap = _redoStack.pop();
94
+ _restoreSnapshot(snap);
95
+ _updateUndoButtons();
96
+ }
97
+
98
+ /**
99
+ * Serialise the current tree to a plain JS value (no DOM refs) for history storage.
100
+ * Uses the same _serialise() path so it's always consistent.
101
+ */
102
+ function _serialiseForHistory() {
103
+ return _serialise();
104
+ }
105
+
106
+ /**
107
+ * Restore tree from a snapshot string.
108
+ * Parses → _objToRows → _fullRender. Fast because _objToRows is pure data.
109
+ */
110
+ function _restoreSnapshot(snap) {
111
+ try {
112
+ var state = JSON.parse(snap);
113
+ _rootType = state.rootType || 'object';
114
+ _tree = _objToRows(state.tree);
115
+ _renderRootTypeToggle();
116
+ _fullRender();
117
+ _updateCount();
118
+ } catch(e) {
119
+ console.error('[JsonEditor] undo/redo restore failed:', e);
120
+ }
121
+ }
122
+
123
+ /** Clear both stacks — called on open() so each editor session starts fresh. */
124
+ function _clearHistory() {
125
+ _undoStack = [];
126
+ _redoStack = [];
127
+ _updateUndoButtons();
128
+ }
129
+
130
+ /** Refresh the enabled/disabled state of the undo/redo toolbar buttons. */
131
+ function _updateUndoButtons() {
132
+ var undoBtn = document.getElementById('je-undo-btn');
133
+ var redoBtn = document.getElementById('je-redo-btn');
134
+ if (undoBtn) undoBtn.disabled = (_undoStack.length === 0);
135
+ if (redoBtn) redoBtn.disabled = (_redoStack.length === 0);
136
+ }
137
+
138
+ /** Global keydown handler — attached while the modal is open. */
139
+ function _onKeyDown(e) {
140
+ var isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
141
+ var mod = isMac ? e.metaKey : e.ctrlKey;
142
+
143
+ // ── Undo / Redo ──────────────────────────────────────────────────────────
144
+ if (mod) {
145
+ if (e.key === 'z' && !e.shiftKey) {
146
+ if (document.activeElement && document.activeElement.id === 'je-raw-ta') return;
147
+ e.preventDefault();
148
+ _undo();
149
+ return;
150
+ }
151
+ if (e.key === 'y' || (e.key === 'z' && e.shiftKey)) {
152
+ if (document.activeElement && document.activeElement.id === 'je-raw-ta') return;
153
+ e.preventDefault();
154
+ _redo();
155
+ return;
156
+ }
157
+ }
158
+
159
+ // ── Tree keyboard navigation (Phase 7) ──────────────────────────────────
160
+ // Only active when focus is on a je-row or a direct child of one
161
+ var active = document.activeElement;
162
+ if (!active) return;
163
+ var rowEl = active.closest ? active.closest('.je-row') : null;
164
+ if (!rowEl) return;
165
+
166
+ // Find the row object from the element
167
+ var row = _rowFromEl(rowEl);
168
+ if (!row) return;
169
+
170
+ switch (e.key) {
171
+ case 'ArrowDown': {
172
+ e.preventDefault();
173
+ var next = _nextVisibleRow(rowEl);
174
+ if (next) _focusRowEl(next);
175
+ break;
176
+ }
177
+ case 'ArrowUp': {
178
+ e.preventDefault();
179
+ var prev = _prevVisibleRow(rowEl);
180
+ if (prev) _focusRowEl(prev);
181
+ break;
182
+ }
183
+ case 'ArrowRight': {
184
+ // Expand if collapsed container and focus not in a text input
185
+ if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return;
186
+ if ((row.type === 'object' || row.type === 'array') && row.collapsed) {
187
+ e.preventDefault();
188
+ _toggleCollapse(row);
189
+ }
190
+ break;
191
+ }
192
+ case 'ArrowLeft': {
193
+ if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA') return;
194
+ if ((row.type === 'object' || row.type === 'array') && !row.collapsed) {
195
+ e.preventDefault();
196
+ _toggleCollapse(row);
197
+ } else if (row._depth > 0) {
198
+ // Jump to parent row
199
+ e.preventDefault();
200
+ var parentEl = _findParentRowEl(rowEl);
201
+ if (parentEl) _focusRowEl(parentEl);
202
+ }
203
+ break;
204
+ }
205
+ case 'Delete':
206
+ case 'Backspace': {
207
+ // Delete the row only if focus is on the row itself (not inside an input)
208
+ if (active === rowEl) {
209
+ e.preventDefault();
210
+ _deleteRow(row);
211
+ }
212
+ break;
213
+ }
214
+ }
215
+ }
216
+
217
+ // ── Phase 7: keyboard nav helpers ─────────────────────────────────────────
218
+
219
+ /** Find the row object whose _el matches a DOM element. */
220
+ function _rowFromEl(el) {
221
+ return _findRowByEl(el, _tree);
222
+ }
223
+ function _findRowByEl(el, rows) {
224
+ for (var i = 0; i < rows.length; i++) {
225
+ if (rows[i]._el === el) return rows[i];
226
+ if (rows[i].children) {
227
+ var found = _findRowByEl(el, rows[i].children);
228
+ if (found) return found;
229
+ }
230
+ }
231
+ return null;
232
+ }
233
+
234
+ /** Next visible je-row element in DOM order. */
235
+ function _nextVisibleRow(rowEl) {
236
+ var all = Array.from(document.getElementById('je-tree').querySelectorAll('.je-row'));
237
+ var idx = all.indexOf(rowEl);
238
+ return idx >= 0 && idx < all.length - 1 ? all[idx + 1] : null;
239
+ }
240
+
241
+ /** Previous visible je-row element in DOM order. */
242
+ function _prevVisibleRow(rowEl) {
243
+ var all = Array.from(document.getElementById('je-tree').querySelectorAll('.je-row'));
244
+ var idx = all.indexOf(rowEl);
245
+ return idx > 0 ? all[idx - 1] : null;
246
+ }
247
+
248
+ /** Focus a row element — make it tabbable and focus it. */
249
+ function _focusRowEl(rowEl) {
250
+ if (!rowEl) return;
251
+ rowEl.setAttribute('tabindex', '0');
252
+ rowEl.focus();
253
+ }
254
+
255
+ /** Find the parent row element of a nested row by walking up through .je-children. */
256
+ function _findParentRowEl(rowEl) {
257
+ var childrenWrap = rowEl.parentNode;
258
+ if (!childrenWrap || !childrenWrap.classList.contains('je-children')) return null;
259
+ // The parent row is the sibling immediately before the children wrapper
260
+ var prev = childrenWrap.previousSibling;
261
+ while (prev && !prev.classList) prev = prev.previousSibling;
262
+ return (prev && prev.classList.contains('je-row')) ? prev : null;
263
+ }
264
+
265
+ // ── Row factory ────────────────────────────────────────────────────────────
266
+ // row._el → its <div class="je-row"> in the DOM (null until mounted)
267
+ // row._childrenEl → its <div class="je-children"> wrapper (null until mounted)
268
+ // row._depth → nesting depth (0 = root), set at mount time (Phase 3)
269
+ // row._isArray → whether THIS row lives inside an array container (Phase 3)
270
+ // row.isArrayContainer → true when this object/array row holds array items (Phase 2)
271
+ function makeRow(key, type, value, children, isArrayContainer) {
272
+ return {
273
+ id: _id(),
274
+ key: key,
275
+ type: type,
276
+ value: value,
277
+ children: children || null,
278
+ isArrayContainer: !!isArrayContainer,
279
+ collapsed: true,
280
+ _el: null,
281
+ _childrenEl: null,
282
+ _depth: 0,
283
+ _isArray: false,
284
+ _warnEl: null, // Phase 4: active type-change warning banner
285
+ _dragEl: null, // Phase 5: drag handle element reference
286
+ };
287
+ }
288
+
289
+ // ── Public API ─────────────────────────────────────────────────────────────
290
+
291
+ function open(fieldName) {
292
+ _fieldName = fieldName;
293
+ var raw = document.getElementById('field-' + fieldName).value || '{}';
294
+ var parsed;
295
+ try { parsed = JSON.parse(raw); } catch(e) { parsed = {}; }
296
+
297
+ _rootType = Array.isArray(parsed) ? 'array' : 'object';
298
+ _tree = _objToRows(parsed);
299
+ _view = 'pretty';
300
+
301
+ _clearHistory(); // Phase 6: fresh history per session
302
+
303
+ var $content = $(_template.cloneNode(true));
304
+ $content.css('display', '');
305
+
306
+ _modal = UI.Modal.create({
307
+ title: 'JSON Editor',
308
+ size: 'lg',
309
+ className: 'je-modal',
310
+ content: $content[0],
311
+ onClose: function() {
312
+ document.removeEventListener('keydown', _onKeyDown); // Phase 6
313
+ _modal = null;
314
+ },
315
+ });
316
+
317
+ _modal.open();
318
+ document.addEventListener('keydown', _onKeyDown); // Phase 6
319
+
320
+ $content.find('#je-title').text(fieldName.replace(/_/g, ' '));
321
+ _renderRootTypeToggle();
322
+ _setView('pretty');
323
+ _updateCount();
324
+ _updateUndoButtons(); // Phase 6: initialise button states
325
+ }
326
+
327
+ // Phase 2: render the root-level {obj} / [arr] toggle in the toolbar
328
+ function _renderRootTypeToggle() {
329
+ var wrap = document.getElementById('je-root-type-wrap');
330
+ if (!wrap) return;
331
+ wrap.innerHTML = '';
332
+
333
+ var label = document.createElement('span');
334
+ label.className = 'je-root-type-label';
335
+ label.textContent = 'Root:';
336
+ wrap.appendChild(label);
337
+
338
+ ['object', 'array'].forEach(function(t) {
339
+ var btn = document.createElement('button');
340
+ btn.type = 'button';
341
+ btn.className = 'je-root-type-btn' + (_rootType === t ? ' active' : '');
342
+ btn.textContent = t === 'object' ? '{ } object' : '[ ] array';
343
+ btn.dataset.type = t;
344
+ btn.addEventListener('click', function() {
345
+ if (_rootType === t) return;
346
+ _snapshot(); // Phase 6
347
+ _rootType = t;
348
+ // Update button states
349
+ wrap.querySelectorAll('.je-root-type-btn').forEach(function(b) {
350
+ b.classList.toggle('active', b.dataset.type === t);
351
+ });
352
+ // Full re-render — structure changed
353
+ _fullRender();
354
+ _updateCount();
355
+ });
356
+ wrap.appendChild(btn);
357
+ });
358
+ }
359
+
360
+ function _apply() {
361
+ if (_view === 'raw') {
362
+ var raw = document.getElementById('je-raw-ta').value;
363
+ var parsed;
364
+ try { parsed = JSON.parse(raw); } catch(e) {
365
+ _showRawHint('Invalid JSON — fix before applying', true);
366
+ return;
367
+ }
368
+ _rootType = Array.isArray(parsed) ? 'array' : 'object';
369
+ _tree = _objToRows(parsed);
370
+ }
371
+
372
+ var result = _serialise();
373
+ var jsonStr = JSON.stringify(result);
374
+
375
+ document.getElementById('field-' + _fieldName).value = jsonStr;
376
+ _renderPreview(_fieldName, result);
377
+ document.removeEventListener('keydown', _onKeyDown); // Phase 6
378
+ _modal.close();
379
+ _modal = null;
380
+ }
381
+
382
+ function _cancel() {
383
+ document.removeEventListener('keydown', _onKeyDown); // Phase 6
384
+ if (_modal) { _modal.close(); _modal = null; }
385
+ }
386
+
387
+ function _setView(v) {
388
+ _view = v;
389
+ $('#je-tab-pretty').toggleClass('active', v === 'pretty');
390
+ $('#je-tab-raw').toggleClass('active', v === 'raw');
391
+ $('#je-format-btn').toggle(v === 'raw');
392
+
393
+ if (v === 'raw') {
394
+ var obj = _serialise();
395
+ $('#je-raw-ta').val(JSON.stringify(obj, null, 2));
396
+ _showRawHint('');
397
+ $('#je-pretty-view').hide();
398
+ $('#je-raw-view').show();
399
+ $('#je-raw-ta').focus();
400
+ } else {
401
+ // Sync raw → tree if switching back
402
+ var rawVal = $('#je-raw-ta').val();
403
+ if (rawVal) {
404
+ try {
405
+ var parsed = JSON.parse(rawVal);
406
+ _rootType = Array.isArray(parsed) ? 'array' : 'object';
407
+ _tree = _objToRows(parsed);
408
+ _showRawHint('');
409
+ } catch(e) { /* keep current tree */ }
410
+ }
411
+ $('#je-raw-view').hide();
412
+ $('#je-pretty-view').show();
413
+ _renderRootTypeToggle();
414
+ _fullRender();
415
+ }
416
+ _updateCount();
417
+ }
418
+
419
+ function _format() {
420
+ var raw = $('#je-raw-ta').val();
421
+ try {
422
+ $('#je-raw-ta').val(JSON.stringify(JSON.parse(raw), null, 2));
423
+ _showRawHint('Formatted', false);
424
+ } catch(e) {
425
+ _showRawHint('Invalid JSON', true);
426
+ }
427
+ }
428
+
429
+ // ══════════════════════════════════════════════════════════════════════════
430
+ // RENDERING — stable DOM approach
431
+ // ══════════════════════════════════════════════════════════════════════════
432
+
433
+ // Full render: clears #je-tree and rebuilds from _tree.
434
+ // Only called on open(), view switch, root type toggle, paste, or undo/redo.
435
+ function _fullRender() {
436
+ var treeEl = document.getElementById('je-tree');
437
+ if (!treeEl) return;
438
+ treeEl.innerHTML = '';
439
+
440
+ // Phase 7: empty state
441
+ if (!_tree.length) {
442
+ var empty = document.createElement('div');
443
+ empty.className = 'je-empty-state';
444
+ empty.innerHTML =
445
+ '<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="color:var(--border)"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>' +
446
+ '<p>No entries yet.</p>' +
447
+ '<button type="button" class="je-empty-add-btn">+ Add first row</button>';
448
+ empty.querySelector('.je-empty-add-btn').addEventListener('click', function() {
449
+ // Push a blank row into the data model, then do a clean full render
450
+ // so _mountRows sets up the container and add-btn properly
451
+ _snapshot();
452
+ var isArr = _rootType === 'array';
453
+ var row = makeRow('', 'string', '', null);
454
+ row._depth = 0;
455
+ row._isArray = isArr;
456
+ _tree.push(row);
457
+ _fullRender();
458
+ _updateCount();
459
+ // Focus the new row's first input
460
+ setTimeout(function() {
461
+ var firstInput = treeEl.querySelector('.je-key-input, .je-val-input');
462
+ if (firstInput) firstInput.focus();
463
+ }, 30);
464
+ });
465
+ treeEl.appendChild(empty);
466
+ return;
467
+ }
468
+
469
+ // Phase 7: wrap in try/catch — show inline error if render fails
470
+ try {
471
+ _mountRows(_tree, treeEl, 0, _rootType === 'array');
472
+ } catch(err) {
473
+ console.error('[JsonEditor] render error:', err);
474
+ treeEl.innerHTML =
475
+ '<div class="je-render-error">' +
476
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>' +
477
+ ' Render error — switch to Raw view to inspect and fix the data.' +
478
+ '</div>';
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Mount a list of rows into a container element.
484
+ * Phase 3: all rows always visible — no TREE_COLLAPSE truncation.
485
+ */
486
+ function _mountRows(rows, container, depth, isArray) {
487
+ rows.forEach(function(row) {
488
+ _mountRow(row, container, depth, isArray);
489
+ });
490
+ // Stable add-row button — created once, lives at bottom of this container
491
+ var addBtn = _makeAddBtn(rows, container, depth, isArray);
492
+ addBtn.classList.add('je-add-row-btn--container');
493
+ container.appendChild(addBtn);
494
+ }
495
+
496
+ /**
497
+ * Mount a single row into a container.
498
+ * Phase 3: stamps row._depth and row._isArray at mount time.
499
+ */
500
+ function _mountRow(row, container, depth, isArray) {
501
+ row._depth = depth; // Phase 3: stored so we don't thread depth everywhere
502
+ row._isArray = isArray; // Phase 3: stored for same reason
503
+ var el = _buildRowEl(row, depth, isArray);
504
+ row._el = el;
505
+ container.appendChild(el);
506
+
507
+ if ((row.type === 'object' || row.type === 'array') && !row.collapsed && row.children) {
508
+ _mountChildrenEl(row);
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Unmount a row (and its children) from the DOM without touching siblings.
514
+ */
515
+ function _unmountRow(row) {
516
+ if (row._childrenEl && row._childrenEl.parentNode) {
517
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
518
+ row._childrenEl = null;
519
+ }
520
+ if (row._el && row._el.parentNode) {
521
+ row._el.parentNode.removeChild(row._el);
522
+ row._el = null;
523
+ }
524
+ }
525
+
526
+ /**
527
+ * Patch an existing row's DOM node in-place.
528
+ * Phase 3: uses row._depth and row._isArray stored on the row.
529
+ */
530
+ function _patchRow(row) {
531
+ if (!row._el) return;
532
+ var depth = row._depth;
533
+ var isArray = row._isArray;
534
+
535
+ // Re-render the toggle icon
536
+ var toggleEl = row._el.querySelector('.je-toggle');
537
+ if (toggleEl) {
538
+ if (row.type === 'object' || row.type === 'array') {
539
+ toggleEl.innerHTML = row.collapsed
540
+ ? '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>'
541
+ : '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
542
+ toggleEl.classList.add('je-toggle-active');
543
+ } else {
544
+ toggleEl.innerHTML = '';
545
+ toggleEl.classList.remove('je-toggle-active');
546
+ }
547
+ }
548
+
549
+ // Update type select colour
550
+ var typeSelect = row._el.querySelector('.je-type-select');
551
+ if (typeSelect) {
552
+ typeSelect.style.color = TYPE_COLORS[row.type] || '';
553
+ }
554
+
555
+ // Replace value cell
556
+ var oldValueCell = row._el.querySelector('.je-value-cell');
557
+ if (oldValueCell) {
558
+ var newValueCell = _buildValueCell(row);
559
+ row._el.replaceChild(newValueCell, oldValueCell);
560
+ }
561
+
562
+ // Update index badge if this is an array item
563
+ var indexBadge = row._el.querySelector('.je-index-badge');
564
+ if (indexBadge) {
565
+ var idx = _getRowIndex(row);
566
+ indexBadge.textContent = idx >= 0 ? String(idx) : '—';
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Mount the children wrapper for a row (object/array that is expanded).
572
+ * Phase 3: uses row._depth instead of a depth argument.
573
+ * Detaches/reattaches the existing wrapper element to keep descendant _el refs valid.
574
+ */
575
+ function _mountChildrenEl(row) {
576
+ if (!row._el) return;
577
+ var childDepth = row._depth + 1;
578
+ var childIsArray = row.isArrayContainer;
579
+
580
+ if (row._childrenEl) {
581
+ // Wrapper already exists — clear only the DOM children, remount rows
582
+ // This preserves the element reference so CSS transitions stay intact
583
+ while (row._childrenEl.firstChild) {
584
+ row._childrenEl.removeChild(row._childrenEl.firstChild);
585
+ }
586
+ _mountRows(row.children, row._childrenEl, childDepth, childIsArray);
587
+ // Re-attach if it was detached
588
+ if (!row._childrenEl.parentNode) {
589
+ row._el.parentNode.insertBefore(row._childrenEl, row._el.nextSibling);
590
+ }
591
+ return;
592
+ }
593
+
594
+ var div = document.createElement('div');
595
+ div.className = 'je-children';
596
+ row._childrenEl = div;
597
+ _mountRows(row.children, div, childDepth, childIsArray);
598
+ row._el.parentNode.insertBefore(div, row._el.nextSibling);
599
+ }
600
+
601
+ // ── Element builders ───────────────────────────────────────────────────────
602
+
603
+ function _buildRowEl(row, depth, isArray) {
604
+ var el = document.createElement('div');
605
+ el.className = 'je-row';
606
+ el.dataset.id = row.id;
607
+ el.draggable = true;
608
+ el.setAttribute('tabindex', '0'); // Phase 7: keyboard nav
609
+
610
+ // Phase 5: drag handle — 6-dot grip, leftmost element
611
+ var handle = document.createElement('span');
612
+ handle.className = 'je-drag-handle';
613
+ handle.title = 'Drag to reorder';
614
+ handle.innerHTML =
615
+ '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor">' +
616
+ '<circle cx="2.5" cy="2.5" r="1.5"/><circle cx="7.5" cy="2.5" r="1.5"/>' +
617
+ '<circle cx="2.5" cy="7" r="1.5"/><circle cx="7.5" cy="7" r="1.5"/>' +
618
+ '<circle cx="2.5" cy="11.5" r="1.5"/><circle cx="7.5" cy="11.5" r="1.5"/>' +
619
+ '</svg>';
620
+ row._dragEl = handle;
621
+ el.appendChild(handle);
622
+
623
+ // Indent
624
+ if (depth) {
625
+ var indent = document.createElement('span');
626
+ indent.className = 'je-indent';
627
+ indent.style.width = (depth * 20) + 'px';
628
+ el.appendChild(indent);
629
+ }
630
+
631
+ // Toggle
632
+ var toggle = document.createElement('span');
633
+ toggle.className = 'je-toggle';
634
+ if (row.type === 'object' || row.type === 'array') {
635
+ toggle.innerHTML = row.collapsed
636
+ ? '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>'
637
+ : '<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>';
638
+ toggle.classList.add('je-toggle-active');
639
+ toggle.addEventListener('click', function() { _toggleCollapse(row); }); // Phase 3: no depth arg
640
+ }
641
+ el.appendChild(toggle);
642
+
643
+ // Phase 2: array items get a read-only index badge; object items get a key input
644
+ if (isArray) {
645
+ var indexBadge = document.createElement('span');
646
+ indexBadge.className = 'je-index-badge';
647
+ // We'll compute the actual index at render time
648
+ indexBadge.textContent = _getRowIndex(row) >= 0 ? String(_getRowIndex(row)) : '—';
649
+ el.appendChild(indexBadge);
650
+
651
+ var colon2 = document.createElement('span');
652
+ colon2.className = 'je-colon';
653
+ el.appendChild(colon2);
654
+ } else {
655
+ var keyInput = document.createElement('input');
656
+ keyInput.type = 'text';
657
+ keyInput.className = 'je-key-input';
658
+ keyInput.placeholder = 'key';
659
+ keyInput.value = row.key;
660
+ keyInput.addEventListener('input', function() {
661
+ row.key = keyInput.value;
662
+ _checkDupKey(row, keyInput); // Phase 7
663
+ _updateCount();
664
+ });
665
+ keyInput.addEventListener('paste', function(e) {
666
+ _handlePaste(e, row);
667
+ });
668
+ // Phase 7: initial dup check on mount
669
+ setTimeout(function() { _checkDupKey(row, keyInput); }, 0);
670
+ el.appendChild(keyInput);
671
+
672
+ var colon = document.createElement('span');
673
+ colon.className = 'je-colon';
674
+ el.appendChild(colon);
675
+ }
676
+
677
+ // Type select
678
+ var typeSelect = document.createElement('select');
679
+ typeSelect.className = 'je-type-select';
680
+ typeSelect.style.color = TYPE_COLORS[row.type] || '';
681
+ ['string','number','boolean','null','object','array'].forEach(function(t) {
682
+ var opt = document.createElement('option');
683
+ opt.value = t;
684
+ opt.text = t;
685
+ opt.selected = (t === row.type);
686
+ typeSelect.appendChild(opt);
687
+ });
688
+ typeSelect.addEventListener('change', function() {
689
+ _changeType(row, typeSelect.value); // Phase 3: no depth/isArray args
690
+ });
691
+ el.appendChild(typeSelect);
692
+
693
+ // Value cell
694
+ el.appendChild(_buildValueCell(row));
695
+
696
+ // Delete button
697
+ var delBtn = document.createElement('button');
698
+ delBtn.type = 'button';
699
+ delBtn.className = 'je-del-btn';
700
+ delBtn.title = 'Remove';
701
+ delBtn.innerHTML = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
702
+ delBtn.addEventListener('click', function() {
703
+ _deleteRow(row);
704
+ });
705
+ el.appendChild(delBtn);
706
+
707
+ // Phase 5: drag events
708
+ el.addEventListener('dragstart', function(e) {
709
+ _dragRow = row;
710
+ e.dataTransfer.effectAllowed = 'move';
711
+ e.dataTransfer.setData('text/plain', String(row.id)); // required by Firefox
712
+ // Use the handle as the drag image anchor so the whole row doesn't ghost weirdly
713
+ setTimeout(function() { el.classList.add('je-dragging'); }, 0);
714
+ });
715
+
716
+ el.addEventListener('dragend', function() {
717
+ el.classList.remove('je-dragging');
718
+ _removeDragIndicator();
719
+ _dragRow = null;
720
+ });
721
+
722
+ el.addEventListener('dragover', function(e) {
723
+ if (!_dragRow || _dragRow === row) return;
724
+ // Only allow same-container drops
725
+ var dragParent = _findParentRows(_dragRow, _tree);
726
+ var thisParent = _findParentRows(row, _tree);
727
+ if (dragParent !== thisParent) return;
728
+
729
+ e.preventDefault();
730
+ e.dataTransfer.dropEffect = 'move';
731
+
732
+ // Decide whether to insert before or after based on cursor Y position
733
+ var rect = el.getBoundingClientRect();
734
+ var midY = rect.top + rect.height / 2;
735
+ var before = e.clientY < midY;
736
+ _showDragIndicator(el, before);
737
+ });
738
+
739
+ el.addEventListener('dragleave', function(e) {
740
+ // Only remove indicator if leaving to outside this row entirely
741
+ if (!el.contains(e.relatedTarget)) {
742
+ _removeDragIndicator();
743
+ }
744
+ });
745
+
746
+ el.addEventListener('drop', function(e) {
747
+ e.preventDefault();
748
+ if (!_dragRow || _dragRow === row) return;
749
+
750
+ var dragParent = _findParentRows(_dragRow, _tree);
751
+ var thisParent = _findParentRows(row, _tree);
752
+ if (dragParent !== thisParent) return;
753
+
754
+ var rect = el.getBoundingClientRect();
755
+ var before = e.clientY < rect.top + rect.height / 2;
756
+
757
+ _reorderRow(_dragRow, row, before, dragParent);
758
+ _removeDragIndicator();
759
+ });
760
+
761
+ return el;
762
+ }
763
+
764
+ // Phase 2: find the index of a row in its parent array (for index badge)
765
+ function _getRowIndex(row) {
766
+ var parent = _findParentRows(row, _tree);
767
+ if (!parent) return -1;
768
+ return parent.indexOf(row);
769
+ }
770
+
771
+ function _buildValueCell(row) {
772
+ var cell = document.createElement('span');
773
+ cell.className = 'je-value-cell';
774
+
775
+ if (row.type === 'object' || row.type === 'array') {
776
+ var count = row.children ? row.children.length : 0;
777
+ var label = row.isArrayContainer ? count + ' items' : count + ' keys';
778
+ var badge = document.createElement('span');
779
+ badge.className = 'je-nested-badge';
780
+ badge.textContent = label;
781
+ badge.addEventListener('click', function() { _toggleCollapse(row); }); // Phase 3
782
+ cell.appendChild(badge);
783
+ return cell;
784
+ }
785
+
786
+ if (row.type === 'boolean') {
787
+ var sel = document.createElement('select');
788
+ sel.className = 'je-val-select';
789
+ ['true','false'].forEach(function(v) {
790
+ var o = document.createElement('option');
791
+ o.value = v; o.text = v;
792
+ o.selected = (String(row.value) === v);
793
+ sel.appendChild(o);
794
+ });
795
+ sel.addEventListener('change', function() { row.value = sel.value === 'true'; });
796
+ cell.appendChild(sel);
797
+ return cell;
798
+ }
799
+
800
+ if (row.type === 'null') {
801
+ var nullLabel = document.createElement('span');
802
+ nullLabel.className = 'je-null-label';
803
+ nullLabel.textContent = 'null';
804
+ cell.appendChild(nullLabel);
805
+ return cell;
806
+ }
807
+
808
+ var input = document.createElement('input');
809
+ // Phase 7: use type="number" for number rows — mobile numpad + browser validation
810
+ input.type = row.type === 'number' ? 'number' : 'text';
811
+ input.className = 'je-val-input';
812
+ input.placeholder = row.type === 'number' ? '0' : 'value';
813
+ input.value = (row.value === null || row.value === undefined) ? '' : String(row.value);
814
+ input.addEventListener('input', function() {
815
+ row.value = row.type === 'number' ? Number(input.value) : input.value;
816
+ });
817
+ input.addEventListener('paste', function(e) { _handlePaste(e, row); }); // Phase 3: no depth/isArray
818
+ cell.appendChild(input);
819
+ return cell;
820
+ }
821
+
822
+ function _makeAddBtn(rows, container, depth, isArray) {
823
+ var btn = document.createElement('button');
824
+ btn.type = 'button';
825
+ btn.className = 'je-add-row-btn';
826
+ btn.style.marginLeft = (depth * 20) + 'px';
827
+ btn.innerHTML =
828
+ '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add row';
829
+ btn.addEventListener('click', function() {
830
+ _addRow(rows, container, depth, isArray);
831
+ });
832
+ return btn;
833
+ }
834
+
835
+ // ── Row operations ─────────────────────────────────────────────────────────
836
+
837
+ /**
838
+ * Toggle collapse on an object/array row.
839
+ * Phase 3: uses row._depth stored on the row.
840
+ * Collapsing detaches the children wrapper from the DOM but keeps the reference.
841
+ * Expanding re-attaches it (or builds it fresh if first expand).
842
+ */
843
+ function _toggleCollapse(row) {
844
+ row.collapsed = !row.collapsed;
845
+
846
+ if (row.collapsed) {
847
+ // First recursively detach any expanded descendants so their DOM
848
+ // doesn't linger when the parent wrapper is removed
849
+ _detachSubtree(row);
850
+ if (row._childrenEl && row._childrenEl.parentNode) {
851
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
852
+ }
853
+ } else {
854
+ if (!row.children) row.children = [];
855
+ _mountChildrenEl(row);
856
+ }
857
+
858
+ _patchRow(row);
859
+ _updateCount();
860
+ }
861
+
862
+ /**
863
+ * Recursively detach all descendant children wrappers from the DOM.
864
+ * Used when collapsing a parent that has expanded children beneath it.
865
+ * Logical collapsed state of descendants is NOT changed — only DOM visibility.
866
+ */
867
+ function _detachSubtree(row) {
868
+ if (!row.children) return;
869
+ row.children.forEach(function(child) {
870
+ _detachSubtree(child);
871
+ if (child._childrenEl && child._childrenEl.parentNode) {
872
+ child._childrenEl.parentNode.removeChild(child._childrenEl);
873
+ }
874
+ });
875
+ }
876
+
877
+ /**
878
+ * Change the type of a row.
879
+ * Phase 4: if the row has children and the user is switching to a primitive,
880
+ * show a confirmation with two options:
881
+ * - Drop children (destructive, was the old silent behaviour)
882
+ * - Serialize to string (saves the nested value as a JSON string)
883
+ * If there are no children, or the user is switching between object/array, proceed immediately.
884
+ */
885
+ function _changeType(row, newType) {
886
+ var oldType = row.type;
887
+
888
+ // Switching between two container types — just update flag and re-render, no data loss
889
+ if ((oldType === 'object' || oldType === 'array') &&
890
+ (newType === 'object' || newType === 'array')) {
891
+ _snapshot(); // Phase 6
892
+ row.type = newType;
893
+ row.isArrayContainer = (newType === 'array');
894
+ // Detach children DOM and rebuild (structure changed)
895
+ if (row._childrenEl && row._childrenEl.parentNode) {
896
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
897
+ row._childrenEl = null;
898
+ }
899
+ row.collapsed = true;
900
+ _patchRow(row);
901
+ _updateCount();
902
+ return;
903
+ }
904
+
905
+ // Switching from a container to a primitive — children would be lost
906
+ if ((oldType === 'object' || oldType === 'array') &&
907
+ row.children && row.children.length > 0) {
908
+
909
+ _showTypeWarning(row, newType);
910
+ // Revert the <select> visually until the user confirms
911
+ var typeSelect = row._el && row._el.querySelector('.je-type-select');
912
+ if (typeSelect) typeSelect.value = oldType;
913
+ return;
914
+ }
915
+
916
+ // Switching from primitive to container — safe, no data loss
917
+ if (newType === 'object' || newType === 'array') {
918
+ _snapshot(); // Phase 6
919
+ row.type = newType;
920
+ if (!row.children) row.children = [];
921
+ row.isArrayContainer = (newType === 'array');
922
+ row.collapsed = true;
923
+ row.value = null;
924
+ if (row._childrenEl && row._childrenEl.parentNode) {
925
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
926
+ }
927
+ _patchRow(row);
928
+ var toggleEl = row._el && row._el.querySelector('.je-toggle');
929
+ if (toggleEl) {
930
+ toggleEl.classList.add('je-toggle-active');
931
+ toggleEl.addEventListener('click', function() { _toggleCollapse(row); });
932
+ }
933
+ _updateCount();
934
+ return;
935
+ }
936
+
937
+ // Primitive → different primitive — just update
938
+ _snapshot(); // Phase 6
939
+ row.type = newType;
940
+ row.value = _defaultValue(newType);
941
+ _patchRow(row);
942
+ _updateCount();
943
+ }
944
+
945
+ /**
946
+ * Show a non-blocking inline warning banner under the row when the user tries
947
+ * to switch a container type to a primitive.
948
+ * Offers two actions: Drop children | Serialize as string
949
+ */
950
+ function _showTypeWarning(row, targetType) {
951
+ // Remove any existing warning for this row
952
+ _clearTypeWarning(row);
953
+
954
+ var banner = document.createElement('div');
955
+ banner.className = 'je-type-warn';
956
+ banner.dataset.rowId = row.id;
957
+
958
+ var icon = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
959
+ var childCount = row.children ? row.children.length : 0;
960
+ var noun = childCount === 1 ? '1 child' : childCount + ' children';
961
+
962
+ var msg = document.createElement('span');
963
+ msg.className = 'je-type-warn-msg';
964
+ msg.innerHTML = icon + ' Switching to <strong>' + targetType + '</strong> will remove ' + noun + '.';
965
+ banner.appendChild(msg);
966
+
967
+ var actions = document.createElement('span');
968
+ actions.className = 'je-type-warn-actions';
969
+
970
+ // Option A: Drop children
971
+ var dropBtn = document.createElement('button');
972
+ dropBtn.type = 'button';
973
+ dropBtn.className = 'je-type-warn-btn je-type-warn-drop';
974
+ dropBtn.textContent = 'Drop children';
975
+ dropBtn.addEventListener('click', function() {
976
+ _snapshot(); // Phase 6
977
+ _clearTypeWarning(row);
978
+ row.type = targetType;
979
+ row.children = null;
980
+ row.value = _defaultValue(targetType);
981
+ if (row._childrenEl && row._childrenEl.parentNode) {
982
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
983
+ row._childrenEl = null;
984
+ }
985
+ // Update the select to the confirmed type
986
+ var typeSelect = row._el && row._el.querySelector('.je-type-select');
987
+ if (typeSelect) typeSelect.value = targetType;
988
+ _patchRow(row);
989
+ _updateCount();
990
+ });
991
+
992
+ // Option B: Serialize as JSON string
993
+ var serBtn = document.createElement('button');
994
+ serBtn.type = 'button';
995
+ serBtn.className = 'je-type-warn-btn je-type-warn-ser';
996
+ serBtn.textContent = 'Keep as JSON string';
997
+ serBtn.addEventListener('click', function() {
998
+ _snapshot(); // Phase 6
999
+ _clearTypeWarning(row);
1000
+ var serialized = JSON.stringify(_rowsToValue(row.children, row.isArrayContainer));
1001
+ row.type = 'string';
1002
+ row.children = null;
1003
+ row.value = serialized;
1004
+ if (row._childrenEl && row._childrenEl.parentNode) {
1005
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
1006
+ row._childrenEl = null;
1007
+ }
1008
+ var typeSelect = row._el && row._el.querySelector('.je-type-select');
1009
+ if (typeSelect) typeSelect.value = 'string';
1010
+ _patchRow(row);
1011
+ _updateCount();
1012
+ });
1013
+
1014
+ // Cancel
1015
+ var cancelBtn = document.createElement('button');
1016
+ cancelBtn.type = 'button';
1017
+ cancelBtn.className = 'je-type-warn-btn je-type-warn-cancel';
1018
+ cancelBtn.textContent = 'Cancel';
1019
+ cancelBtn.addEventListener('click', function() { _clearTypeWarning(row); });
1020
+
1021
+ actions.appendChild(dropBtn);
1022
+ actions.appendChild(serBtn);
1023
+ actions.appendChild(cancelBtn);
1024
+ banner.appendChild(actions);
1025
+
1026
+ // Insert banner immediately after the row element
1027
+ if (row._el && row._el.parentNode) {
1028
+ var next = row._childrenEl || row._el.nextSibling;
1029
+ row._el.parentNode.insertBefore(banner, next);
1030
+ row._warnEl = banner;
1031
+ }
1032
+ }
1033
+
1034
+ function _clearTypeWarning(row) {
1035
+ if (row._warnEl && row._warnEl.parentNode) {
1036
+ row._warnEl.parentNode.removeChild(row._warnEl);
1037
+ row._warnEl = null;
1038
+ }
1039
+ }
1040
+
1041
+ // ── Phase 7: duplicate key detection ──────────────────────────────────────
1042
+
1043
+ /**
1044
+ * Highlight a key input red if its key is a duplicate within its parent object.
1045
+ * Clears the highlight if the key is unique or blank.
1046
+ */
1047
+ function _checkDupKey(row, keyInputEl) {
1048
+ var parentRows = _findParentRows(row, _tree);
1049
+ if (!parentRows) return;
1050
+ var key = row.key;
1051
+ if (!key) {
1052
+ keyInputEl.classList.remove('je-dup-key');
1053
+ keyInputEl.title = '';
1054
+ return;
1055
+ }
1056
+ var count = parentRows.filter(function(r) { return r !== row && r.key === key; }).length;
1057
+ if (count > 0) {
1058
+ keyInputEl.classList.add('je-dup-key');
1059
+ keyInputEl.title = 'Duplicate key — will overwrite on save';
1060
+ } else {
1061
+ keyInputEl.classList.remove('je-dup-key');
1062
+ keyInputEl.title = '';
1063
+ }
1064
+ }
1065
+
1066
+ // ── Phase 5: Drag to Reorder ───────────────────────────────────────────────
1067
+
1068
+ /**
1069
+ * Move draggedRow before or after targetRow within their shared parent array.
1070
+ * Splices the data array, then moves the DOM node — no full re-render needed.
1071
+ */
1072
+ function _reorderRow(draggedRow, targetRow, insertBefore, parentRows) {
1073
+ _snapshot(); // Phase 6
1074
+ var fromIdx = parentRows.indexOf(draggedRow);
1075
+ var toIdx = parentRows.indexOf(targetRow);
1076
+ if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
1077
+
1078
+ // Splice data array
1079
+ parentRows.splice(fromIdx, 1);
1080
+ var newIdx = parentRows.indexOf(targetRow);
1081
+ if (insertBefore) {
1082
+ parentRows.splice(newIdx, 0, draggedRow);
1083
+ } else {
1084
+ parentRows.splice(newIdx + 1, 0, draggedRow);
1085
+ }
1086
+
1087
+ // Move DOM node — no re-render, just reposition
1088
+ var container = targetRow._el.parentNode;
1089
+ if (!container) return;
1090
+
1091
+ if (insertBefore) {
1092
+ container.insertBefore(draggedRow._el, targetRow._el);
1093
+ // Also move children wrapper if expanded
1094
+ if (draggedRow._childrenEl) {
1095
+ container.insertBefore(draggedRow._childrenEl, targetRow._el);
1096
+ }
1097
+ } else {
1098
+ // Insert after targetRow (and its children if expanded)
1099
+ var anchor = (targetRow._childrenEl && targetRow._childrenEl.parentNode)
1100
+ ? targetRow._childrenEl.nextSibling
1101
+ : targetRow._el.nextSibling;
1102
+ container.insertBefore(draggedRow._el, anchor);
1103
+ if (draggedRow._childrenEl) {
1104
+ container.insertBefore(draggedRow._childrenEl, draggedRow._el.nextSibling);
1105
+ }
1106
+ }
1107
+
1108
+ // Refresh index badges in the whole container (array items only)
1109
+ _refreshIndexBadges(parentRows);
1110
+ }
1111
+
1112
+ /**
1113
+ * Show or reposition the 2px blue drop indicator line.
1114
+ * Inserts it before or after the target row element.
1115
+ */
1116
+ function _showDragIndicator(targetEl, before) {
1117
+ if (!_dragIndicator) {
1118
+ _dragIndicator = document.createElement('div');
1119
+ _dragIndicator.className = 'je-drop-indicator';
1120
+ }
1121
+ var parent = targetEl.parentNode;
1122
+ if (!parent) return;
1123
+ if (before) {
1124
+ parent.insertBefore(_dragIndicator, targetEl);
1125
+ } else {
1126
+ parent.insertBefore(_dragIndicator, targetEl.nextSibling);
1127
+ }
1128
+ }
1129
+
1130
+ function _removeDragIndicator() {
1131
+ if (_dragIndicator && _dragIndicator.parentNode) {
1132
+ _dragIndicator.parentNode.removeChild(_dragIndicator);
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * After a reorder, refresh all index badges in a rows array.
1138
+ * Only does anything if the parent is an array container.
1139
+ */
1140
+ function _refreshIndexBadges(rows) {
1141
+ rows.forEach(function(row, i) {
1142
+ if (!row._el) return;
1143
+ var badge = row._el.querySelector('.je-index-badge');
1144
+ if (badge) badge.textContent = String(i);
1145
+ });
1146
+ }
1147
+
1148
+ /**
1149
+ * Add a new empty row to a rows array.
1150
+ * Phase 3: stamps row._depth and row._isArray at add time.
1151
+ */
1152
+ function _addRow(rows, container, depth, isArray) {
1153
+ _snapshot(); // Phase 6
1154
+ var row = makeRow('', 'string', '', null);
1155
+ row._depth = depth;
1156
+ row._isArray = isArray;
1157
+ rows.push(row);
1158
+
1159
+ var addBtn = container.querySelector('.je-add-row-btn--container');
1160
+ var rowEl = _buildRowEl(row, depth, isArray);
1161
+ row._el = rowEl;
1162
+ if (addBtn) {
1163
+ container.insertBefore(rowEl, addBtn);
1164
+ } else {
1165
+ container.appendChild(rowEl);
1166
+ }
1167
+
1168
+ _updateCount();
1169
+
1170
+ setTimeout(function() {
1171
+ var keyInput = rowEl.querySelector('.je-key-input');
1172
+ if (keyInput) keyInput.focus();
1173
+ else {
1174
+ var valInput = rowEl.querySelector('.je-val-input');
1175
+ if (valInput) valInput.focus();
1176
+ }
1177
+ }, 30);
1178
+ }
1179
+
1180
+ /**
1181
+ * Delete a row. Removes from its parent array and from the DOM.
1182
+ * Finds the parent array by walking _tree.
1183
+ */
1184
+ function _deleteRow(row) {
1185
+ _snapshot(); // Phase 6
1186
+ _clearTypeWarning(row);
1187
+ var parentRows = _findParentRows(row, _tree);
1188
+ if (parentRows) {
1189
+ var idx = parentRows.indexOf(row);
1190
+ if (idx > -1) parentRows.splice(idx, 1);
1191
+ }
1192
+ _unmountRow(row);
1193
+ _updateCount();
1194
+ }
1195
+
1196
+ function _findParentRows(row, rows) {
1197
+ if (rows.indexOf(row) > -1) return rows;
1198
+ for (var i = 0; i < rows.length; i++) {
1199
+ if (rows[i].children) {
1200
+ var found = _findParentRows(row, rows[i].children);
1201
+ if (found) return found;
1202
+ }
1203
+ }
1204
+ return null;
1205
+ }
1206
+
1207
+ // ── Paste detection ────────────────────────────────────────────────────────
1208
+
1209
+ function _handlePaste(e, row) {
1210
+ var text = (e.originalEvent
1211
+ ? e.originalEvent.clipboardData
1212
+ : e.clipboardData
1213
+ ).getData('text');
1214
+
1215
+ var trimmed = text.trim();
1216
+ if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return;
1217
+
1218
+ try {
1219
+ var parsed = JSON.parse(trimmed);
1220
+ e.preventDefault();
1221
+ _snapshot(); // Phase 6
1222
+
1223
+ if (row && (row.type === 'object' || row.type === 'array') && row._depth > 0) {
1224
+ row.isArrayContainer = Array.isArray(parsed);
1225
+ row.children = _objToRows(parsed);
1226
+ row.collapsed = false;
1227
+ if (row._childrenEl && row._childrenEl.parentNode) {
1228
+ row._childrenEl.parentNode.removeChild(row._childrenEl);
1229
+ row._childrenEl = null;
1230
+ }
1231
+ _mountChildrenEl(row);
1232
+ _patchRow(row);
1233
+ } else {
1234
+ _rootType = Array.isArray(parsed) ? 'array' : 'object';
1235
+ _tree = _objToRows(parsed);
1236
+ _renderRootTypeToggle();
1237
+ _fullRender();
1238
+ }
1239
+ _updateCount();
1240
+ } catch(err) {
1241
+ // Not valid JSON — let browser handle
1242
+ }
1243
+ }
1244
+
1245
+ // ── Conversion helpers ─────────────────────────────────────────────────────
1246
+
1247
+ function _objToRows(obj) {
1248
+ if (Array.isArray(obj)) {
1249
+ return obj.map(function(v) { return _valueToRow('', v); });
1250
+ }
1251
+ if (obj && typeof obj === 'object') {
1252
+ return Object.keys(obj).map(function(k) { return _valueToRow(k, obj[k]); });
1253
+ }
1254
+ return [];
1255
+ }
1256
+
1257
+ function _valueToRow(key, value) {
1258
+ var type = _typeOf(value);
1259
+ // Phase 2: set isArrayContainer from the actual JS type
1260
+ var isArrayContainer = (type === 'array');
1261
+ var row = makeRow(String(key), type, null, null, isArrayContainer);
1262
+ if (type === 'object' || type === 'array') {
1263
+ row.children = _objToRows(value);
1264
+ } else {
1265
+ row.value = value;
1266
+ }
1267
+ return row;
1268
+ }
1269
+
1270
+ // ── Serialisation — Phase 2: no more heuristic array detection ────────────
1271
+
1272
+ // Top-level serialise: uses _rootType
1273
+ function _serialise() {
1274
+ return _rowsToValue(_tree, _rootType === 'array');
1275
+ }
1276
+
1277
+ // Serialise a rows array into a JS value.
1278
+ // isArrayContainer comes from the parent row's isArrayContainer flag,
1279
+ // or from _rootType at the top level.
1280
+ function _rowsToValue(rows, isArrayContainer) {
1281
+ if (isArrayContainer) {
1282
+ return rows.map(function(r) { return _rowToValue(r); });
1283
+ }
1284
+ var obj = {};
1285
+ rows.forEach(function(r) {
1286
+ if (r.key === '') return; // skip blank-keyed rows in objects
1287
+ obj[r.key] = _rowToValue(r);
1288
+ });
1289
+ return obj;
1290
+ }
1291
+
1292
+ function _rowToValue(row) {
1293
+ if (row.type === 'null') return null;
1294
+ if (row.type === 'boolean') return row.value === true || row.value === 'true';
1295
+ if (row.type === 'number') return Number(row.value);
1296
+ if (row.type === 'object' || row.type === 'array') {
1297
+ return row.children
1298
+ ? _rowsToValue(row.children, row.isArrayContainer)
1299
+ : (row.isArrayContainer ? [] : {});
1300
+ }
1301
+ return row.value === null || row.value === undefined ? '' : String(row.value);
1302
+ }
1303
+
1304
+ // Keep _rowsToObj as a thin alias so any external callers don't break
1305
+ function _rowsToObj(rows) { return _rowsToValue(rows, false); }
1306
+
1307
+ function _typeOf(v) {
1308
+ if (v === null) return 'null';
1309
+ if (Array.isArray(v)) return 'array';
1310
+ if (typeof v === 'object') return 'object';
1311
+ if (typeof v === 'boolean') return 'boolean';
1312
+ if (typeof v === 'number') return 'number';
1313
+ return 'string';
1314
+ }
1315
+
1316
+ function _defaultValue(type) {
1317
+ if (type === 'string') return '';
1318
+ if (type === 'number') return 0;
1319
+ if (type === 'boolean') return false;
1320
+ return null;
1321
+ }
1322
+
1323
+ // ── Count + hints ──────────────────────────────────────────────────────────
1324
+
1325
+ function _updateCount() {
1326
+ var total = _countRows(_tree);
1327
+ var el = document.getElementById('je-count');
1328
+ if (el) el.textContent = total + ' entr' + (total === 1 ? 'y' : 'ies');
1329
+ }
1330
+
1331
+ function _countRows(rows) {
1332
+ return rows.reduce(function(n, r) {
1333
+ return n + 1 + (r.children ? _countRows(r.children) : 0);
1334
+ }, 0);
1335
+ }
1336
+
1337
+ function _showRawHint(msg, isError) {
1338
+ var el = document.getElementById('je-raw-hint');
1339
+ if (!el) return;
1340
+ el.textContent = msg;
1341
+ el.classList.toggle('je-raw-hint-error', !!isError);
1342
+ }
1343
+
1344
+ // ── Inline preview ─────────────────────────────────────────────────────────
1345
+
1346
+ function _renderPreview(fieldName, obj) {
1347
+ var wrap = document.getElementById('jep-' + fieldName);
1348
+ if (!wrap) return;
1349
+ wrap.innerHTML = '';
1350
+
1351
+ if (!obj || typeof obj !== 'object') {
1352
+ var ph = document.createElement('span');
1353
+ ph.className = 'je-preview-placeholder';
1354
+ ph.textContent = String(obj);
1355
+ wrap.appendChild(ph);
1356
+ return;
1357
+ }
1358
+
1359
+ var keys = Object.keys(obj);
1360
+ var visible = keys.slice(0, PREVIEW_ROWS);
1361
+ var rest = keys.length - visible.length;
1362
+
1363
+ visible.forEach(function(k) {
1364
+ var v = obj[k];
1365
+ var type = _typeOf(v);
1366
+ var disp = type === 'object' ? '{…}' : type === 'array' ? '[…]' : type === 'null' ? 'null' : String(v);
1367
+
1368
+ var chip = document.createElement('span'); chip.className = 'je-chip';
1369
+ var keyEl = document.createElement('span'); keyEl.className = 'je-chip-key'; keyEl.textContent = k;
1370
+ var sepEl = document.createElement('span'); sepEl.className = 'je-chip-sep'; sepEl.textContent = ': ';
1371
+ var valEl = document.createElement('span'); valEl.className = 'je-chip-val'; valEl.textContent = disp;
1372
+ valEl.style.color = TYPE_COLORS[type] || '';
1373
+ chip.appendChild(keyEl); chip.appendChild(sepEl); chip.appendChild(valEl);
1374
+ wrap.appendChild(chip);
1375
+ });
1376
+
1377
+ if (rest > 0) {
1378
+ var more = document.createElement('span');
1379
+ more.className = 'je-chip-more';
1380
+ more.textContent = '+' + rest + ' more';
1381
+ wrap.appendChild(more);
1382
+ }
1383
+ if (keys.length === 0) {
1384
+ var empty = document.createElement('span');
1385
+ empty.className = 'je-preview-placeholder';
1386
+ empty.textContent = '{} (empty)';
1387
+ wrap.appendChild(empty);
1388
+ }
1389
+ }
1390
+
1391
+ // ── Init ───────────────────────────────────────────────────────────────────
1392
+
1393
+ $(function() {
1394
+ var tpl = document.getElementById('je-dialog-content');
1395
+ if (!tpl) return;
1396
+ _template = tpl.cloneNode(true);
1397
+ tpl.parentNode.removeChild(tpl);
1398
+
1399
+ // Render inline previews for any pre-populated JSON fields
1400
+ document.querySelectorAll('[id^="jet-"]').forEach(function(el) {
1401
+ var fieldName = el.dataset.field;
1402
+ var hidden = document.getElementById('field-' + fieldName);
1403
+ if (!hidden || !hidden.value) return;
1404
+ try {
1405
+ _renderPreview(fieldName, JSON.parse(hidden.value));
1406
+ } catch(e) {
1407
+ var wrap = document.getElementById('jep-' + fieldName);
1408
+ if (wrap) wrap.innerHTML = '<span class="je-preview-placeholder je-preview-error">Invalid JSON</span>';
1409
+ }
1410
+ });
1411
+ });
1412
+
1413
+ // ── Export ─────────────────────────────────────────────────────────────────
1414
+
1415
+ root.JsonEditor = {
1416
+ open: open,
1417
+ _apply: _apply,
1418
+ _cancel: _cancel,
1419
+ _setView: _setView,
1420
+ _format: _format,
1421
+ _undo: _undo, // Phase 6
1422
+ _redo: _redo, // Phase 6
1423
+ _addRootRow: function() {
1424
+ var container = document.getElementById('je-tree');
1425
+ if (container) _addRow(_tree, container, 0, _rootType === 'array');
1426
+ },
1427
+ };
1428
+
1429
+ }(window));