Files
JSONEditor/json.html
2026-01-16 17:17:26 +00:00

1057 lines
18 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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: Doubleclick 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 = 'Doubleclick 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){
// besteffort conversion from current text view
return parseValue(text, toType);
}
// Initialize empty doc
data = {};
renderTree();
syncRawFromData();
})();
</script>
</body>
</html>