when a ledmap is read from a file, it first parses the keys, putting the in front is more efficient as it will find them in the first 256 byte chunk.
591 lines
16 KiB
HTML
591 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 {
|
|
showToast("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 > 0 ? Math.max(0.1, (size / 1024)).toFixed(1) : 0) + "KB"; // show size in KB, minimum 0.1 to not show 0KB for small files
|
|
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";
|
|
for (let k of Object.keys(obj)) {
|
|
if (k !== "map") {
|
|
pretty += " \"" + k + "\": " + JSON.stringify(obj[k]) + ",\n"; // print all keys first (speeds up loading)
|
|
}
|
|
}
|
|
pretty += " \"map\": [\n" + rows.join(",\n") + "\n ]\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 {
|
|
showToast("File saved");
|
|
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);
|
|
// Add Ctrl+S / Cmd+S override to save the file
|
|
document.addEventListener('keydown', function(e) {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
e.preventDefault();
|
|
editor.save();
|
|
}
|
|
});
|
|
}
|
|
</script>
|
|
</head>
|
|
<body onload="onBodyLoad()">
|
|
<div id="toast"></div>
|
|
<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> |