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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- 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));
|