399 lines
12 KiB
JavaScript
399 lines
12 KiB
JavaScript
/*
|
|
createJsonTree({
|
|
container: domElement,
|
|
data: jsonData,
|
|
expandInitially = true // optional: expand all nodes
|
|
onChange: (data) => { }, // optional: callback on change
|
|
onSave: (data) => { } // optional: if set, a save button will be added
|
|
});
|
|
*/
|
|
function createJsonTree({
|
|
container,
|
|
data,
|
|
onChange = () => {},
|
|
onSave = null,
|
|
expandInitially = true
|
|
}) {
|
|
container.innerHTML = "";
|
|
container.classList.add("json-tree-root");
|
|
|
|
const history = [];
|
|
const redoStack = [];
|
|
|
|
let lastSnapshot = clone(data);
|
|
|
|
const expandedPaths = new Set();
|
|
|
|
function clone(obj) {
|
|
return JSON.parse(JSON.stringify(obj));
|
|
}
|
|
|
|
function diff(oldObj, newObj) {
|
|
const changes = {};
|
|
|
|
function walk(o, n, path = "") {
|
|
const keys = new Set([
|
|
...Object.keys(o || {}),
|
|
...Object.keys(n || {})
|
|
]);
|
|
|
|
for (const key of keys) {
|
|
const oldVal = o?.[key];
|
|
const newVal = n?.[key];
|
|
|
|
const currentPath = path ? `${path}.${key}` : key;
|
|
|
|
if (
|
|
typeof oldVal === "object" &&
|
|
oldVal !== null &&
|
|
typeof newVal === "object" &&
|
|
newVal !== null &&
|
|
!Array.isArray(oldVal)
|
|
) {
|
|
walk(oldVal, newVal, currentPath);
|
|
continue;
|
|
}
|
|
|
|
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
changes[currentPath] = newVal;
|
|
}
|
|
}
|
|
}
|
|
|
|
walk(oldObj, newObj);
|
|
return changes;
|
|
}
|
|
|
|
function pushHistory() {
|
|
history.push(clone(data));
|
|
redoStack.length = 0;
|
|
}
|
|
|
|
function undo() {
|
|
if (!history.length) return;
|
|
redoStack.push(clone(data));
|
|
data = history.pop();
|
|
render();
|
|
}
|
|
|
|
function redo() {
|
|
if (!redoStack.length) return;
|
|
history.push(clone(data));
|
|
data = redoStack.pop();
|
|
render();
|
|
}
|
|
|
|
function save() {
|
|
if (onSave) onSave(data);
|
|
}
|
|
|
|
function autoResize(input) {
|
|
input.style.width = "10px";
|
|
input.style.width = input.scrollWidth + "px";
|
|
}
|
|
|
|
function remove(key, parent) {
|
|
if (key === null || parent == null) return;
|
|
|
|
if (!confirm("Wirklich löschen?")) return;
|
|
|
|
pushHistory();
|
|
|
|
if (Array.isArray(parent)) {
|
|
parent.splice(key, 1);
|
|
} else {
|
|
delete parent[key];
|
|
}
|
|
|
|
onChange(data);
|
|
render();
|
|
}
|
|
|
|
function collectPaths(obj, path = "root") {
|
|
if (typeof obj !== "object" || obj === null) return;
|
|
|
|
expandedPaths.add(path);
|
|
|
|
const entries = Array.isArray(obj)
|
|
? obj.map((v, i) => [i, v])
|
|
: Object.entries(obj);
|
|
|
|
for (const [k, v] of entries) {
|
|
collectPaths(v, `${path}.${k}`);
|
|
}
|
|
}
|
|
|
|
if (expandInitially) {
|
|
collectPaths(data);
|
|
}
|
|
|
|
function render() {
|
|
container.innerHTML = "";
|
|
|
|
if (onSave) {
|
|
const controls = document.createElement("div");
|
|
controls.className = "json-controls";
|
|
|
|
const undoBtn = document.createElement("button");
|
|
undoBtn.className = "monolyth";
|
|
undoBtn.textContent = "Undo";
|
|
undoBtn.onclick = undo;
|
|
|
|
const redoBtn = document.createElement("button");
|
|
redoBtn.className = "monolyth";
|
|
redoBtn.textContent = "Redo";
|
|
redoBtn.onclick = redo;
|
|
|
|
const saveBtn = document.createElement("button");
|
|
saveBtn.className = "monolyth";
|
|
saveBtn.textContent = "Save";
|
|
saveBtn.onclick = save;
|
|
|
|
controls.appendChild(undoBtn);
|
|
controls.appendChild(redoBtn);
|
|
controls.appendChild(saveBtn);
|
|
|
|
container.appendChild(controls);
|
|
}
|
|
|
|
container.appendChild(renderNode(data, null, null, "root", 0));
|
|
}
|
|
|
|
function renderNode(value, key, parent, path, level) {
|
|
const wrapper = document.createElement("div");
|
|
wrapper.className = "json-line";
|
|
wrapper.style.marginLeft = `${level * 18}px`;
|
|
|
|
const keySpan = document.createElement("span");
|
|
keySpan.className = "json-key";
|
|
|
|
if (key !== null) {
|
|
keySpan.textContent = `"${key}": `;
|
|
}
|
|
|
|
const removeBtn = document.createElement("span");
|
|
removeBtn.className = "json-remove";
|
|
removeBtn.textContent = " [x]";
|
|
removeBtn.onclick = (evt) => {
|
|
evt.stopPropagation();
|
|
remove(key, parent);
|
|
};
|
|
|
|
// OBJECT / ARRAY
|
|
if (typeof value === "object" && value !== null) {
|
|
const isArray = Array.isArray(value);
|
|
|
|
const header = document.createElement("div");
|
|
header.className = "json-header";
|
|
|
|
const toggle = document.createElement("span");
|
|
toggle.className = "json-toggle";
|
|
toggle.textContent = isArray ? "[ ]" : "{ }";
|
|
|
|
const keyLabel = document.createElement("span");
|
|
keyLabel.className = "json-key";
|
|
if (key !== null) keyLabel.textContent = `"${key}": `;
|
|
|
|
const addBtn = document.createElement("span");
|
|
addBtn.className = "json-add";
|
|
addBtn.textContent = " [+]";
|
|
|
|
const children = document.createElement("div");
|
|
children.className = "json-children";
|
|
|
|
const toggleState = () => {
|
|
if (expandedPaths.has(path)) {
|
|
expandedPaths.delete(path);
|
|
children.classList.add("collapsed");
|
|
} else {
|
|
expandedPaths.add(path);
|
|
children.classList.remove("collapsed");
|
|
}
|
|
};
|
|
|
|
keyLabel.onclick = toggle.onclick = toggleState;
|
|
|
|
if (expandedPaths.has(path)) {
|
|
children.classList.remove("collapsed");
|
|
} else {
|
|
children.classList.add("collapsed");
|
|
}
|
|
|
|
addBtn.onclick = () => {
|
|
let newKey = "";
|
|
let newValue;
|
|
|
|
feedbox({
|
|
title: `<span style="color:#f44336">Key</span> hinzufügen`,
|
|
message: `
|
|
<div style="display:flex;flex-direction:column;gap:8px;">
|
|
${!isArray ? `
|
|
<input id="newJsonKeyName" placeholder="Key Name" style="padding:6px;" />
|
|
` : ``}
|
|
<select id="newJsonValueType">
|
|
<option value="text">Text</option>
|
|
<option value="number">Zahl</option>
|
|
<option value="boolean">Boolean</option>
|
|
<option value="object">Objekt</option>
|
|
<option value="array">Array</option>
|
|
</select>
|
|
</div>
|
|
`,
|
|
buttons: {
|
|
yes: {
|
|
text: '<b>Anlegen</b>',
|
|
onClick: () => {
|
|
pushHistory();
|
|
|
|
if (!isArray) {
|
|
const input = document.querySelector('#newJsonKeyName');
|
|
newKey = input?.value?.trim();
|
|
if (!newKey) return;
|
|
}
|
|
|
|
const selectedType = document.querySelector('#newJsonValueType').value;
|
|
|
|
switch (selectedType) {
|
|
case "number": newValue = 0; break;
|
|
case "boolean": newValue = false; break;
|
|
case "object": newValue = {}; break;
|
|
case "array": newValue = []; break;
|
|
default: newValue = "";
|
|
}
|
|
|
|
if (isArray) {
|
|
value.push(newValue);
|
|
} else {
|
|
value[newKey] = newValue;
|
|
}
|
|
|
|
onChange(data);
|
|
render();
|
|
}
|
|
},
|
|
no: { text: 'Abbrechen' }
|
|
}
|
|
});
|
|
};
|
|
|
|
const inner = document.createElement("div");
|
|
|
|
const entries = isArray
|
|
? value.map((v, i) => [i, v])
|
|
: Object.entries(value);
|
|
|
|
entries.forEach(([k, v]) => {
|
|
inner.appendChild(
|
|
renderNode(v, k, value, `${path}.${k}`, level + 1)
|
|
);
|
|
});
|
|
|
|
children.appendChild(inner);
|
|
|
|
header.appendChild(keyLabel);
|
|
header.appendChild(toggle);
|
|
header.appendChild(addBtn);
|
|
|
|
if (key !== null) header.appendChild(removeBtn);
|
|
|
|
wrapper.appendChild(header);
|
|
wrapper.appendChild(children);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
// STRING
|
|
if (typeof value === "string") {
|
|
const input = document.createElement("input");
|
|
input.className = "json-input string";
|
|
input.value = value;
|
|
|
|
input.oninput = () => {
|
|
pushHistory();
|
|
parent[key] = input.value;
|
|
autoResize(input);
|
|
onChange(data);
|
|
};
|
|
|
|
setTimeout(() => autoResize(input), 0);
|
|
|
|
wrapper.appendChild(keySpan);
|
|
wrapper.appendChild(input);
|
|
if (key !== null) wrapper.appendChild(removeBtn);
|
|
return wrapper;
|
|
}
|
|
|
|
// NUMBER
|
|
if (typeof value === "number") {
|
|
const input = document.createElement("input");
|
|
input.type = "number";
|
|
input.className = "json-input number";
|
|
input.value = value;
|
|
|
|
input.oninput = () => {
|
|
pushHistory();
|
|
parent[key] = Number(input.value);
|
|
autoResize(input);
|
|
onChange(data);
|
|
};
|
|
|
|
setTimeout(() => autoResize(input), 0);
|
|
|
|
wrapper.appendChild(keySpan);
|
|
wrapper.appendChild(input);
|
|
if (key !== null) wrapper.appendChild(removeBtn);
|
|
return wrapper;
|
|
}
|
|
|
|
// BOOLEAN
|
|
if (typeof value === "boolean") {
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.checked = value;
|
|
|
|
input.onchange = () => {
|
|
pushHistory();
|
|
parent[key] = input.checked;
|
|
onChange(data);
|
|
};
|
|
|
|
wrapper.appendChild(keySpan);
|
|
wrapper.appendChild(input);
|
|
if (key !== null) wrapper.appendChild(removeBtn);
|
|
return wrapper;
|
|
}
|
|
|
|
const span = document.createElement("span");
|
|
span.textContent = String(value);
|
|
|
|
wrapper.appendChild(keySpan);
|
|
wrapper.appendChild(span);
|
|
|
|
if (key !== null) wrapper.appendChild(removeBtn);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
pushHistory();
|
|
render();
|
|
|
|
return {
|
|
undo,
|
|
redo,
|
|
save,
|
|
refresh: render,
|
|
getData: () => data,
|
|
getChanges() {
|
|
return diff(lastSnapshot, data);
|
|
},
|
|
commit() {
|
|
lastSnapshot = clone(data);
|
|
},
|
|
update(newData) {
|
|
data = newData;
|
|
lastSnapshot = clone(newData);
|
|
pushHistory();
|
|
render();
|
|
}
|
|
};
|
|
} |