Files
WLED/wled00/data/edit.htm

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 / 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 {
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>