fix(theme-builder): 图层换不了图、背景关不掉、草稿清不掉

三个问题都是交互设计造成的,核心逻辑其实没坏(实测换图是生效的):

1. 「每层都只有这张图、换不了」
   - 加层时 l.asset 默认绑 S.assets[0],连加三层全是同一张;
   - 换图的下拉只在「选中那一层」的编辑器里,用户根本看不出每层能各自换。
   改为:新层不自动绑图(素材池只剩一张时才绑),每行带缩略图 + 「图片」下拉直接换。

2. 「背景图一直保留、关不掉」
   - 「跟随全局」状态下背景下拉是 disabled,用户在箱主页看到背景还在又点不动。
   改为:一律可点,一改就自动把这一页切成独立配置,并把全局当前值复制过来当起点
   (改背景不会顺带把图案和图层弄丢;图层重新发 id,避免两个作用域共用一批 id)。

3. 「上次的数据关闭后还在」
   - 之前只能靠隐藏参数 ?fresh=1 绕过 localStorage 草稿,UI 上没有出口。
   顶栏加「新建」:清空工程 + 删草稿,二次确认。

顺带:
- 「加一层」不再因 inherit 而 disabled(点了自动切独立),改为独立后若全局层数已达 10 会明确提示;
- 跟随全局时不给拖拽框 —— 那时 cfgOf 返回的是 global 对象,一拖就改到全局去了;
- 编辑器里删掉重复的图片下拉,只留尺寸。

