- no mendatory external JS dependency, works in offline mode - optional external dependency is used for highlighting JSON, plain text edit is used if not available - WLED styling (dark mode only) - JSON files are displayed "prettyfied" and saved "minified" - JSON color highlighting (if available) - JSON verification during edit and on saving both in online and offline mode - special treatment for ledmap files: displayed in aligned columns (2D) or as lines (1D), saved as minified json: no more white-space problems - displays file size and total flash usage
583 lines
16 KiB
HTML
583 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>WLED File Editor</title>
|
|
<meta name="author" content="DedeHai, based on editor by Me-No-Dev">
|
|
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport"><!-- prevent too much scaling on mobile -->
|
|
<link rel="shortcut icon" href="favicon.ico">
|
|
<link href="style.css" rel="stylesheet">
|
|
<!-- Optional lightweight JSON editor - fallback to textarea if CDN is unavailable -->
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.4/ace.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.4/mode-json.min.js"></script>
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.4/theme-monokai.min.js"></script>
|
|
<script src="common.js"></script>
|
|
<style>
|
|
/* Editor-specific styles */
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
min-width: 850px; /* prevent layout breakdown on small screens */
|
|
}
|
|
#top {
|
|
font-size: 20px;
|
|
font-weight: bold;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
background: #222;
|
|
padding: 10px 10px;
|
|
}
|
|
#top .center {
|
|
flex: 1;
|
|
text-align: center;
|
|
}
|
|
#top .right {
|
|
margin-left: auto;
|
|
}
|
|
#top input[type="text"] {
|
|
background: #555;
|
|
border: 2px solid #555;
|
|
border-radius: 8px;
|
|
padding: 6px 8px;
|
|
min-width: 200px;
|
|
}
|
|
#tree {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
width: 300px;
|
|
background: #222;
|
|
overflow-y: auto;
|
|
padding: 8px 8px 100px; /* extra space on bottom so context menu always fits */
|
|
text-align: left;
|
|
}
|
|
#editor, #preview {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 300px;
|
|
right: 0;
|
|
background: #333;
|
|
}
|
|
#editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
#editor textarea {
|
|
flex: 1;
|
|
background: #333;
|
|
color: #fff;
|
|
border: 2px solid #333;
|
|
padding: 8px;
|
|
font: 13px monospace;
|
|
resize: none;
|
|
outline: none;
|
|
}
|
|
#ace-editor {
|
|
flex: 1;
|
|
}
|
|
#preview {
|
|
display: none;
|
|
padding: 10px;
|
|
text-align: center;
|
|
}
|
|
#preview img {
|
|
image-rendering: pixelated;
|
|
width: 40%;
|
|
height: auto;
|
|
}
|
|
#loader {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
display: none;
|
|
}
|
|
.loader {
|
|
width: 60px;
|
|
height: 60px;
|
|
border: 6px solid #444;
|
|
border-top-color: #28f;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
/* Ace editor colors to match WLED style*/
|
|
.ace-monokai .ace_string { color: #4c4 !important; }
|
|
.ace-monokai .ace_constant.ace_numeric { color: #fa0 !important; }
|
|
.ace-monokai .ace_constant.ace_language { color: #f84 !important; }
|
|
.ace-monokai .ace_variable { color: #28f !important; }
|
|
.ace_editor { font: 13px monospace !important; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
<script>
|
|
var QueuedRequester = function(){ this.q=[]; this.r=false; this.x=null; }
|
|
QueuedRequester.prototype = {
|
|
_request: function(req){
|
|
this.r = true;
|
|
var that = this;
|
|
function cb(x,d){ return function(){
|
|
if (x.readyState==4){
|
|
gId("loader").style.display="none";
|
|
d.callback(x.status,x.responseText);
|
|
if (that.q.length===0) that.r=false;
|
|
if (that.r) that._request(that.q.shift());
|
|
}
|
|
}}
|
|
gId("loader").style.display="block";
|
|
var p="";
|
|
if (req.params instanceof FormData) p=req.params;
|
|
else if (req.params instanceof Object){
|
|
for(var key in req.params){
|
|
p+=(p===""?(req.method==="GET"?"?":""):"&")+encodeURIComponent(key)+"="+encodeURIComponent(req.params[key]);
|
|
}
|
|
}
|
|
this.x=new XMLHttpRequest();
|
|
this.x.onreadystatechange=cb(this.x,req);
|
|
if (req.method==="GET"){
|
|
this.x.open(req.method, req.url+p, true);
|
|
this.x.send();
|
|
}
|
|
else{
|
|
this.x.open(req.method, req.url, true);
|
|
if (typeof p === "string") this.x.setRequestHeader("Content-type","application/x-www-form-urlencoded");
|
|
this.x.send(p);
|
|
}
|
|
},
|
|
add: function(method,url,params,cb){
|
|
this.q.push({url:url,method:method,params:params,callback:cb});
|
|
if (!this.r) this._request(this.q.shift());
|
|
}
|
|
};
|
|
var req=new QueuedRequester();
|
|
var globalTree; // Single global reference for tree refresh
|
|
|
|
function loadPreview(filename, editor) {
|
|
var pathField = gId("filepath");
|
|
pathField.value = filename;
|
|
if (/\.(png|jpg|jpeg|gif|bmp|webp)$/i.test(filename)) {
|
|
gId("editor").style.display="none";
|
|
gId("preview").style.display="block";
|
|
gId("preview").innerHTML = '<img src="/edit?func=edit&path=' + encodeURIComponent(filename) + '&_cb=' + Date.now() + '">';
|
|
} else {
|
|
editor.loadText(filename);
|
|
}
|
|
}
|
|
|
|
function refreshTree() {
|
|
if (globalTree) globalTree.refreshPath("/");
|
|
}
|
|
|
|
function createTop(element, editor){
|
|
var input = cE("input");
|
|
input.type = "file";
|
|
input.style.display = "none"; // Hide the default file input
|
|
|
|
// Create container structure
|
|
var leftDiv = cE("div");
|
|
leftDiv.className = "left";
|
|
var centerDiv = cE("div");
|
|
centerDiv.className = "center";
|
|
var rightDiv = cE("div");
|
|
rightDiv.className = "right";
|
|
|
|
// Single text field for filename
|
|
var path = cE("input");
|
|
path.id = "filepath";
|
|
path.type = "text";
|
|
path.maxLength = "31"; // limit filename length
|
|
|
|
var uploadBtn = cE("button"); uploadBtn.className = "sml"; uploadBtn.innerHTML = "Upload File";
|
|
var clearBtn = cE("button"); clearBtn.className = "sml"; clearBtn.innerHTML = "Clear";
|
|
var saveBtn = cE("button"); saveBtn.className = "sml"; saveBtn.innerHTML = "Save";
|
|
var backBtn = cE("button"); backBtn.className = "sml"; backBtn.innerHTML = "Back to the controls"; backBtn.onclick = function(){ window.location.href = getURL("/"); };
|
|
|
|
// Add elements to Top
|
|
leftDiv.appendChild(path);
|
|
leftDiv.appendChild(clearBtn);
|
|
leftDiv.appendChild(saveBtn);
|
|
leftDiv.appendChild(uploadBtn);
|
|
centerDiv.innerHTML = "WLED File Editor";
|
|
rightDiv.appendChild(backBtn);
|
|
gId(element).appendChild(input);
|
|
gId(element).appendChild(leftDiv);
|
|
gId(element).appendChild(centerDiv);
|
|
gId(element).appendChild(rightDiv);
|
|
|
|
editor.clearEditor();
|
|
|
|
uploadBtn.onclick = function() { input.click(); }; // invokes file selector
|
|
|
|
function httpPostCb(st,resp){
|
|
if (st!=200) alert("ERROR "+st+": "+resp);
|
|
else {
|
|
alert("Upload successful!");
|
|
refreshTree();
|
|
}
|
|
}
|
|
|
|
// Clear button - clears the editor
|
|
clearBtn.onclick = function(){
|
|
editor.clearEditor(); // Clear editor, file will be created on save
|
|
input.value = ""; // Clear the file selection
|
|
};
|
|
|
|
saveBtn.onclick = function(){ editor.save(); };
|
|
|
|
// Handle file selection and upload
|
|
input.onchange = function(){
|
|
if (input.files.length == 0) return;
|
|
var file = input.files[0];
|
|
var fd = new FormData();
|
|
fd.append("file", file, file.name);
|
|
console.log("Uploading:", file.name);
|
|
req.add("POST", "/upload", fd, function(st, resp) {
|
|
httpPostCb(st, resp);
|
|
if(st == 200) { loadPreview(file.name, editor); }
|
|
});
|
|
input.value = ""; // Clear the file selection
|
|
};
|
|
}
|
|
|
|
function createTree(element, editor){
|
|
var treeRoot=cE("div");
|
|
gId(element).appendChild(treeRoot);
|
|
var menuCleanup = null; // Track menu cleanup function
|
|
|
|
function downloadFile(p){
|
|
window.open(getURL("/edit") + "?func=download&path=" + encodeURIComponent(p), "_blank");
|
|
}
|
|
|
|
function deleteFile(p) {
|
|
if (!confirm("Delete " + p + "?")) return;
|
|
req.add("GET", getURL("/edit"), { func:"delete", path:p }, function(st, resp) {
|
|
if (st != 200) alert("ERROR " + st + ": " + resp);
|
|
else refreshTree();
|
|
});
|
|
}
|
|
|
|
function createLeaf(path,name,size){
|
|
var leaf=cE("div");
|
|
leaf.style.cssText="cursor:pointer;padding:2px 4px;border-radius:2px;position:relative";
|
|
leaf.textContent=name;
|
|
var span = cE("span");
|
|
span.style.cssText = "font-size: 14px; color: #aaa; margin-left: 8px;";
|
|
span.textContent = (size / 1024).toFixed(1) + "KB";
|
|
leaf.appendChild(span);
|
|
leaf.onmouseover=function(){ leaf.style.background="#333"; };
|
|
leaf.onmouseout=function(){ leaf.style.background=""; };
|
|
leaf.onclick=function(){ loadPreview(name, editor); };
|
|
|
|
// Right-click context menu
|
|
leaf.oncontextmenu = function(e) {
|
|
e.preventDefault();
|
|
// Clean up previous menu
|
|
if (menuCleanup) menuCleanup();
|
|
var menu = cE("div");
|
|
menu.id = "context-menu";
|
|
menu.style.cssText = "position:fixed;left:"+e.clientX+"px;top:"+e.clientY+"px;background:#333;border:1px solid #666;border-radius:4px;z-index:1000;box-shadow:2px 2px 8px rgba(0,0,0,0.5)";
|
|
|
|
function createOption(text, color, handler) {
|
|
var opt = cE("div");
|
|
opt.textContent = text;
|
|
opt.style.cssText = "padding:8px 12px;cursor:pointer;color:" + color;
|
|
opt.onmouseover = function() { this.style.background = "#555"; };
|
|
opt.onmouseout = function() { this.style.background = ""; };
|
|
opt.onclick = function() { handler(); cleanup(); };
|
|
return opt;
|
|
}
|
|
|
|
menu.appendChild(createOption("Download", "#fff", function(){ downloadFile(path); }));
|
|
menu.appendChild(createOption("Delete", "#f66", function(){ deleteFile(path); }));
|
|
d.body.appendChild(menu);
|
|
|
|
function cleanup() {
|
|
if (menu.parentNode) menu.remove();
|
|
d.onclick = null;
|
|
menuCleanup = null;
|
|
}
|
|
|
|
menuCleanup = cleanup;
|
|
setTimeout(function() { d.onclick = cleanup; }, 100);
|
|
};
|
|
|
|
treeRoot.appendChild(leaf);
|
|
}
|
|
|
|
function addList(path,items){
|
|
for(var i=0;i<items.length;i++){
|
|
if (items[i].type==="file" && items[i].name !== "wsec.json") { // hide wsec.json, this is redundant on purpose (done in C code too), just in case...
|
|
var fullPath = path === "/" ? "/" + items[i].name : path + "/" + items[i].name;
|
|
createLeaf(fullPath, items[i].name, items[i].size);
|
|
}
|
|
}
|
|
fsMem();
|
|
}
|
|
|
|
// add file system memory info and credits at the bottom of the tree
|
|
async function fsMem(){
|
|
try{
|
|
const r=await fetch(getURL("/json/info"));
|
|
const info=await r.json();
|
|
var c=cE("div");
|
|
c.style.cssText="font-size:12px;color:#aaa;margin-top:40px";
|
|
c.textContent="by @dedehai";
|
|
if(info&&info.fs){
|
|
c.textContent=`by @dedehai | Memory: ${info.fs.u} KB / ${info.fs.t} KB`;
|
|
}
|
|
treeRoot.appendChild(c);
|
|
}catch(e){console.error(e);}
|
|
}
|
|
|
|
function getCb(p){
|
|
return function(st,resp){
|
|
if (st==200) {
|
|
try {
|
|
addList(p, JSON.parse(resp));
|
|
} catch(e) {
|
|
console.error("Error parsing file list:", e);
|
|
}
|
|
} else {
|
|
console.error("Error loading file list:", st, resp);
|
|
}
|
|
};
|
|
}
|
|
|
|
function httpGet(p){
|
|
req.add("GET", "/edit", { func:"list", path:p }, getCb(p));
|
|
}
|
|
|
|
this.refreshPath=function(p){
|
|
treeRoot.innerHTML="";
|
|
httpGet("/");
|
|
};
|
|
|
|
httpGet("/");
|
|
return this;
|
|
}
|
|
|
|
// Pretty-print ledmapX.json: print 2D maps in aligned columns, print 1D maps as single line
|
|
function prettyLedmap(json){
|
|
try {
|
|
let obj = JSON.parse(json);
|
|
if (!obj.map || !Array.isArray(obj.map)) return JSON.stringify(obj, null, 2);
|
|
let width = obj.width || obj.map.length;
|
|
let maxLen = Math.max(...obj.map.map(n => String(n).length)); // max length of numbers for padding
|
|
|
|
function pad(num) {
|
|
let s = String(num);
|
|
while (s.length < maxLen) s = " " + s;
|
|
return s;
|
|
}
|
|
|
|
let rows = [];
|
|
for (let i = 0; i < obj.map.length; i += width) {
|
|
rows.push(" " + obj.map.slice(i, i + width).map(pad).join(", "));
|
|
}
|
|
|
|
let pretty = "{\n \"map\": [\n" + rows.join(",\n") + "\n ]";
|
|
for (let k of Object.keys(obj)) {
|
|
if (k !== "map") {
|
|
pretty += ",\n \"" + k + "\": " + JSON.stringify(obj[k]);
|
|
}
|
|
}
|
|
pretty += "\n}";
|
|
return pretty;
|
|
} catch (e) {
|
|
return json;
|
|
}
|
|
}
|
|
|
|
function createEditor(element,file){
|
|
if (!file) file="";
|
|
|
|
var ta = cE("textarea");
|
|
var editorDiv = cE("div");
|
|
editorDiv.id = "ace-editor";
|
|
editorDiv.style.display = "none";
|
|
|
|
gId(element).appendChild(ta);
|
|
gId(element).appendChild(editorDiv);
|
|
|
|
var currentFile = file;
|
|
var aceEditor = null;
|
|
var useAce = false;
|
|
|
|
function updateEditorMode() {
|
|
if (!useAce || !aceEditor) return;
|
|
|
|
// Check filename from text field or current file
|
|
var pathField = gId("filepath");
|
|
var filename = (pathField && pathField.value) ? pathField.value : currentFile;
|
|
aceEditor.session.setMode(filename && filename.toLowerCase().endsWith('.json') ? "ace/mode/json" : "ace/mode/text");
|
|
}
|
|
|
|
// Try to initialize Ace editor if available
|
|
function initAce(){
|
|
if (useAce || typeof ace === 'undefined') return;
|
|
try {
|
|
aceEditor = ace.edit(editorDiv);
|
|
aceEditor.setTheme("ace/theme/monokai");
|
|
aceEditor.session.setMode("ace/mode/text");
|
|
aceEditor.setOptions({ fontSize:"13px", fontFamily:"monospace", showPrintMargin:false, wrap:true });
|
|
useAce = true;
|
|
//console.log("Use Ace editor");
|
|
switchToAce();
|
|
updateEditorMode();
|
|
|
|
// Monitor filename input for JSON highlighting (prevent duplicate listeners)
|
|
var pathField = gId("filepath");
|
|
if (pathField && !pathField.jsonListener) {
|
|
pathField.oninput = updateEditorMode;
|
|
pathField.jsonListener = true;
|
|
}
|
|
} catch(e) {
|
|
//console.log("Ace load failed:", e);
|
|
useAce = false;
|
|
}
|
|
}
|
|
// Try now and on window load as a fallback
|
|
setTimeout(initAce, 100);
|
|
window.addEventListener('load', initAce);
|
|
|
|
function switchToAce() {
|
|
if (useAce && aceEditor) {
|
|
ta.style.display = "none";
|
|
editorDiv.style.display = "block";
|
|
editorDiv.style.flex = "1";
|
|
aceEditor.setValue(ta.value, -1);
|
|
aceEditor.resize();
|
|
}
|
|
}
|
|
|
|
function getContent() {
|
|
return (useAce && aceEditor && editorDiv.style.display !== "none") ? aceEditor.getValue() : ta.value;
|
|
}
|
|
|
|
function setContent(content) {
|
|
ta.value = content;
|
|
if (useAce && aceEditor) aceEditor.setValue(content, -1);
|
|
}
|
|
|
|
// Live JSON validation for textarea
|
|
ta.oninput = function() {
|
|
var pathField = gId("filepath");
|
|
var filename = pathField ? pathField.value : currentFile;
|
|
var border = "2px solid #333";
|
|
|
|
if (filename && filename.toLowerCase().endsWith('.json')) {
|
|
try {
|
|
JSON.parse(ta.value);
|
|
} catch(e) {
|
|
border = "2px solid #f00";
|
|
}
|
|
}
|
|
ta.style.border = border;
|
|
};
|
|
|
|
function saveFile(filename,data){
|
|
var finalData = data;
|
|
// Minify JSON files before upload
|
|
if (filename.toLowerCase().endsWith('.json')) {
|
|
try {
|
|
finalData = JSON.stringify(JSON.parse(data));
|
|
} catch(e) {
|
|
alert("Invalid JSON! Please fix syntax.");
|
|
return;
|
|
}
|
|
}
|
|
var fd=new FormData();
|
|
fd.append("file",new Blob([finalData],{type:"text/plain"}),filename);
|
|
req.add("POST","/upload",fd,function(st,resp){
|
|
if (st!=200) alert("ERROR "+st+": "+resp);
|
|
else {
|
|
alert("File saved successfully!");
|
|
refreshTree();
|
|
}
|
|
});
|
|
}
|
|
|
|
function loadFile(filename){
|
|
if (!filename) return;
|
|
req.add("GET", "/edit", { func:"edit", path:filename }, function(st, resp) {
|
|
gId("preview").style.display="none";
|
|
gId("editor").style.display="flex";
|
|
if (st==200) {
|
|
if (filename.toLowerCase().endsWith('.json')) {
|
|
try {
|
|
setContent(filename.toLowerCase().includes('ledmap') ? prettyLedmap(resp) : JSON.stringify(JSON.parse(resp), null, 2));
|
|
} catch(e) {
|
|
setContent(resp);
|
|
}
|
|
} else {
|
|
setContent(resp);
|
|
}
|
|
} else {
|
|
setContent("");
|
|
}
|
|
currentFile = filename;
|
|
updateEditorMode();
|
|
});
|
|
}
|
|
|
|
if (file) loadFile(file);
|
|
|
|
return {
|
|
save:function(){
|
|
var pathField = gId("filepath");
|
|
var fn = pathField ? pathField.value : "";
|
|
if (!fn) {
|
|
alert("Please enter a filename!");
|
|
return;
|
|
}
|
|
if (!fn.startsWith("/")) fn = "/" + fn;
|
|
currentFile = fn; // Update current file
|
|
saveFile(fn, getContent());
|
|
loadFile(fn);
|
|
},
|
|
loadText:function(fn){
|
|
currentFile=fn;
|
|
var pathField = gId("filepath");
|
|
if (pathField && fn) {
|
|
pathField.value = fn.startsWith("/") ? fn.substring(1) : fn;
|
|
}
|
|
loadFile(fn);
|
|
},
|
|
clearEditor:function(){
|
|
gId("preview").style.display="none";
|
|
gId("editor").style.display="flex";
|
|
// Update filename in text field
|
|
setContent("");
|
|
var pathField = gId("filepath");
|
|
pathField.value = "";
|
|
pathField.placeholder = "Filename to save";
|
|
updateEditorMode();
|
|
}
|
|
};
|
|
}
|
|
|
|
function onBodyLoad(){
|
|
var vars={};
|
|
window.location.href.replace(/[?&]+([^=&]+)=([^&]*)/gi,function(m,k,v){
|
|
vars[decodeURIComponent(k)]=decodeURIComponent(v);
|
|
});
|
|
|
|
var editor=createEditor("editor",vars.file);
|
|
globalTree=createTree("tree",editor);
|
|
createTop("top",editor);
|
|
}
|
|
</script>
|
|
</head>
|
|
<body onload="onBodyLoad()">
|
|
<div id="loader"><div class="loader"></div></div>
|
|
<div id="top"></div>
|
|
<div style="flex:1;position:relative">
|
|
<div id="tree">
|
|
</div>
|
|
<div id="editor"></div>
|
|
<div id="preview"></div>
|
|
</div>
|
|
</body>
|
|
</html> |