From 937a8901b2f4431e2951342f621824524c56bced Mon Sep 17 00:00:00 2001 From: tamakyi Date: Tue, 20 Oct 2026 15:56:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(theme-builder):=20=E5=9B=BE=E5=B1=82?= =?UTF-8?q?=E6=8D=A2=E4=B8=8D=E4=BA=86=E5=9B=BE=E3=80=81=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E5=85=B3=E4=B8=8D=E6=8E=89=E3=80=81=E8=8D=89=E7=A8=BF=E6=B8=85?= =?UTF-8?q?=E4=B8=8D=E6=8E=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个问题都是交互设计造成的,核心逻辑其实没坏(实测换图是生效的): 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。 --- dev/theme-builder/README.md | 17 ++ dev/theme-builder/index.html | 326 +++++++++++++++++++++++++++++------ 2 files changed, 294 insertions(+), 49 deletions(-) diff --git a/dev/theme-builder/README.md b/dev/theme-builder/README.md index bb455b8..f8ab921 100644 --- a/dev/theme-builder/README.md +++ b/dev/theme-builder/README.md @@ -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-.json`,图片以 base64 内嵌,一个文件就能带走; @@ -139,6 +154,7 @@ INSTALL-.txt ``` ?demo=1 载入示例素材(canvas 画的背景 + 剪影人物) +?rows=2 加 2 层并依次绑到不同素材(截图多图层效果用) ?scope=admin 直接落在某个作用域 ?fresh=1 忽略 localStorage 草稿,从空白开始 ?dump=css 把最终 CSS 写进
@@ -147,6 +163,7 @@ INSTALL-.txt
 ?dump=install      把安装说明写进 
 ?dump=fx           跑处理算法自检(13 个断言,输出 JSON)
 ?dump=fxpipe       跑弹窗链路冒烟(打开→改参→预览→落盘→无改动不重编码)
+?dump=layers       图层/作用域回归(加层默认不绑图、行内换图、自动切独立、新建清空)
 ?dump=fxkey        端到端去白底:载入白底图,统计透明像素比例
 ?loadurl=/static/images/x.webp&fx=x
                    直接把项目里的图当素材载入(不必先下载再上传),fx= 打开处理面板
diff --git a/dev/theme-builder/index.html b/dev/theme-builder/index.html
index 9413ea9..e6680ec 100644
--- a/dev/theme-builder/index.html
+++ b/dev/theme-builder/index.html
@@ -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 @@
   TamaBox 主题生成器
   
   
+  
   
   
   
@@ -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 = '' +
       S.assets.map(a => ``).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 '' +
+    S.assets.map(a => ``).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 = `
-      ${esc(layerName(l))}
-      ${i === 0 ? '最上层' : '第 ' + (i+1) + ' 层'}
-      
-      
-      `;
+    const thumb = l.kind !== 'image'
+      ? `~`
+      : (a ? `` : '?');
+
+    d.innerHTML = `
+ ${thumb} + ${esc(layerName(l))} + ${i === 0 ? '最上层' : '第 ' + (i + 1) + ' 层'} + + + +
`; + + // 图片层:换图的下拉直接放在行里,不必先选中再到底下的编辑器里找。 + // 之前只有「选中那一层」的编辑器里有下拉,于是看起来像「每层都只能用同一张图」。 + 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 = '' + - S.assets.map(a => ``).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% 透明)对比,差值应很小。