1057 lines
18 KiB
HTML
1057 lines
18 KiB
HTML
<!doctype html>
|
||
|
||
<html lang="en">
|
||
|
||
<head>
|
||
|
||
<meta charset="utf-8" />
|
||
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
|
||
<title>JSON Editor – Upload • Edit • Download</title>
|
||
|
||
<style>
|
||
|
||
:root{
|
||
|
||
--bg:#0b0f14; --panel:#121922; --muted:#1a2430; --text:#e6edf3; --sub:#9fb0c3;
|
||
|
||
--accent:#4aa3ff; --accent-2:#22c55e; --warn:#f59e0b; --err:#ef4444; --border:#243241;
|
||
|
||
}
|
||
|
||
*{box-sizing:border-box}
|
||
|
||
body{margin:0; font:14px/1.4 system-ui,Segoe UI,Roboto,Ubuntu,Arial,sans-serif; background:var(--bg); color:var(--text);}
|
||
|
||
header{display:flex; gap:.75rem; align-items:center; padding:12px 14px; border-bottom:1px solid var(--border); background:linear-gradient(180deg,var(--panel),#0f1520);}
|
||
|
||
header h1{font-size:16px; margin:0; font-weight:600; letter-spacing:.2px}
|
||
|
||
header .spacer{flex:1}
|
||
|
||
button,input[type=file],.btn{border:1px solid var(--border); background:var(--muted); color:var(--text); border-radius:10px; padding:8px 12px; cursor:pointer}
|
||
|
||
button:hover,.btn:hover{background:#223046}
|
||
|
||
button.primary{background:var(--accent); color:#06121f; border-color:#2d7cd6; font-weight:600}
|
||
|
||
button.success{background:var(--accent-2); color:#04120a; border-color:#18a24c; font-weight:600}
|
||
|
||
button.warn{background:var(--warn); color:#1d1402; border-color:#b7791f; font-weight:600}
|
||
|
||
button.danger{background:var(--err); color:#1c0303; border-color:#b91c1c; font-weight:600}
|
||
|
||
.wrap{display:grid; grid-template-rows:auto 1fr; height:calc(100dvh - 0px);}
|
||
|
||
.toolbar{display:flex; flex-wrap:wrap; gap:.5rem; padding:10px 14px; background:#0f1622; border-bottom:1px solid var(--border)}
|
||
|
||
.toolbar .group{display:flex; gap:.5rem; align-items:center}
|
||
|
||
.filelabel{padding:8px 12px; border:1px dashed var(--border); border-radius:10px; background:var(--panel)}
|
||
|
||
.tabs{display:flex; gap:8px; padding:8px 14px; border-bottom:1px solid var(--border); background:#0e1520}
|
||
|
||
.tab{padding:8px 12px; border-radius:8px; cursor:pointer; color:var(--sub); border:1px solid transparent}
|
||
|
||
.tab.active{color:var(--text); border-color:var(--border); background:var(--muted)}
|
||
|
||
.panes{height:100%;}
|
||
|
||
.pane{display:none; height:calc(100dvh - 180px);} /* adjusted for header+toolbars */
|
||
|
||
.pane.active{display:block}
|
||
|
||
.row{display:grid; grid-template-columns: 1fr 1fr; gap:12px; height:100%}
|
||
|
||
.col{height:100%; overflow:auto; background:var(--panel); border-top:1px solid var(--border)}
|
||
|
||
.col h3{margin:0; padding:10px 12px; border-bottom:1px solid var(--border); font-size:13px; letter-spacing:.25px; color:var(--sub); position:sticky; top:0; background:var(--panel)}
|
||
|
||
textarea#raw{width:100%; height:100%; border:none; outline:none; background:#0b111a; color:var(--text); padding:12px; font:12px/1.5 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}
|
||
|
||
.dropzone{border:2px dashed var(--border); margin:12px; padding:20px; border-radius:12px; text-align:center; color:var(--sub)}
|
||
|
||
.dropzone.drag{border-color:var(--accent); color:var(--accent)}
|
||
|
||
|
||
|
||
|
||
/* Tree editor */
|
||
|
||
.tree{padding:8px 10px}
|
||
|
||
.node{display:flex; align-items:center; gap:8px; padding:4px 6px; border-left:2px solid transparent}
|
||
|
||
.node:hover{background:#0e1520; border-left-color:#233347}
|
||
|
||
.indent{width:16px}
|
||
|
||
.key{width:220px; max-width:35vw}
|
||
|
||
.val{flex:1}
|
||
|
||
.key input,.val input,.val select{width:100%; padding:6px 8px; border-radius:8px; border:1px solid var(--border); background:#0b111a; color:var(--text); font:12px ui-monospace,Consolas,monospace}
|
||
|
||
.val input.invalid{border-color:var(--err); outline:2px solid rgba(239,68,68,.25)}
|
||
|
||
.pill{font-size:11px; padding:2px 6px; border-radius:999px; border:1px solid var(--border); background:#0b111a; color:var(--sub)}
|
||
|
||
.controls{display:flex; gap:6px}
|
||
|
||
.controls button{padding:4px 8px; font-size:12px; border-radius:8px}
|
||
|
||
.help{color:var(--sub); padding:8px 12px; font-size:12px}
|
||
|
||
.status{padding:6px 10px; font-size:12px}
|
||
|
||
.status.good{color:var(--accent-2)}
|
||
|
||
.status.bad{color:var(--err)}
|
||
|
||
.search{margin-left:auto; display:flex; gap:6px; align-items:center}
|
||
|
||
.search input{padding:6px 8px; border-radius:8px; border:1px solid var(--border); background:#0b111a; color:var(--text)}
|
||
|
||
</style>
|
||
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<header>
|
||
|
||
<h1>JSON Editor</h1>
|
||
|
||
<div class="spacer"></div>
|
||
|
||
<button id="downloadBtn" class="primary" title="Download edited JSON (Ctrl/Cmd + S)">Download</button>
|
||
|
||
</header>
|
||
|
||
|
||
|
||
|
||
<div class="wrap">
|
||
|
||
<div class="toolbar">
|
||
|
||
<div class="group">
|
||
|
||
<label class="filelabel" for="file">Choose JSON…</label>
|
||
|
||
<input id="file" type="file" accept="application/json,.json,.txt" hidden>
|
||
|
||
<button id="pasteBtn">Paste</button>
|
||
|
||
<button id="sampleBtn">Load Sample</button>
|
||
|
||
<button id="formatBtn">Format</button>
|
||
|
||
<button id="minifyBtn">Minify</button>
|
||
|
||
<button id="clearBtn">Clear</button>
|
||
|
||
</div>
|
||
|
||
<div class="group search">
|
||
|
||
<input id="searchBox" placeholder="Find key or value… (Enter)" />
|
||
|
||
<span id="matchCount" class="pill">0 matches</span>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<div class="tabs">
|
||
|
||
<div class="tab active" data-tab="tree">Tree Editor</div>
|
||
|
||
<div class="tab" data-tab="raw">Raw JSON</div>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<div class="panes">
|
||
|
||
<div class="pane active" id="pane-tree">
|
||
|
||
<div class="row" style="grid-template-columns:1fr;">
|
||
|
||
<div class="col">
|
||
|
||
<h3>Tree • <span id="status" class="status">No document</span></h3>
|
||
|
||
<div id="drop" class="dropzone">Drop a .json file here</div>
|
||
|
||
<div class="help">Tips: Double‑click a key to rename. Change the type dropdown to convert values. Use + to add, − to remove. Arrays use numeric indices. Root can be Object or Array via the type dropdown at the top.</div>
|
||
|
||
<div id="tree" class="tree"></div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<div class="pane" id="pane-raw">
|
||
|
||
<div class="row" style="grid-template-columns:1fr;">
|
||
|
||
<div class="col">
|
||
|
||
<h3>Raw JSON <span id="rawStatus" class="status"></span></h3>
|
||
|
||
<textarea id="raw" spellcheck="false" placeholder="Paste or type JSON here…"></textarea>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
|
||
|
||
|
||
|
||
<script>
|
||
|
||
(function(){
|
||
|
||
'use strict';
|
||
|
||
|
||
|
||
|
||
// ---- State ----
|
||
|
||
let data = null; // current JSON object/array
|
||
|
||
let filename = 'edited.json';
|
||
|
||
let lastSearch = '';
|
||
|
||
|
||
|
||
|
||
// ---- DOM ----
|
||
|
||
const $ = sel => document.querySelector(sel);
|
||
|
||
const $$ = sel => Array.from(document.querySelectorAll(sel));
|
||
|
||
const fileInput = $('#file');
|
||
|
||
const downloadBtn = $('#downloadBtn');
|
||
|
||
const pasteBtn = $('#pasteBtn');
|
||
|
||
const formatBtn = $('#formatBtn');
|
||
|
||
const minifyBtn = $('#minifyBtn');
|
||
|
||
const clearBtn = $('#clearBtn');
|
||
|
||
const sampleBtn = $('#sampleBtn');
|
||
|
||
const statusEl = $('#status');
|
||
|
||
const rawStatusEl = $('#rawStatus');
|
||
|
||
const rawTA = $('#raw');
|
||
|
||
const drop = $('#drop');
|
||
|
||
const treeEl = $('#tree');
|
||
|
||
const searchBox = $('#searchBox');
|
||
|
||
const matchCount = $('#matchCount');
|
||
|
||
|
||
|
||
|
||
// ---- Tabs ----
|
||
|
||
$$('.tab').forEach(tab => tab.addEventListener('click', () => {
|
||
|
||
$$('.tab').forEach(t=>t.classList.remove('active'));
|
||
|
||
tab.classList.add('active');
|
||
|
||
const target = tab.dataset.tab;
|
||
|
||
$$('.pane').forEach(p=>p.classList.remove('active'));
|
||
|
||
$('#pane-'+target).classList.add('active');
|
||
|
||
if (target === 'raw') syncRawFromData();
|
||
|
||
if (target === 'tree') syncDataFromRaw(false);
|
||
|
||
}));
|
||
|
||
|
||
|
||
|
||
// ---- File handling ----
|
||
|
||
fileInput.addEventListener('change', async (e) => {
|
||
|
||
if (!e.target.files.length) return;
|
||
|
||
const f = e.target.files[0];
|
||
|
||
filename = f.name.replace(/\.[^.]+$/, '') + '.json';
|
||
|
||
const text = await f.text();
|
||
|
||
loadJSONText(text);
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
// Drag & Drop
|
||
|
||
;['dragenter','dragover'].forEach(ev=>drop.addEventListener(ev, e=>{e.preventDefault(); drop.classList.add('drag');}))
|
||
|
||
;['dragleave','drop'].forEach(ev=>drop.addEventListener(ev, e=>{e.preventDefault(); drop.classList.remove('drag');}))
|
||
|
||
drop.addEventListener('drop', async (e)=>{
|
||
|
||
const f = e.dataTransfer.files?.[0];
|
||
|
||
if (!f) return;
|
||
|
||
filename = f.name.replace(/\.[^.]+$/, '') + '.json';
|
||
|
||
const text = await f.text();
|
||
|
||
loadJSONText(text);
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
function loadJSONText(text){
|
||
|
||
try{
|
||
|
||
const parsed = JSON.parse(text);
|
||
|
||
data = parsed;
|
||
|
||
status(`Loaded ${typeof data === 'object' && data ? (Array.isArray(data)?'Array':'Object') : typeof data}`);
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
}catch(err){
|
||
|
||
alert('Invalid JSON: '+ err.message);
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---- Toolbar actions ----
|
||
|
||
pasteBtn.addEventListener('click', async ()=>{
|
||
|
||
try{
|
||
|
||
const text = await navigator.clipboard.readText();
|
||
|
||
if (!text) return alert('Clipboard is empty.');
|
||
|
||
loadJSONText(text);
|
||
|
||
}catch{ alert('Clipboard access denied. Paste into Raw JSON tab instead.'); }
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
sampleBtn.addEventListener('click', ()=>{
|
||
|
||
const sample = {
|
||
|
||
name:"Sample Document",
|
||
|
||
version:1,
|
||
|
||
tags:["example","json","editor"],
|
||
|
||
features:{
|
||
|
||
upload:true,
|
||
|
||
edit:true,
|
||
|
||
download:true,
|
||
|
||
nested:{ ok:true, count:3 }
|
||
|
||
},
|
||
|
||
items:[ {id:1, title:"First", active:true}, {id:2, title:"Second", active:false} ]
|
||
|
||
};
|
||
|
||
data = sample;
|
||
|
||
filename = 'sample.json';
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
status('Sample loaded');
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
formatBtn.addEventListener('click', ()=>{ if (!rawTA.value.trim()) syncRawFromData(); try{ const o=JSON.parse(rawTA.value); rawTA.value = JSON.stringify(o, null, 2); rawStatus('Formatted ✓', true);}catch(e){ rawStatus('Invalid JSON: '+e.message, false);} });
|
||
|
||
minifyBtn.addEventListener('click', ()=>{ if (!rawTA.value.trim()) syncRawFromData(); try{ const o=JSON.parse(rawTA.value); rawTA.value = JSON.stringify(o); rawStatus('Minified ✓', true);}catch(e){ rawStatus('Invalid JSON: '+e.message, false);} });
|
||
|
||
clearBtn.addEventListener('click', ()=>{ data = null; filename='edited.json'; treeEl.innerHTML=''; rawTA.value=''; status('No document'); rawStatus(''); matchCount.textContent='0 matches'; });
|
||
|
||
|
||
|
||
|
||
// Download
|
||
|
||
downloadBtn.addEventListener('click', ()=>{
|
||
|
||
if ($('#pane-raw').classList.contains('active')){
|
||
|
||
if (!syncDataFromRaw(true)) return; // invalid
|
||
|
||
}
|
||
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], {type:'application/json'});
|
||
|
||
const a = document.createElement('a');
|
||
|
||
a.href = URL.createObjectURL(blob);
|
||
|
||
a.download = filename || 'edited.json';
|
||
|
||
a.click();
|
||
|
||
setTimeout(()=>URL.revokeObjectURL(a.href), 1500);
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
// Save shortcut
|
||
|
||
window.addEventListener('keydown', (e)=>{
|
||
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase()==='s'){
|
||
|
||
e.preventDefault();
|
||
|
||
downloadBtn.click();
|
||
|
||
}
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
// ---- Search ----
|
||
|
||
searchBox.addEventListener('keydown', (e)=>{
|
||
|
||
if (e.key==='Enter') doSearch(searchBox.value.trim());
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
function doSearch(q){
|
||
|
||
lastSearch = q;
|
||
|
||
let matches = 0;
|
||
|
||
$$('.node').forEach(n=>{
|
||
|
||
n.style.outline = 'none';
|
||
|
||
const hay = (n.dataset.key+' '+n.dataset.type+' '+n.dataset.value).toLowerCase();
|
||
|
||
if (q && hay.includes(q.toLowerCase())){ matches++; n.style.outline='2px solid var(--accent)'; }
|
||
|
||
});
|
||
|
||
matchCount.textContent = `${matches} match${matches===1?'':'es'}`;
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---- Raw JSON <-> data sync ----
|
||
|
||
function syncRawFromData(){
|
||
|
||
if (data==null){ rawTA.value=''; rawStatus(''); return; }
|
||
|
||
rawTA.value = JSON.stringify(data, null, 2);
|
||
|
||
rawStatus('Synced from tree ✓', true);
|
||
|
||
}
|
||
|
||
function syncDataFromRaw(showErrors){
|
||
|
||
if (!rawTA.value.trim()) return true;
|
||
|
||
try{ data = JSON.parse(rawTA.value); renderTree(); rawStatus('Applied ✓', true); return true; }
|
||
|
||
catch(e){ if (showErrors) rawStatus('Invalid JSON: '+e.message, false); return false; }
|
||
|
||
}
|
||
|
||
function rawStatus(msg, good){ rawStatusEl.textContent = msg||''; rawStatusEl.className = 'status ' + (good? 'good':'bad'); }
|
||
|
||
function status(msg){ statusEl.textContent = msg; statusEl.className = 'status ' + (/✓|Loaded|Sample/.test(msg)?'good':''); }
|
||
|
||
|
||
|
||
|
||
// ---- Tree editor rendering ----
|
||
|
||
function renderTree(){
|
||
|
||
treeEl.innerHTML='';
|
||
|
||
if (data==null){ return; }
|
||
|
||
|
||
|
||
|
||
// root row header
|
||
|
||
const rootControls = document.createElement('div');
|
||
|
||
rootControls.className='node';
|
||
|
||
rootControls.dataset.key='(root)';
|
||
|
||
rootControls.dataset.type=Array.isArray(data)?'array':'object';
|
||
|
||
rootControls.dataset.value='';
|
||
|
||
|
||
|
||
|
||
// Root type switcher
|
||
|
||
const typeSel = typeSelect(Array.isArray(data)?'array':'object');
|
||
|
||
typeSel.addEventListener('change', ()=>{
|
||
|
||
const t = typeSel.value;
|
||
|
||
data = (t==='array')? [] : {};
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
const keyPill = pill('(root)');
|
||
|
||
const controls = btnRow([
|
||
|
||
['+ key', ()=> addChild([], 'object')],
|
||
|
||
['+ item', ()=> addChild([], 'array')],
|
||
|
||
['Clear', ()=>{ data = Array.isArray(data)?[]:{}; renderTree(); syncRawFromData(); }]
|
||
|
||
]);
|
||
|
||
|
||
|
||
|
||
rootControls.append(
|
||
|
||
span('indent'),
|
||
|
||
divEl('key', keyPill),
|
||
|
||
divEl('val', typeSel),
|
||
|
||
divEl('controls', controls)
|
||
|
||
);
|
||
|
||
treeEl.appendChild(rootControls);
|
||
|
||
|
||
|
||
|
||
// body
|
||
|
||
buildNodes(data, []);
|
||
|
||
status(`Editing ${Array.isArray(data)?'Array':'Object'} ✓`);
|
||
|
||
if (lastSearch) doSearch(lastSearch);
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
function buildNodes(value, path){
|
||
|
||
if (Array.isArray(value)){
|
||
|
||
value.forEach((v, i)=> renderNode(String(i), v, path.concat(i), 'array'));
|
||
|
||
} else if (isObject(value)){
|
||
|
||
Object.keys(value).forEach(k=> renderNode(k, value[k], path.concat(k), 'object'));
|
||
|
||
}
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
function renderNode(key, value, path, parentType){
|
||
|
||
const node = document.createElement('div');
|
||
|
||
node.className='node';
|
||
|
||
node.dataset.key=key;
|
||
|
||
node.dataset.type=typeOf(value);
|
||
|
||
node.dataset.value=safeStr(value);
|
||
|
||
|
||
|
||
|
||
// indent based on depth
|
||
|
||
const depth = path.length; // root is []
|
||
|
||
const indent = document.createElement('div');
|
||
|
||
indent.className='indent';
|
||
|
||
indent.style.marginLeft = (depth*16)+ 'px';
|
||
|
||
|
||
|
||
|
||
// key editor (objects) or index label (arrays)
|
||
|
||
let keyEl;
|
||
|
||
if (parentType==='object'){
|
||
|
||
keyEl = document.createElement('input');
|
||
|
||
keyEl.value = key;
|
||
|
||
keyEl.title = 'Double‑click to rename key';
|
||
|
||
keyEl.readOnly = true;
|
||
|
||
keyEl.addEventListener('dblclick', ()=> keyEl.readOnly=false);
|
||
|
||
keyEl.addEventListener('blur', ()=> keyEl.readOnly=true);
|
||
|
||
keyEl.addEventListener('change', ()=>{
|
||
|
||
if (!keyEl.value) { keyEl.value = key; return; }
|
||
|
||
renameKey(path.slice(0,-1), key, keyEl.value);
|
||
|
||
});
|
||
|
||
}else{
|
||
|
||
keyEl = pill('['+key+']');
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// type selector and value editor
|
||
|
||
const tSel = typeSelect(typeOf(value));
|
||
|
||
const valInput = valueInput(value);
|
||
|
||
|
||
|
||
|
||
tSel.addEventListener('change', ()=>{
|
||
|
||
const newType = tSel.value;
|
||
|
||
const converted = convertValue(valInput.value, newType);
|
||
|
||
setByPath(path, converted);
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
valInput.addEventListener('change', ()=>{
|
||
|
||
const t = tSel.value;
|
||
|
||
if (!validateValue(valInput.value, t)){
|
||
|
||
valInput.classList.add('invalid');
|
||
|
||
return;
|
||
|
||
}
|
||
|
||
valInput.classList.remove('invalid');
|
||
|
||
const v = parseValue(valInput.value, t);
|
||
|
||
setByPath(path, v);
|
||
|
||
syncRawFromData();
|
||
|
||
node.dataset.value = safeStr(v);
|
||
|
||
if (lastSearch) doSearch(lastSearch);
|
||
|
||
});
|
||
|
||
|
||
|
||
|
||
// controls
|
||
|
||
const controls = btnRow([
|
||
|
||
['+', ()=> addChild(path, tSel.value)],
|
||
|
||
['−', ()=> deletePath(path)],
|
||
|
||
['⋮', ()=> navigator.clipboard.writeText(JSON.stringify(getByPath(path)))],
|
||
|
||
]);
|
||
|
||
|
||
|
||
|
||
node.append(
|
||
|
||
indent,
|
||
|
||
divEl('key', keyEl),
|
||
|
||
divEl('val', compose(tSel, valInput)),
|
||
|
||
divEl('controls', controls)
|
||
|
||
);
|
||
|
||
|
||
|
||
|
||
treeEl.appendChild(node);
|
||
|
||
|
||
|
||
|
||
// recurse for containers
|
||
|
||
if (Array.isArray(value) || isObject(value)) buildNodes(value, path);
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---- Helpers: create common UI bits ----
|
||
|
||
function typeSelect(current){
|
||
|
||
const sel = document.createElement('select');
|
||
|
||
['string','number','boolean','null','object','array'].forEach(t=>{
|
||
|
||
const o = document.createElement('option'); o.value=t; o.textContent=t; sel.appendChild(o);
|
||
|
||
});
|
||
|
||
sel.value = current;
|
||
|
||
return sel;
|
||
|
||
}
|
||
|
||
function valueInput(value){
|
||
|
||
const i = document.createElement('input');
|
||
|
||
if (isObject(value) || Array.isArray(value)){
|
||
|
||
i.value = JSON.stringify(value);
|
||
|
||
i.title = 'Edit as JSON fragment';
|
||
|
||
} else if (value === null){
|
||
|
||
i.value = 'null';
|
||
|
||
} else {
|
||
|
||
i.value = String(value);
|
||
|
||
}
|
||
|
||
return i;
|
||
|
||
}
|
||
|
||
function btnRow(items){
|
||
|
||
const wrap = document.createElement('div'); wrap.className='controls';
|
||
|
||
items.forEach(([label, fn])=>{ const b=document.createElement('button'); b.textContent=label; b.addEventListener('click', fn); wrap.appendChild(b); });
|
||
|
||
return wrap;
|
||
|
||
}
|
||
|
||
function pill(text){ const s=document.createElement('span'); s.className='pill'; s.textContent=text; return s; }
|
||
|
||
function span(t){ const s=document.createElement('span'); s.textContent=t; return s; }
|
||
|
||
function divEl(cls, child){ const d=document.createElement('div'); d.className=cls; if (child) d.appendChild(child); return d; }
|
||
|
||
function compose(a,b){ const w=document.createElement('div'); w.style.display='flex'; w.style.gap='6px'; w.append(a,b); return w; }
|
||
|
||
|
||
|
||
|
||
// ---- Data ops ----
|
||
|
||
function isObject(o){ return o && typeof o==='object' && !Array.isArray(o); }
|
||
|
||
function typeOf(v){ return v===null? 'null' : Array.isArray(v)? 'array' : typeof v; }
|
||
|
||
function safeStr(v){ try{return JSON.stringify(v);}catch{return String(v);} }
|
||
|
||
|
||
|
||
|
||
function getByPath(path){
|
||
|
||
return path.reduce((acc, key)=> acc?.[key], data);
|
||
|
||
}
|
||
|
||
function setByPath(path, value){
|
||
|
||
if (path.length===0){ data = value; renderTree(); return; }
|
||
|
||
const parent = getByPath(path.slice(0,-1));
|
||
|
||
const key = path[path.length-1];
|
||
|
||
parent[key] = value;
|
||
|
||
}
|
||
|
||
function deletePath(path){
|
||
|
||
const parent = getByPath(path.slice(0,-1));
|
||
|
||
const key = path[path.length-1];
|
||
|
||
if (Array.isArray(parent)) parent.splice(Number(key),1); else delete parent[key];
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
}
|
||
|
||
function renameKey(parentPath, oldKey, newKey){
|
||
|
||
const parent = getByPath(parentPath);
|
||
|
||
if (!isObject(parent)) return;
|
||
|
||
if (newKey in parent){ alert('Key already exists at this level.'); renderTree(); return; }
|
||
|
||
parent[newKey] = parent[oldKey];
|
||
|
||
delete parent[oldKey];
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
}
|
||
|
||
function addChild(path, typeAtPath){
|
||
|
||
// If this node is a primitive, convert it into a container first
|
||
|
||
let target = getByPath(path);
|
||
|
||
const atRoot = path.length===0;
|
||
|
||
if (typeAtPath==='object' && !isObject(target)) setByPath(path, {}), target = getByPath(path);
|
||
|
||
if (typeAtPath==='array' && !Array.isArray(target)) setByPath(path, []), target = getByPath(path);
|
||
|
||
|
||
|
||
|
||
if (Array.isArray(target)){
|
||
|
||
target.push(null);
|
||
|
||
} else if (isObject(target)){
|
||
|
||
let k = 'key'; let i=1; while(Object.prototype.hasOwnProperty.call(target, k)) k = 'key_'+(i++);
|
||
|
||
target[k] = null;
|
||
|
||
} else if (atRoot){
|
||
|
||
data = (typeAtPath==='array')? [] : {};
|
||
|
||
}
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// ---- Parsing / validation ----
|
||
|
||
function validateValue(text, type){
|
||
|
||
try{ parseValue(text, type); return true; }catch{ return false; }
|
||
|
||
}
|
||
|
||
function parseValue(text, type){
|
||
|
||
switch(type){
|
||
|
||
case 'string': return text;
|
||
|
||
case 'number': {
|
||
|
||
const n = Number(text); if (!Number.isFinite(n)) throw new Error('Not a number'); return n;
|
||
|
||
}
|
||
|
||
case 'boolean': {
|
||
|
||
if (/^(true|false)$/i.test(text)) return /^true$/i.test(text); throw new Error('Use true or false');
|
||
|
||
}
|
||
|
||
case 'null': return null;
|
||
|
||
case 'object': {
|
||
|
||
const v = JSON.parse(text||'{}'); if (!isObject(v)) throw new Error('Not an object'); return v;
|
||
|
||
}
|
||
|
||
case 'array': {
|
||
|
||
const v = JSON.parse(text||'[]'); if (!Array.isArray(v)) throw new Error('Not an array'); return v;
|
||
|
||
}
|
||
|
||
default: return text;
|
||
|
||
}
|
||
|
||
}
|
||
|
||
function convertValue(text, toType){
|
||
|
||
// best‑effort conversion from current text view
|
||
|
||
return parseValue(text, toType);
|
||
|
||
}
|
||
|
||
|
||
|
||
|
||
// Initialize empty doc
|
||
|
||
data = {};
|
||
|
||
renderTree();
|
||
|
||
syncRawFromData();
|
||
|
||
})();
|
||
|
||
</script>
|
||
|
||
</body>
|
||
|
||
</html>
|