* full refactoring, added live preview, better minifying in cdata.js * update main UI buttons, support for gaps in cpal files, cpal UI cleanup * fixed some layout issues, added un-ordered cpal deletion * changed to tab indentation, paste button border color now holds stored color * fix preview to work properly and some other fixes in UI * always unfreeze * new approach to loading iro.js, add harmonic random palette, many fixes. * decoupling iro.j, update UI of cpal.htm - load iro.js sequentially - no parallel requests in cpal.htm - update UI buttons - fix showing sequential loading of palettes (using opacity) - better UX for mobile (larger markers, larger editor) - various fixes * small change to buttons * load iro.js dynamically, remove iro.js from index.htm, revert changes to cdata.js * improved visibility for very dark/black palettes and markers
908 lines
27 KiB
HTML
908 lines
27 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1">
|
|
<title>WLED Palette Editor</title>
|
|
<!--* <link rel="stylesheet" href="style.css">
|
|
<script src="common.js"></script>
|
|
<script src="iro.js"></script>
|
|
<link rel="icon" href="data:,"> *-->
|
|
</head>
|
|
<body>
|
|
<div class="ctr">
|
|
<header><h1>WLED Palette Editor</h1></header>
|
|
|
|
<div id="pickerWrap">
|
|
<div id="picker"></div>
|
|
<div class="rgbi">
|
|
<label>R</label><input type="number" id="rInput" min="0" max="255" value="0">
|
|
<label>G</label><input type="number" id="gInput" min="0" max="255" value="0">
|
|
<label>B</label><input type="number" id="bInput" min="0" max="255" value="0">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bar">
|
|
<div class="bar">
|
|
<button id="btnNew" class="sml">Generate</button>
|
|
<button id="btnDist" class="sml">Distribute</button>
|
|
</div>
|
|
<div class="bar">
|
|
<button id="btnCopy" class="sml">Copy</button>
|
|
<button id="btnPaste" class="sml">Paste</button>
|
|
<button id="btnDel" class="sml">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="editor">
|
|
<div id="gradWrap"><div id="grad"></div></div>
|
|
</div>
|
|
|
|
<label>
|
|
<input type="checkbox" id="chkPreview">
|
|
<span>preview on selected segments</span>
|
|
</label>
|
|
|
|
<div class="tbl" id="empty"></div>
|
|
|
|
<section>
|
|
<span id="memWarn">Warning: Adding many custom palettes might cause stability issues, create <a href="/settings/sec#backup">backups</a></span>
|
|
<div class="tbl"><div id="custom" class="lst"></div></div>
|
|
</section>
|
|
|
|
<section><div id="allCats" class="cats"></div></section>
|
|
|
|
<div>
|
|
<button id="btnFetchExt" class="btn">Download more palettes</button>
|
|
</div>
|
|
<div style="margin-bottom: 24px;">
|
|
<button class="btn" onclick="window.location.href = getURL('/');">Back to the controls</button>
|
|
</div>
|
|
|
|
<div style="font-size:12px;color:#666;">by @dedehai</div>
|
|
</div>
|
|
|
|
<script>
|
|
// State
|
|
let gr, wr, rc, w = 0, sc = 1, pl = 16;
|
|
let sel = null, pk = null;
|
|
let cpc = 0, cpm = 10, pnm = [], cpal = [], spal = []; // custom palette count, custom palette max, names, custom palettes, static palettes
|
|
let prvTmr = null, prvEn = false, palCache = [];
|
|
let isDragging = false;
|
|
let copyColor = '#000';
|
|
let ws = null;
|
|
let maxCol; // max colors to send out in one chunk, ESP8266 is limited to ~50 (500 bytes), ESP32 can do ~128 (1340 bytes)
|
|
|
|
// load external resources in sequence to avoid 503 errors if heap is low, repeats indefinitely until loaded
|
|
// load CSS
|
|
const l1 = document.createElement('link');
|
|
l1.rel = 'stylesheet';
|
|
l1.href = 'style.css';
|
|
l1.onload = () => {
|
|
// load iro.js
|
|
const l2 = document.createElement('script');
|
|
l2.src = 'iro.js';
|
|
l2.onload = () => {
|
|
// load common.js
|
|
const l3 = document.createElement('script');
|
|
l3.src = 'common.js';
|
|
// initialize when all documents are loaded
|
|
l3.onload = () => document.readyState === 'complete' ? init() : window.addEventListener('load', init);
|
|
l3.onerror = () => setTimeout(() => document.head.appendChild(l3), 100);
|
|
document.head.appendChild(l3);
|
|
};
|
|
l2.onerror = () => setTimeout(() => document.head.appendChild(l2), 100);
|
|
document.head.appendChild(l2);
|
|
};
|
|
l1.onerror = () => setTimeout(() => document.head.appendChild(l1), 100);
|
|
document.head.appendChild(l1);
|
|
|
|
// main init function, called when all resources are loaded
|
|
function init() {
|
|
// init iro color picker
|
|
pk = new iro.ColorPicker('#picker', {
|
|
width: 240,
|
|
wheelAngle: 270,
|
|
wheelDirection: 'clockwise',
|
|
layout: [
|
|
{component: iro.ui.Wheel},
|
|
{component: iro.ui.Slider, options: {sliderType: 'value'}}
|
|
]
|
|
});
|
|
// update color when picker changes
|
|
pk.on('color:change', (c) => { setCol(c.hexString); updRGB(c.hexString); });
|
|
|
|
const updFromRGB = () => {
|
|
const r = clamp(parseInt(gId('rInput').value) || 0, 0, 255);
|
|
const g = clamp(parseInt(gId('gInput').value) || 0, 0, 255);
|
|
const b = clamp(parseInt(gId('bInput').value) || 0, 0, 255);
|
|
pk.color.rgb = {r, g, b};
|
|
};
|
|
gId('rInput').addEventListener('change', updFromRGB);
|
|
gId('gInput').addEventListener('change', updFromRGB);
|
|
gId('bInput').addEventListener('change', updFromRGB);
|
|
|
|
gId('btnDist').onclick = dist;
|
|
gId('btnDel').onclick = deleteMarker;
|
|
gId('btnNew').onclick = rndPal;
|
|
gId('btnCopy').onclick = () => copypasteColor(0);
|
|
gId('btnPaste').onclick = () => copypasteColor(1);
|
|
gId('btnFetchExt').onclick = fetchExt;
|
|
gId('chkPreview').addEventListener('change', (e) => {
|
|
prvEn = e.target.checked;
|
|
if (prvEn) applyLED();
|
|
else requestJson({seg:{frz:false}});
|
|
});
|
|
|
|
gr = gId('grad');
|
|
wr = gId('gradWrap');
|
|
wr.addEventListener('pointerdown', (e) => {
|
|
const m = e.target.closest('.mk');
|
|
if (m) {
|
|
isDragging = false;
|
|
selMk(m);
|
|
if (m.dataset.lock === '1') return;
|
|
e.preventDefault();
|
|
const startT = +m.dataset.t;
|
|
const mv = (ev) => {
|
|
isDragging = true;
|
|
const minT = (startT === 0) ? 0 : 1;
|
|
const maxT = (startT === 255) ? 255 : 254;
|
|
const x = clamp(Math.round((ev.clientX - (rc.left + pl)) / sc), minT, maxT);
|
|
m.dataset.t = x; m.style.left = (pl + (x * sc)) + 'px'; draw();
|
|
};
|
|
d.addEventListener('pointermove', mv);
|
|
d.addEventListener('pointerup', () => d.removeEventListener('pointermove', mv), {once:1});
|
|
} else if (e.target === wr || e.target === gr) {
|
|
const t = clamp(Math.round((e.clientX - (rc.left + pl)) / sc), 1, 254);
|
|
if (canAdd() && t !== 0 && t !== 255) {
|
|
addMk(t, '#' + (palCache[t] || '000'));
|
|
// trigger drag immediately
|
|
const newM = sel;
|
|
if (newM) {
|
|
e.preventDefault();
|
|
const mv = (ev) => {
|
|
isDragging = true;
|
|
const x = clamp(Math.round((ev.clientX - (rc.left + pl)) / sc), 1, 254);
|
|
newM.dataset.t = x; newM.style.left = (pl + (x * sc)) + 'px'; draw();
|
|
};
|
|
d.addEventListener('pointermove', mv);
|
|
d.addEventListener('pointerup', () => d.removeEventListener('pointermove', mv), {once:1});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
// keyboard nudge for selected marker (note: uses about 100 bytes of code)
|
|
d.addEventListener('keydown', (e) => {
|
|
if (!sel || sel.dataset.lock === '1') return;
|
|
let t = +sel.dataset.t;
|
|
if (e.key === 'ArrowLeft') t = Math.max(1, t - (e.shiftKey ? 8 : 1));
|
|
else if (e.key === 'ArrowRight') t = Math.min(254, t + (e.shiftKey ? 8 : 1));
|
|
else return;
|
|
sel.dataset.t = t; sel.style.left = (pl + (t * sc)) + 'px'; draw(); e.preventDefault();
|
|
});
|
|
|
|
getLoc(); // set base URL
|
|
ws = connectWs();
|
|
|
|
let extGrp = {};
|
|
const cached = tryCache();
|
|
if (!cached) gId('btnFetchExt').style.display = '';
|
|
// fetch info + palnames
|
|
Promise.all([fetch(getURL('/json/info')).then(r=>r.json()), fetch(getURL('/json/pal')).then(r=>r.json())])
|
|
.then(([inf, nm]) => {
|
|
pnm = nm; cpc = inf.cpalcount; cpm = inf.cpalmax;
|
|
fetchC(cpc);
|
|
if (inf.arch === 'esp8266') maxCol = 50; // TODO: test if this works.
|
|
else maxCol = 128;
|
|
|
|
// Extract WLED palettes from wledPalx cache from main UI
|
|
let cache; try { cache = JSON.parse(localStorage.getItem('wledPalx')); } catch {}
|
|
if (cache?.p) {
|
|
for (const k in cache.p) {
|
|
if (+k > 255 - cpm || !Array.isArray(cache.p[k])) { delete cache.p[k]; continue; }
|
|
const a = cache.p[k];
|
|
if (a[a.length-1][0] !== 255) a.push([255, ...a[a.length-1].slice(1)]);
|
|
cache.p[k].name = pnm[k];
|
|
}
|
|
spal = Object.entries(cache.p).filter(([k]) => +k >= 8).map(([k, v]) => ({[k]:v.flat(), name:v.name}));
|
|
}
|
|
try { const raw = localStorage.getItem('wledCptCityJson'); if (raw) extGrp = grpExt(JSON.parse(raw)); } catch(e) {}
|
|
bldCat(extGrp);
|
|
})
|
|
.catch(()=>{});
|
|
|
|
recalc();
|
|
rndPal();
|
|
}
|
|
|
|
// Utils
|
|
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
|
|
function rndHex() { return '#' + (Math.random() * 0xFFFFFF << 0).toString(16).padStart(6, '0'); }
|
|
function h2rgb(h) { h = h.replace('#', ''); const n = parseInt(h, 16); return [(n>>16)&255, (n>>8)&255, n&255]; }
|
|
function rgb2h(r, g, b) { return ((1<<24) + (r<<16) + (g<<8) + b).toString(16).slice(1); }
|
|
function lerp(a, b, t) { return a + (b-a)*t; }
|
|
function updRGB(h) { const [r,g,b] = h2rgb(h); gId('rInput').value=r; gId('gInput').value=g; gId('bInput').value=b; }
|
|
function isEmpty(a) { return Array.isArray(a) && a.length === 1 && a[0] === 255; }
|
|
|
|
// copy / paste color
|
|
function copypasteColor(paste = false) {
|
|
if (!sel) return;
|
|
if (paste) {
|
|
setCol(copyColor);
|
|
} else {
|
|
copyColor = sel.dataset.c;
|
|
gId('btnPaste').style.borderColor = copyColor;
|
|
}
|
|
}
|
|
|
|
// Geometry
|
|
function recalc() {
|
|
rc = wr.getBoundingClientRect();
|
|
const cs = getComputedStyle(wr);
|
|
pl = parseInt(cs.paddingLeft || 16, 10);
|
|
w = rc.width - pl - parseInt(cs.paddingRight || 16, 10);
|
|
sc = w / 255;
|
|
layout();
|
|
draw();
|
|
}
|
|
|
|
function layout() {
|
|
[...gr.querySelectorAll('.mk')].forEach(m => {
|
|
m.style.left = (pl + (+m.dataset.t * sc)) + 'px';
|
|
});
|
|
}
|
|
|
|
function stops() {
|
|
return [...gr.querySelectorAll('.mk')]
|
|
.map(m => ({ t: +m.dataset.t, c: m.dataset.c, lock: m.dataset.lock === '1' }))
|
|
.sort((a, b) => a.t - b.t);
|
|
}
|
|
|
|
/* Build a 256-entry cache of palette RGB hex (without #). This is used for previewing and sending to LEDs */
|
|
function bldCache() {
|
|
const s = stops();
|
|
palCache = new Array(256);
|
|
if (!s.length) { palCache.fill('000000'); return; }
|
|
|
|
for (let t = 0; t <= 255; t++) {
|
|
if (t <= s[0].t) { palCache[t] = s[0].c.slice(1); continue; }
|
|
if (t >= s[s.length - 1].t) { palCache[t] = s[s.length - 1].c.slice(1); continue; }
|
|
|
|
for (let i = 0; i < s.length - 1; i++) {
|
|
const a = s[i], b = s[i+1];
|
|
if (t >= a.t && t <= b.t) {
|
|
const f = (b.t === a.t) ? 0 : ((t - a.t) / (b.t - a.t));
|
|
const [ar,ag,ab] = h2rgb(a.c), [br,bg,bb] = h2rgb(b.c);
|
|
palCache[t] = rgb2h(Math.round(lerp(ar,br,f)), Math.round(lerp(ag,bg,f)), Math.round(lerp(ab,bb,f)));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
const s = stops();
|
|
gr.style.background = 'linear-gradient(to right,' + s.map(x => x.c + ' ' + Math.round(x.t * sc) + 'px').join(',') + ')';
|
|
bldCache();
|
|
if (prvEn && !prvTmr) {
|
|
let d = (ws && ws.readyState == 1) ? 50 : 500; // slower updates if using HTTP to not overwhelm the ESP
|
|
prvTmr = setTimeout(() => { prvTmr = null; applyLED(); }, d);
|
|
}
|
|
}
|
|
|
|
// Markers
|
|
function selMk(m) {
|
|
if (sel) sel.classList.remove('sel');
|
|
sel = m || null;
|
|
if (sel) {
|
|
sel.classList.add('sel');
|
|
if (pk) { pk.color.hexString = sel.dataset.c; updRGB(sel.dataset.c); }
|
|
}
|
|
}
|
|
|
|
function canAdd() { return gr.querySelectorAll('.mk').length < 16; }
|
|
|
|
function addMk(t, c, lock) {
|
|
// keep start and end markers at 0/255 locked
|
|
if (t < 0 || t > 255 || !canAdd() || gr.querySelector('.mk[data-t="' + t + '"]')) return;
|
|
const m = cE('div');
|
|
m.className = 'mk';
|
|
m.dataset.t = t;
|
|
m.dataset.c = c || rndHex();
|
|
m.dataset.lock = lock ? '1' : '0';
|
|
m.style.left = (pl + (t * sc)) + 'px';
|
|
m.style.background = m.dataset.c;
|
|
gr.appendChild(m);
|
|
selMk(m);
|
|
draw();
|
|
}
|
|
|
|
function deleteMarker() {
|
|
if (!sel || sel.dataset.lock === '1') return;
|
|
sel.remove();
|
|
selMk(null);
|
|
draw();
|
|
}
|
|
|
|
function setCol(h) {
|
|
if (!sel) return;
|
|
sel.dataset.c = h;
|
|
sel.style.background = h;
|
|
draw();
|
|
}
|
|
|
|
function dist() {
|
|
const s = stops();
|
|
if (s.length < 3) return;
|
|
const inner = s.slice(1, -1), step = Math.round(255 / (inner.length + 1));
|
|
inner.forEach((p, i) => {
|
|
// find node corresponding to this pos (skips locked)
|
|
const m = [...gr.querySelectorAll('.mk')].find(x => +x.dataset.t === p.t && x.dataset.lock !== '1');
|
|
if (m) { m.dataset.t = step * (i + 1); m.style.left = (pl + (m.dataset.t * sc)) + 'px'; }
|
|
});
|
|
draw();
|
|
}
|
|
/*
|
|
function rndPal() {
|
|
gr.innerHTML = '';
|
|
addMk(0, rndHex(), 1);
|
|
const cnt = Math.floor(Math.random() * 3) + 2, pos = new Set();
|
|
while (pos.size < cnt) pos.add(Math.floor(Math.random() * 254) + 1);
|
|
[...pos].sort((a,b)=>a-b).forEach(t => addMk(t, rndHex()));
|
|
addMk(255, rndHex(), 1);
|
|
}*/
|
|
|
|
|
|
// convert hsl to hex using canvas
|
|
function hslToHex(h, s, l) {
|
|
let ctx = cE("canvas").getContext("2d");
|
|
ctx.fillStyle = `hsl(${h},${s}%,${l}%)`;
|
|
return ctx.fillStyle;
|
|
}
|
|
// random or harmonic palette (uses 100 bytes extra compared to simple random palette)
|
|
function rndPal() {
|
|
gr.innerHTML = '';
|
|
const mode = Math.floor(Math.random() * 3); // 33% chance for random, 67% for harmonic
|
|
const cnt = Math.floor(Math.random() * 4) + 1; // 1-4 colors (+ start/end)
|
|
let pos = new Set();
|
|
while (pos.size < cnt) pos.add(Math.floor(Math.random() * 254) + 1);
|
|
const markers = [0, ...[...pos].sort((a,b)=>a-b), 255];
|
|
let colors;
|
|
|
|
if (mode === 0) { // random
|
|
//console.log('random');
|
|
colors = markers.map(() => hslToHex(
|
|
Math.random() * 360,
|
|
Math.random() * 100,
|
|
Math.random() * 60 + 10
|
|
));
|
|
}
|
|
else { // harmonic triadic/tetradic
|
|
//console.log('harmonic');
|
|
const hcount = Math.random() < 0.5 ? 3 : 4;
|
|
const base = Math.random() * 360;
|
|
colors = markers.map((_, i) => hslToHex(
|
|
(base + 360 * (i % hcount) / hcount) % 360,
|
|
100,
|
|
Math.random() * 60 + 25 // chance for pastel colors
|
|
));
|
|
}
|
|
|
|
markers.forEach((t, i) =>
|
|
addMk(t, colors[i], (i === 0 || i === markers.length - 1) ? 1 : 0)
|
|
);
|
|
}
|
|
// Data
|
|
function toJSON() { return JSON.stringify({ palette: stops().flatMap(s => [s.t, s.c.slice(1)]) }); }
|
|
|
|
// convert wled palette array to CSS gradient string
|
|
function cssArr(a) {
|
|
let out = [];
|
|
for (let i = 0; i < a.length; i += 2) {
|
|
const t = a[i], v = a[i + 1];
|
|
out.push(typeof v === 'string' ? `#${v} ${t/255*100}%` : `rgba(${v},${a[i+2]},${a[i+3]},1) ${t/255*100}%`);
|
|
if (typeof v !== 'string') i += 2;
|
|
}
|
|
return 'linear-gradient(to right,' + out.join(',') + ')';
|
|
}
|
|
|
|
// accepts stops array: [pos, color, pos, color, ...] where color can be 'rrggbb' or [r,g,b]
|
|
function loadArr(a) {
|
|
gr.innerHTML = '';
|
|
for (let i = 0; i < a.length; i += 2) {
|
|
const t = a[i], v = a[i + 1];
|
|
const h = typeof v === 'string' ? '#' + v : '#' + ((v << 16) | (a[i + 2] << 8) | a[i + 3]).toString(16).padStart(6, '0');
|
|
if (typeof v !== 'string') i += 2;
|
|
addMk(t, h, (t === 0 || t === 255));
|
|
}
|
|
}
|
|
|
|
function normCat(k) { const n = (k || '').toLowerCase(); return n === 'themed' ? 'thematic' : n; }
|
|
|
|
// group external palettes into categories
|
|
function grpExt(d) {
|
|
const g = {colorful:[], thematic:[], pastel:[], striped:[], gradient:[], monochrome:[]};
|
|
const add = (p) => { const k = normCat(p.category || p.class); if (g[k]) g[k].push(p); };
|
|
if (d?.categories && isO(d.categories)) {
|
|
for (const [k, l] of Object.entries(d.categories)) {
|
|
const nk = normCat(k);
|
|
if (Array.isArray(l)) l.forEach(p => { if (g[nk]) g[nk].push(p); });
|
|
}
|
|
} else if (Array.isArray(d?.palettes)) d.palettes.forEach(add);
|
|
else if (Array.isArray(d)) d.forEach(add);
|
|
return g;
|
|
}
|
|
|
|
// UI
|
|
function mkPalItem(name, author, css, arr, save, remove) {
|
|
const itm = cE('div'), nam = cE('div'), pvw = cE('div'), prow = cE('div'), pv = cE('div');
|
|
itm.className = 'pal';
|
|
nam.className = 'nam';
|
|
nam.textContent = name;
|
|
if (author) {
|
|
const a = cE('span');
|
|
a.className = 'by';
|
|
a.textContent = ' by ' + author;
|
|
nam.appendChild(a);
|
|
}
|
|
|
|
pvw.style.alignItems = 'center';
|
|
pvw.style.flex = '1';
|
|
prow.className = 'prow';
|
|
pv.className = 'prv';
|
|
pv.style.background = css;
|
|
pv.title = 'Click to load';
|
|
pv.onclick = () => loadArr(arr);
|
|
pvw.append(nam, pv);
|
|
prow.appendChild(pvw);
|
|
if (save) prow.appendChild(save);
|
|
if (remove) prow.appendChild(remove);
|
|
itm.appendChild(prow);
|
|
return itm;
|
|
}
|
|
|
|
// Build the custom palette list (custom slots + empty slot handling)
|
|
// input: slots larger than "loaded" are set to 50% opacity to indicate they are not refreshed yet
|
|
function bldLst(loaded) {
|
|
const cd = gId('custom'), fc = d.createDocumentFragment();
|
|
cd.innerHTML = '';
|
|
let emptyslot = gId('empty');
|
|
emptyslot.innerHTML = '';
|
|
let foundEmpty = false;
|
|
|
|
cpal.forEach((p, i) => {
|
|
const sv = cE('button');
|
|
sv.className = 'sml';
|
|
sv.innerHTML = '⇦'; // alternative arrows: '↩' '←'
|
|
sv.title = 'Save to slot ' + i;
|
|
sv.onclick = () => upload(i);
|
|
|
|
const rm = cE('button');
|
|
rm.className = 'sml';
|
|
rm.title = 'Delete palette';
|
|
rm.innerHTML = '✖';
|
|
rm.onclick = () => { requestJson({rmcpal:i}); setTimeout(refr, 500); };
|
|
|
|
const name = isEmpty(p.palette) ? 'Empty slot' : 'Custom' + i;
|
|
const css = isEmpty(p.palette) ? '#666' : cssArr(p.palette);
|
|
const item = mkPalItem(name, null, css, p.palette, sv, rm);
|
|
const prv = item.querySelector('.prv');
|
|
prv.style.opacity = i > loaded ? 0.5 : 1; // set opacity of palette preview
|
|
|
|
// disable loading for empty palettes
|
|
if (isEmpty(p.palette) && !foundEmpty) {
|
|
foundEmpty = true;
|
|
prv.style.cursor = 'not-allowed';
|
|
prv.onclick = null;
|
|
item.querySelector('.nam').style.transform = 'translateY(100%)'; // move name down to preview center
|
|
emptyslot.appendChild(item);
|
|
} else if (!isEmpty(p.palette)) {
|
|
fc.appendChild(item);
|
|
}
|
|
});
|
|
|
|
cd.appendChild(fc);
|
|
gId('memWarn').style.display = (cpc > 10) ? 'block' : 'none'; // show warning if 10 or more custom palettes
|
|
}
|
|
|
|
// build categories UI from grouped palettes
|
|
function bldCat(extGrp) {
|
|
const h = gId('allCats');
|
|
if (!h) return;
|
|
h.innerHTML = '';
|
|
const cats = [
|
|
{k:'wled', lbl:'WLED Palettes', items:spal},
|
|
{k:'colorful', lbl:'Colorful', items:extGrp.colorful||[]},
|
|
{k:'thematic', lbl:'Thematic', items:extGrp.thematic||[]},
|
|
{k:'pastel', lbl:'Pastel', items:extGrp.pastel||[]},
|
|
{k:'striped', lbl:'Striped', items:extGrp.striped||[]},
|
|
{k:'gradient', lbl:'Gradient', items:extGrp.gradient||[]},
|
|
{k:'monochrome', lbl:'Monochrome', items:extGrp.monochrome||[]}
|
|
];
|
|
cats.forEach(({k, lbl, items}) => {
|
|
if (!items.length && k !== 'wled') return; // skip external categories if empty
|
|
const det = cE('details'), sum = cE('summary'), body = cE('div');
|
|
det.className = 'cat';
|
|
sum.innerHTML = `▸ ${lbl} (${items.length})`;
|
|
det.appendChild(sum);
|
|
body.className = 'cbdy';
|
|
|
|
items.forEach(p => {
|
|
if (k === 'wled') {
|
|
const key = Object.keys(p)[0];
|
|
body.appendChild(mkPalItem(p.name || ' ', null, cssArr(p[key]), p[key]));
|
|
} else {
|
|
body.appendChild(mkPalItem(p.name || ' ', p.author, cssArr(p.colors), p.colors));
|
|
}
|
|
});
|
|
det.appendChild(body);
|
|
h.appendChild(det);
|
|
det.addEventListener('toggle', () => {
|
|
sum.innerHTML = det.open ? `▾ ${lbl} (${items.length})` : `▸ ${lbl} (${items.length})`;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Network
|
|
function upload(i) {
|
|
const b = new Blob([toJSON()], {type:'application/json'});
|
|
const fakeFileObj = { files: [b] };
|
|
uploadFile(fakeFileObj, '/palette' + i + '.json');
|
|
localStorage.removeItem('wledPalx'); // invalidate main UI cache
|
|
setTimeout(refr, 300);
|
|
}
|
|
|
|
async function refr() {
|
|
try {
|
|
const inf = await fetch(getURL('/json/info'), {cache:'no-store'}).then(r=>r.json());
|
|
cpc = inf.cpalcount; cpm = inf.cpalmax;
|
|
await fetchC(cpc);
|
|
} catch(e) {}
|
|
}
|
|
|
|
// load custom palettes, loads from cache and updates in the background as palettes are coming in
|
|
async function fetchC(n) {
|
|
try {
|
|
const cached = localStorage.getItem('wledCustomPal');
|
|
if (cached) cpal = JSON.parse(cached);
|
|
if (cpal.length > n) cpal = cpal.slice(0, n);
|
|
} catch(e) {}
|
|
|
|
while (cpal.length < n) cpal.push({palette:[255]});
|
|
if (cpal.length < cpm) cpal.push({palette:[255]});
|
|
bldLst(0); // initial build, all previews at 50% opacity
|
|
|
|
// fetch and replace palettes as they load
|
|
for (let i = 0; i < n; i++) {
|
|
try {
|
|
const res = await fetch(getURL('/palette' + i + '.json'), {cache:'no-store'});
|
|
if (res.ok) {
|
|
cpal[i] = await res.json();
|
|
bldLst(i); // rebuild list, set opacity of this (and previous slots) to 100%
|
|
} else {
|
|
cpal[i] = {palette:[255]};
|
|
}
|
|
} catch(e) {
|
|
cpal[i] = {palette:[255]};
|
|
}
|
|
}
|
|
|
|
try {
|
|
localStorage.setItem('wledCustomPal', JSON.stringify(cpal));
|
|
} catch(e) {}
|
|
}
|
|
|
|
function tryCache() {
|
|
try {
|
|
const raw = localStorage.getItem('wledCptCityJson');
|
|
if (!raw) return false;
|
|
bldCat(grpExt(JSON.parse(raw)));
|
|
gId('btnFetchExt').style.display = 'none';
|
|
return true;
|
|
} catch (e) { localStorage.removeItem('wledCptCityJson'); return false; }
|
|
}
|
|
|
|
// download external palettes, these were hand picked from cpt-city (http://seaviewsensing.com/pub/cpt-city/)
|
|
// all palettes are licensed "free to use", converted to WLED JSON format by @dedehai
|
|
function fetchExt() {
|
|
fetch('https://dedehai.github.io/cpt_city_selection.json')
|
|
.then(r => { if (!r.ok) throw new Error(); return r.json(); })
|
|
.then(data => {
|
|
try { localStorage.setItem('wledCptCityJson', JSON.stringify(data)); } catch(e) {}
|
|
bldCat(grpExt(data));
|
|
gId('btnFetchExt').style.display = 'none';
|
|
})
|
|
.catch(() => { alert('Download failed'); });
|
|
}
|
|
|
|
// connect to WebSocket, use parent WS or open new
|
|
function connectWs(onOpen) {
|
|
try {
|
|
if (top.window.ws && top.window.ws.readyState === WebSocket.OPEN) {
|
|
if (onOpen) onOpen();
|
|
return top.window.ws;
|
|
}
|
|
} catch (e) {}
|
|
let url = loc ? getURL('/ws').replace("http","ws") : "ws://"+window.location.hostname+"/ws";
|
|
let w = new WebSocket(url);
|
|
w.binaryType = "arraybuffer";
|
|
if (onOpen) w.addEventListener('open', onOpen);
|
|
w.addEventListener('close', () => { ws = null; });
|
|
w.addEventListener('error', () => { ws = null; });
|
|
return w;
|
|
}
|
|
|
|
async function requestJson(cmd)
|
|
{
|
|
if (ws && ws.readyState == 1) {
|
|
try {
|
|
ws.send(JSON.stringify(cmd));
|
|
return 1;
|
|
} catch (e) {}
|
|
}
|
|
|
|
if (!window._httpQueue) {
|
|
window._httpQueue = [];
|
|
window._httpRun = 0;
|
|
}
|
|
if (_httpQueue.length >= 5) {
|
|
return Promise.resolve(-1); // reject if too many queued requests
|
|
}
|
|
|
|
return new Promise(resolve => {
|
|
_httpQueue.push({ cmd, resolve });
|
|
(async function run() {
|
|
if (_httpRun) return;
|
|
_httpRun = 1;
|
|
while (_httpQueue.length) {
|
|
let q = _httpQueue.shift();
|
|
try {
|
|
await fetch(getURL('/json'), {
|
|
method: 'post',
|
|
body: JSON.stringify(q.cmd),
|
|
cache: 'no-store'
|
|
});
|
|
} catch (e) {}
|
|
await new Promise(r => setTimeout(r, 120));
|
|
q.resolve(0);
|
|
}
|
|
_httpRun = 0;
|
|
})();
|
|
});
|
|
}
|
|
|
|
// apply palette preview to selected segments
|
|
async function applyLED()
|
|
{
|
|
if (!palCache.length) return;
|
|
try {
|
|
let st = await (await fetch(getURL('/json/state'), { cache: 'no-store' })).json();
|
|
if (!st.seg || !st.seg.length) return;
|
|
|
|
// get selected segments, use main segment if none selected
|
|
let segs = st.seg.filter(s => s.sel);
|
|
if (!segs.length) {
|
|
const mainSeg = st.seg.find(s => s.id === (st.mainseg || 0));
|
|
if (mainSeg) segs.push(mainSeg);
|
|
}
|
|
// show palette on each selected segment, 2D are treated as 1D to show better gradient
|
|
for (let s of segs) {
|
|
let len = (s.stop - s.start) * ((s.stopY - s.startY) || 1);
|
|
let arr = [];
|
|
for (let i = 0; i < len; i++)
|
|
arr.push(palCache[len > 1 ? Math.round(i * 255 / (len - 1)) : 0]);
|
|
// send colors in chunks
|
|
for (let j = 0; j < arr.length; j += maxCol) {
|
|
let chunk = [s.start + j, ...arr.slice(j, j + maxCol)];
|
|
await requestJson({ seg: { id: s.id, i: chunk } });
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Events
|
|
window.addEventListener('resize', recalc);
|
|
// Unfreeze segments when page is unloaded
|
|
window.addEventListener('beforeunload', () => {
|
|
if (prvEn) requestJson({seg:{frz:false}});
|
|
});
|
|
|
|
</script>
|
|
<style>
|
|
:root {
|
|
--pad:16px;
|
|
--maxw:820px;
|
|
}
|
|
body {
|
|
background: #111;
|
|
min-width: 320px; /* prevent layout breakdown */
|
|
}
|
|
.ctr {
|
|
max-width: calc(var(--maxw) + 2*var(--pad));
|
|
margin: 20px auto;
|
|
padding: 0 var(--pad);
|
|
display: flex; flex-direction: column; align-items: center;
|
|
}
|
|
|
|
header {
|
|
text-align: center;
|
|
margin-bottom: 10px;
|
|
width: 100%;
|
|
}
|
|
header h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
}
|
|
#pickerWrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
margin: 8px 0 12px;
|
|
width: 100%;
|
|
}
|
|
.bar {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
align-items: center;
|
|
margin: 8px 0;
|
|
width: 100%;
|
|
}
|
|
|
|
button.sml { min-width: 70px; }
|
|
|
|
#editor {
|
|
width: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 5;
|
|
padding: 6px 0;
|
|
background: #111;
|
|
}
|
|
#gradWrap {
|
|
position: relative;
|
|
height: 34px;
|
|
width: 100%;
|
|
max-width: var(--maxw);
|
|
touch-action: none; /* fixes click&drag on touch devices */
|
|
}
|
|
#grad {
|
|
height: 100%;
|
|
border-radius: 5px;
|
|
}
|
|
|
|
#empty {
|
|
position: sticky;
|
|
top: 15px;
|
|
z-index: 4;
|
|
padding: 4px 0;
|
|
}
|
|
|
|
.mk {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
width: 8px;
|
|
height: 34px;
|
|
border: 2px solid #5557;
|
|
border-radius: 5px;
|
|
touch-action: none;
|
|
}
|
|
.mk.sel {
|
|
outline: 2px solid #0bf;
|
|
border-color: #000;
|
|
}
|
|
@media (pointer: coarse) {
|
|
#gradWrap {
|
|
height: 60px; /* taller gradient for touch devices */
|
|
}
|
|
.mk {
|
|
height: 60px;
|
|
width: 20px; /* wider markers for touch devices */
|
|
}
|
|
#empty {
|
|
top: 40px; /* adjust for taller gradient */
|
|
}
|
|
}
|
|
|
|
section {
|
|
margin-top: 18px;
|
|
width: 100%;
|
|
}
|
|
.tbl {
|
|
background: #111;
|
|
width: 100%;
|
|
}
|
|
.lst {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
}
|
|
|
|
.pal {
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: relative;
|
|
margin-bottom: 5px;
|
|
}
|
|
.pal .nam {
|
|
text-align: center;
|
|
}
|
|
.pal .nam .by {
|
|
font-size: 11px;
|
|
margin-left: 4px;
|
|
}
|
|
.pal .prow {
|
|
display: flex;
|
|
align-items: flex-end;
|
|
gap: 10px;
|
|
}
|
|
.pal .prv {
|
|
flex: 1;
|
|
border-radius: 26px;
|
|
padding: 17px;
|
|
cursor: pointer;
|
|
}
|
|
.pal .sml {
|
|
min-width: 32px;
|
|
height: 32px;
|
|
padding: 0;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.cats {
|
|
width: 100%;
|
|
}
|
|
details.cat {
|
|
border: 1px solid #444;
|
|
border-radius: 8px;
|
|
margin: 10px 0;
|
|
background: #222;
|
|
}
|
|
details.cat > summary {
|
|
cursor: pointer;
|
|
padding: 8px 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
color: #fff;
|
|
list-style: none;
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
}
|
|
details.cat > summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.cbdy {
|
|
padding: 8px 10px 12px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
}
|
|
|
|
.rgbi {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin-top: 8px;
|
|
align-items: center;
|
|
}
|
|
.rgbi input {
|
|
width: 60px;
|
|
border-radius: 6px;
|
|
}
|
|
|
|
#memWarn {
|
|
display: none;
|
|
color: #ff9900;
|
|
font-size: 16px;
|
|
margin-bottom: 8px;
|
|
text-align: center;
|
|
}
|
|
#memWarn a { color: #ffcc00; text-decoration: underline; }
|
|
</style>
|
|
</body>
|
|
</html> |