回归:?dump=layers 全绿 —— 加层后 asset 为 [null,null]、行内下拉 2 个、
换第 2 层后 [null,blue] 且 CSS 只出现 blue;box 页 inherit 时 addDisabled/bgDisabled 均为 false;
在 box 加层后 inherit=false、本页 3 层而 **全局仍是 2 层**(没被污染);
新建后 assets 0、层 0、草稿删除、scope 回 global。
另外 ?dump=fx 13 项、?dump=validate 空、生成的 CSS 过 TestThemeCSSComplete + TestDecoSlotCount。
This commit is contained in:
tamakyi
2026-10-20 15:56:59 +08:00
parent 927f378d9d
commit 937a8901b2
2 changed files with 294 additions and 49 deletions
+17
View File
@@ -51,6 +51,14 @@ http://127.0.0.1:8777/dev/theme-builder/
默认全部「跟随全局」;勾掉就变成独立配置,导出时才会产出覆盖块。
没配的页面自动沿用全局,不会出现「某页装饰消失」。
以前「跟随全局」时背景下拉和「加一层」都是 disabled —— 用户在箱主页看到背景还在、
又点不动,这就是「背景图关不掉」的来源。现在:
- 「加一层」「改背景」永远可点,**一改就自动把这一页切成独立配置**,并把全局当前值复制过来当起点
(改背景不会顺带把图案和图层弄丢;图层重新发 id,避免两个作用域共用一批 id,拖拽/高亮会串);
- 这种状态下图层列表不展示全局的层、也不给拖拽框 —— 否则一拖就改到全局去了;
- 想主动切换用「跟随全局」复选框,或用列表里的「改为独立配置」按钮。
(首页 `/` 与 `/explore` 不列出来 —— 全站公开页不注入 `ThemeSlug`,不加载主题 css,配了也没效果。)
**装饰图层** —— 每个作用域最多 10 层,三种类型:
@@ -61,6 +69,11 @@ http://127.0.0.1:8777/dev/theme-builder/
锚点用 9 宫格 + 像素偏移,生成 `calc(0% + 40px) calc(100% - 36px)` 这种两值形式。
**每一行都能直接换图**:行里带缩略图 + 「图片」下拉,一眼看到这层用的是哪张。
之前换图的下拉只出现在「选中那一层」的编辑器里,加上新层又默认绑第一张素材,
连加三层就全是同一张图,看起来像「每层只能显示这张、换不了」。
现在新加的层**不会**自动绑第一张(只有素材池只剩一张时才绑),行里显示「(选图片)」等你选。
**↑ / ↓ 调层级**。顺序就是层级:`background-image` 的第一层画在最上面,
所以「谁挡住谁」是靠顺序定的(shiroko 里自行车被人挡住就是这么来的)。
@@ -98,6 +111,8 @@ http://127.0.0.1:8777/dev/theme-builder/
调主题是连续几十次的活,所以配置必须留得住:
- **新建**:顶栏按钮,清空当前工程并删掉浏览器草稿(有二次确认)。
之前只有 `?fresh=1` 这个隐藏参数 —— 用户看到「上次的数据」一直在,就会以为关不掉。
- **自动草稿**:每次改动后 700ms 存进 `localStorage`,下次打开自动恢复。
图片多到顶破 5MB 配额时会提示改用 json(只提示一次)。
- **保存工程 / 打开工程**:`theme-<slug>.json`,图片以 base64 内嵌,一个文件就能带走;
@@ -139,6 +154,7 @@ INSTALL-<slug>.txt
```
?demo=1 载入示例素材(canvas 画的背景 + 剪影人物)
?rows=2 加 2 层并依次绑到不同素材(截图多图层效果用)
?scope=admin 直接落在某个作用域
?fresh=1 忽略 localStorage 草稿,从空白开始
?dump=css 把最终 CSS 写进 <pre id="dump">
@@ -147,6 +163,7 @@ INSTALL-<slug>.txt
?dump=install 把安装说明写进 <pre id="dump">
?dump=fx 跑处理算法自检(13 个断言,输出 JSON)
?dump=fxpipe 跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
?dump=layers 图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
?dump=fxkey 端到端去白底:载入白底图,统计透明像素比例
?loadurl=/static/images/x.webp&fx=x
直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
+277 -49
View File
@@ -70,17 +70,28 @@
.layers{display:flex;flex-direction:column;gap:5px}
.ly{
display:flex;align-items:center;gap:7px;padding:6px 8px;border-radius:6px;cursor:pointer;
display:flex;flex-direction:column;gap:5px;padding:6px 8px;border-radius:6px;cursor:pointer;
background:var(--ui-panel2);border:1px solid transparent;
}
.ly:hover{border-color:var(--ui-line)}
.ly.on{border-color:var(--ui-accent);background:#16303f}
.ly .top{display:flex;align-items:center;gap:7px}
.ly .dot{width:9px;height:9px;border-radius:3px;flex:0 0 auto;background:#4a5563}
/* 缩略图:一眼看出「这一层用的是哪张图」。没有它的话,
三层都绑着同一张图时,用户只能靠层名去猜。 */
.ly .th{width:22px;height:22px;border-radius:4px;object-fit:contain;flex:0 0 auto;
background:#0b0f14;border:1px solid var(--ui-line)}
.ly .th.no{display:flex;align-items:center;justify-content:center;font-size:11px;
color:#6f7c8d;border-style:dashed}
.ly .nm{flex:1;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ly .k{font-size:10px;color:var(--ui-text2)}
.ly .x{opacity:.5;padding:0 4px}
.ly .k{font-size:10px;color:var(--ui-text2);flex:0 0 auto}
.ly .pick{display:flex;align-items:center;gap:5px}
.ly .pick select{flex:1;min-width:0;font-size:11px;padding:2px 5px;height:24px}
.ly .pick .tag{font-size:10px;color:var(--ui-text2);flex:0 0 auto}
.ly .x{opacity:.5;padding:0 4px;flex:0 0 auto}
.ly .x:hover{opacity:1;color:var(--ui-bad)}
.ly .ord{opacity:.55;padding:0 5px;font-size:11px;line-height:1;border-color:transparent;background:none}
.ly .ord{opacity:.55;padding:0 5px;font-size:11px;line-height:1;border-color:transparent;
background:none;flex:0 0 auto}
.ly .ord:hover:not(:disabled){opacity:1;background:#232c37;border-color:var(--ui-line)}
.ly .ord:disabled{opacity:.18;cursor:default}
@@ -178,6 +189,7 @@
<span class="brand">TamaBox 主题生成器</span>
<label class="slug">slug <input id="slug" type="text" value="my-theme" spellcheck="false"></label>
<button id="btn-demo">载入示例</button>
<button id="btn-new" title="清空当前工程与浏览器草稿,从空白开始">新建</button>
<button id="btn-save">保存工程</button>
<button id="btn-open">打开工程</button>
<button id="btn-reload">重载画板</button>
@@ -288,15 +300,8 @@ const SCOPES = [
const SLOT = 10; // 装饰层槽位数,必须与 internal/theme.DecoSlots 一致
const VIEWPORTS = [1512, 1280, 1024, 820];
const S = {
slug: 'my-theme',
scope: 'global',
viewport: 1512,
mode: 'light',
assets: [],
seq: 0,
curLayer: null,
scopes: {},
/* 默认配色/窄屏抽出来,「新建」时能原样还原;S 里存的是副本,不会被重置污染 */
const D = {
palette: {
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
text:'#222d3b', primary:'#4a7fc1', border:'#d6e0ec',
@@ -311,6 +316,20 @@ const S = {
},
};
const S = {
slug: 'my-theme',
scope: 'global',
viewport: 1512,
mode: 'light',
assets: [],
seq: 0,
curLayer: null,
scopes: {},
palette: Object.assign({}, D.palette),
narrow: Object.assign({}, D.narrow),
dark: Object.assign({}, D.dark),
};
const PALETTE_KEYS = [
['bg','页面底色'], ['surface','卡片'], ['navbar','顶栏'],
['text','正文'], ['primary','主色'], ['border','描边'],
@@ -633,6 +652,9 @@ function syncOverlay(){
doc.documentElement.appendChild(ov);
}
ov.innerHTML = '';
// 跟随全局时不给拖拽框:这时 cfgOf 返回的是 global 的图层,
// 一拖就改到全局去了 —— 用户以为在调这一页,实际全站都变了。
if (S.scope !== 'global' && S.scopes[S.scope].inherit) return;
cfgOf(S.scope).layers.forEach(l => {
if (l.kind !== 'image') return;
@@ -726,14 +748,18 @@ function renderScopes(){
}
function renderBgEditor(){
const isGlobal = S.scope === 'global';
const inherit = !isGlobal && S.scopes[S.scope].inherit;
// 展示用 cfg:跟随全局时就是 global 那套(下拉里要能看到当前实际生效的值)
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
$('inherit').checked = S.scope !== 'global' && S.scopes[S.scope].inherit;
$('inherit').disabled = S.scope === 'global';
$('inherit').checked = inherit;
$('inherit').disabled = isGlobal;
const el = $('bg-editor');
el.innerHTML = '';
// 之前 inherit 时整个下拉是 disabled,用户在箱主页/问题页看背景还在、又点不动,
// 这就是「背景图一直保留、关不掉」的来源。现在一律可点:一改就自动变独立配置。
const mkPick = (label, key) => {
const row = document.createElement('div');
row.className = 'row';
@@ -744,8 +770,12 @@ function renderBgEditor(){
sel.innerHTML = '<option value="">(无)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = cfg[key] || '';
sel.disabled = !own;
sel.onchange = () => { cfg[key] = sel.value || null; renderAll(); };
sel.onchange = () => {
ensureOwn(true);
const c = S.scopes[S.scope];
c[key] = sel.value || null;
renderAll();
};
row.appendChild(sel);
el.appendChild(row);
};
@@ -759,15 +789,26 @@ function renderBgEditor(){
const inp = document.createElement('input');
inp.type = 'text';
inp.value = cfg.patternSize || '320px 320px';
inp.disabled = !own;
inp.style.width = '110px';
inp.onchange = () => { cfg.patternSize = inp.value.trim() || '320px 320px'; renderAll(); };
inp.onchange = () => {
ensureOwn(true);
S.scopes[S.scope].patternSize = inp.value.trim() || '320px 320px';
renderAll();
};
row.append(s, inp);
el.appendChild(row);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '背景图按 cover 铺满整页;平铺图案叠在背景图之上、装饰图层之下。';
if (inherit){
// 说清楚「你看到的背景是从哪来的」,不然用户会以为关掉的是本页的
const src = cfg.bg ? assetById(cfg.bg) : null;
tip.textContent = `当前全部继承自「全局」:背景 ${src ? src.name : '无'}`
+ `,图案 ${cfg.pattern && assetById(cfg.pattern) ? assetById(cfg.pattern).name : '无'}。`
+ '在这里改任意一项会自动变成本页独立配置。';
} else {
tip.textContent = '背景图按 cover 铺满整页;平铺图案叠在背景图之上、装饰图层之下。';
}
el.appendChild(tip);
}
@@ -787,54 +828,119 @@ function moveLayer(cfg, idx, delta){
renderAll();
}
/* 在任何「改动本页配置」之前调用:跟随全局的话先切成独立配置。
否则改动会写到 global 对象上 —— 用户以为只改了这一页,实际全站都变了。
copy=true 时把全局的当前值复制过来当起点(改背景不会顺带把图案弄丢);
图层要重新发 id,否则两个作用域共用同一批 id,拖拽/高亮会串。 */
function ensureOwn(copy){
if (S.scope === 'global') return false;
const c = S.scopes[S.scope];
if (!c.inherit) return false;
c.inherit = false;
if (copy){
const g = S.scopes.global;
c.bg = g.bg;
c.pattern = g.pattern;
c.patternSize = g.patternSize;
c.layers = g.layers.map(l => Object.assign({}, l, { id: uid('L') }));
}
S.curLayer = null;
const label = (SCOPES.find(x => x.k === S.scope) || {}).label || S.scope;
log(`「${label}」已改为独立配置(原来跟随全局)`
+ (copy && S.scopes.global.layers.length ? `,已复制全局那 ${S.scopes.global.layers.length} 层作起点` : '')
+ ',这里改的不影响别的页面', 'w');
return true;
}
function assetOptions(){
return '<option value="">(选图片)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
}
function renderLayers(){
const cfg = cfgOf(S.scope);
const own = S.scope === 'global' || !S.scopes[S.scope].inherit;
const cfg = cfgOf(S.scope);
const wrap = $('layers');
wrap.innerHTML = '';
$('add-layer').disabled = !own || cfg.layers.length >= SLOT;
// 「加一层」始终可用:跟随全局时点了会自动切成独立配置。
// 之前这里 inherit 就 disabled,用户在箱主页点不动,只会以为工具坏了。
$('add-layer').disabled = cfg.layers.length >= SLOT;
if (!own){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '这一页跟随全局。勾掉上方「跟随全局」即可单独配置。';
p.textContent = `这一页跟随全局,用的是全局那 ${cfg.layers.length} 层(不在这里改)。`;
wrap.appendChild(p);
const b = document.createElement('button');
b.textContent = '改为独立配置';
b.style.cssText = 'width:100%;margin-top:2px';
b.onclick = () => { ensureOwn(); renderAll(); };
wrap.appendChild(b);
$('layer-editor').innerHTML = '';
S.curLayer = null;
return;
}
if (!cfg.layers.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图层。点「+ 加一层」,可选图片 / 光斑 / 线性光。图片层选中后可以直接在右侧画板上拖动。';
p.textContent = '还没有图层。点「+ 加一层」,可选图片 / 光斑 / 线性光。图片层在画板上可以直接拖。';
wrap.appendChild(p);
}
cfg.layers.forEach((l, i) => {
const d = document.createElement('div');
d.className = 'ly' + (l.id === S.curLayer ? ' on' : '');
const a = l.kind === 'image' ? assetById(l.asset) : null;
const color = l.kind === 'image' ? '#5b9bd5' : l.kind === 'radial' ? '#d8a657' : '#9b7fd0';
// 顺序就是层级:background-image 的第一层画在最上面。所以要能调,
// 否则「谁挡住谁」配反了只能删了重加,位置尺寸全要重来。
d.innerHTML = `<i class="dot" style="background:${color}"></i>
<span class="nm">${esc(layerName(l))}</span>
<span class="k">${i === 0 ? '最上层' : '第 ' + (i+1) + ' 层'}</span>
<button class="ord" data-up="${l.id}" title="上移一层(更靠前)" ${i === 0 ? 'disabled' : ''}>↑</button>
<button class="ord" data-down="${l.id}" title="下移一层(更靠后)" ${i === cfg.layers.length - 1 ? 'disabled' : ''}>↓</button>
<button class="x" data-del="${l.id}" title="删除">×</button>`;
const thumb = l.kind !== 'image'
? `<span class="th no" style="color:${color}">~</span>`
: (a ? `<img class="th" src="${a.dataURL}" alt="">` : '<span class="th no">?</span>');
d.innerHTML = `<div class="top">
<i class="dot" style="background:${color}"></i>${thumb}
<span class="nm">${esc(layerName(l))}</span>
<span class="k">${i === 0 ? '最上层' : '第 ' + (i + 1) + ' 层'}</span>
<button class="ord" data-up title="上移一层(更靠前)" ${i === 0 ? 'disabled' : ''}>↑</button>
<button class="ord" data-down title="下移一层(更靠后)" ${i === cfg.layers.length - 1 ? 'disabled' : ''}>↓</button>
<button class="x" data-del title="删除">×</button>
</div>`;
// 图片层:换图的下拉直接放在行里,不必先选中再到底下的编辑器里找。
// 之前只有「选中那一层」的编辑器里有下拉,于是看起来像「每层都只能用同一张图」。
if (l.kind === 'image'){
const pick = document.createElement('div');
pick.className = 'pick';
const tag = document.createElement('span');
tag.className = 'tag';
tag.textContent = '图片';
const sel = document.createElement('select');
sel.innerHTML = assetOptions();
sel.value = l.asset || '';
sel.onclick = e => e.stopPropagation();
sel.onchange = () => {
l.asset = sel.value || null;
// 只有一张图时,切到「(选图片)」等于这层没东西,直接说清楚
if (!l.asset) log('这一层暂未选图片,画布上不会显示', 'w');
renderAll();
};
pick.append(tag, sel);
d.appendChild(pick);
}
d.querySelector('[data-up]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, -1); };
d.querySelector('[data-down]').onclick = e => { e.stopPropagation(); moveLayer(cfg, i, 1); };
d.onclick = e => {
if (e.target.dataset.del || e.target.dataset.up || e.target.dataset.down) return;
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
};
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
cfg.layers = cfg.layers.filter(x => x.id !== l.id);
if (S.curLayer === l.id) S.curLayer = null;
renderAll();
};
d.onclick = () => {
S.curLayer = l.id;
renderLayers();
highlightGuide(l.id);
};
wrap.appendChild(d);
});
@@ -874,12 +980,7 @@ function renderLayerEditor(l){
};
if (l.kind === 'image'){
const sel = document.createElement('select');
sel.innerHTML = '<option value="">(选图片)</option>' +
S.assets.map(a => `<option value="${a.id}">${esc(a.name)}</option>`).join('');
sel.value = l.asset || '';
sel.onchange = () => { l.asset = sel.value || null; renderAll(); };
row('图片', sel);
// 换图的下拉在图层行里,这里只放尺寸,避免同一件事有两个入口
row('高度 px', numInput(l.h, 20, 4000, v => { l.h = v; renderAll(); }));
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
wIn.title = '0 表示按图片原始比例自动算宽度';
@@ -1173,6 +1274,28 @@ function saveDraft(){
}
}
}
/** 清空到空白状态,并删掉浏览器里的草稿。
之前只有 ?fresh=1 这个隐藏参数:用户看到「上次的数据」一直在,也只能以为关不掉。 */
function resetAll(){
if (!confirm('清空当前工程?\n未导出的图层、配色、素材都会被删掉。建议先点「保存工程」。')) return;
try { localStorage.removeItem(LS_KEY); } catch (_) {}
S.slug = 'my-theme';
S.scope = 'global';
S.mode = 'light';
S.curLayer = null;
S.assets = [];
S.palette = Object.assign({}, D.palette);
S.narrow = Object.assign({}, D.narrow);
S.dark = Object.assign({}, D.dark);
S.scopes = {};
SCOPES.forEach(sc => { S.scopes[sc.k] = blankScope(sc.k); });
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
log('已清空(浏览器草稿一并删掉),从空白开始', 'ok');
}
function scheduleSave(){
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 700);
@@ -1949,15 +2072,31 @@ function boot(){
$('slug').addEventListener('input', () => { S.slug = $('slug').value.trim() || 'my-theme'; applyTheme(); });
$('btn-reload').onclick = () => { loadPreview(); log('重载画板'); };
$('btn-demo').onclick = () => makeDemo();
$('btn-new').onclick = resetAll;
$('btn-export').onclick = () => doExport();
$('add-layer').onclick = () => {
const cfg = cfgOf(S.scope);
if (cfg.layers.length >= SLOT) return;
const l = newLayer(S.assets.length ? 'image' : 'radial');
if (l.kind === 'image' && S.assets.length) l.asset = S.assets[0].id;
// 跟随全局时先切成独立配置(并把全局那几层复制过来当起点),
// 否则改动会写到 global 上,别页面也会跟着变。
ensureOwn(true);
const cfg = S.scopes[S.scope];
if (cfg.layers.length >= SLOT){
renderAll();
log(`装饰层最多 ${SLOT} 个槽位`, 'w');
return;
}
const kind = S.assets.length ? 'image' : 'radial';
const l = newLayer(kind);
// 多个素材时**不**自动绑图:连加三层会全是同一张,看起来就像「每层只能显示这张、换不了」。
// 只有一个素材时才绑它(没得选,省一步)。
if (kind === 'image' && S.assets.length === 1) l.asset = S.assets[0].id;
cfg.layers.push(l);
S.curLayer = l.id;
renderAll();
const sel = $('layers').querySelector('.ly.on select');
if (sel) sel.focus();
if (kind === 'image' && !l.asset){
log('新图层还没选图片 —— 在这一层的「图片」下拉里挑一张', 'w');
}
};
$('btn-save').onclick = () => {
const errs = validate();
@@ -2008,6 +2147,17 @@ function boot(){
window.addEventListener('resize', fitStage);
if (params.get('demo')) makeDemo();
// ?rows=N 加 N 层并依次绑到不同素材(截图 / 人工核对多图层效果用)
const rows = parseInt(params.get('rows') || '0', 10);
for (let i = 0; i < rows; i++){
$('add-layer').click();
const c0 = S.scopes[S.scope];
const l0 = c0.layers[c0.layers.length - 1];
if (l0 && l0.kind === 'image' && S.assets.length){
l0.asset = S.assets[i % S.assets.length].id;
}
renderAll();
}
// ?loadurl=<站点路径> 直接把项目里的图当素材载入(不必先下载再上传)
const loadURL = params.get('loadurl');
if (loadURL){
@@ -2039,6 +2189,84 @@ function boot(){
pre.textContent = installTxt(S.slug);
} else if (dump === 'fx'){
pre.textContent = JSON.stringify(fxSelfTest(), null, 1);
} else if (dump === 'layers'){
// 回归用户反馈:加层全是同一张图 / 换不了;背景关不掉;跟随全局时点不动
(async () => {
const r = {};
try {
const mk = (id, w, h, color) => {
const cv = document.createElement('canvas');
cv.width = w; cv.height = h;
const g = cv.getContext('2d');
g.fillStyle = color; g.fillRect(0, 0, w, h);
const conv = canvasAsset(cv, 0.9);
return { id, name: id + '.webp', orig: { dataURL: conv.dataURL, w, h },
fx: fxDefault(), ...conv };
};
S.assets = [mk('red', 100, 100, '#ff0000'), mk('blue', 200, 200, '#0000ff')];
$('add-layer').click();
$('add-layer').click();
const cfg = S.scopes.global;
r.afterAdd = cfg.layers.map(l => l.asset); // 期望 [null, null]
r.rowSelects = $('layers').querySelectorAll('select').length; // 期望 2
r.addDisabled = $('add-layer').disabled;
// 行内换图:把第 2 层改成 blue
const sels = [...$('layers').querySelectorAll('select')];
sels[1].value = 'blue';
sels[1].dispatchEvent(new Event('change'));
r.afterChange = cfg.layers.map(l => l.asset); // 期望 [null, 'blue']
r.cssUrls = [...buildCSS(false).matchAll(/themes\/[a-z0-9-]+\/([a-z0-9-]+)\.webp/g)]
.map(m => m[1]);
// 切到 box(默认跟随全局)
S.scope = 'box'; S.curLayer = null; renderAll();
r.box = {
inherit: S.scopes.box.inherit,
addDisabled: $('add-layer').disabled,
bgDisabled: $('bg-editor').querySelector('select').disabled,
layerRows: $('layers').querySelectorAll('.ly').length,
overlayGuides: doc ? doc.querySelectorAll('#__tb_ov > div').length : -1,
};
// 在 box 点「加一层」→ 自动变独立 + 复制全局那两层,全局不能被改
$('add-layer').click();
r.boxAfterAdd = {
inherit: S.scopes.box.inherit,
layers: S.scopes.box.layers.length, // 2 复制 + 1 = 3
globalLayers: S.scopes.global.layers.length, // 必须还是 2
};
// 背景:inherit 页原来点不动,现在能改,且改完自动独立
const bsel = $('bg-editor').querySelector('select');
bsel.value = 'red';
bsel.dispatchEvent(new Event('change'));
r.boxBg = {
boxBg: S.scopes.box.bg,
globalBg: S.scopes.global.bg,
inherit: S.scopes.box.inherit,
cssHasBg: /--tb-page-image: url/.test(buildCSS(true)),
};
// 新建:清空 + 删草稿
const oc = window.confirm;
window.confirm = () => true;
resetAll();
window.confirm = oc;
r.afterReset = {
assets: S.assets.length,
globalLayers: S.scopes.global.layers.length,
boxInherit: S.scopes.box.inherit,
scope: S.scope,
draft: localStorage.getItem(LS_KEY) === null ? '已删' : '还在',
slug: $('slug').value,
};
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fxkey'){
// 端到端:把「白底立绘」载入 → 去白底 → 统计透明像素比例。
// 与手写 floodfill 抠出来的那张(46.6% 透明)对比,差值应很小。