Files
tamabox/dev/theme-builder/index.html
T
tamakyi 6c014b3ad2 修 test01:窄屏装饰全藏导致素材消失、主题名显示成 i18n key
- 生成器:勾「窄屏隐藏全部装饰层」时给橙色警告(几层会消失)、图层行标「窄屏隐藏」、加层与导出结果再提醒;「窄屏」下拉在被全藏覆盖时标注不生效
- 新增 ?narrow=hide 复现该状态(这是「素材在手机上看不到」的头号来源:@media 把 10 槽全写 none,桌面完全正常)
- 主题名:新增 theme.DisplayName + 模板函数 themeName,i18n 未登记 theme.name.<slug> 时回退 slug;原来模板直接调用 i18n.T,缺词条就显示 "theme.name.test01"
- test01.css 删掉 @media 全 none 段:装饰是 fit 单位,浏览器会自己按视口缩,不需要这段
- StaticVersion → 20261020g(主题 css 内容变了,必须换缓存键,否则浏览器还会用旧文件)
2026-10-20 18:23:21 +08:00

2988 lines
125 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>TamaBox 主题生成器</title>
<style>
:root{
--ui-bg:#0f1216; --ui-panel:#161b22; --ui-panel2:#1c232c; --ui-line:#28313d;
--ui-text:#dbe3ee; --ui-text2:#8b98a9; --ui-accent:#5b9bd5; --ui-ok:#4bb27d; --ui-bad:#d9636b;
--ui-warn:#d8a657;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0;background:var(--ui-bg);color:var(--ui-text);
font:13px/1.55 -apple-system,"Segoe UI",system-ui,"Microsoft YaHei",sans-serif;
display:flex;flex-direction:column;overflow:hidden;
}
button,input,select{font:inherit;color:inherit}
button{
background:var(--ui-panel2);border:1px solid var(--ui-line);color:var(--ui-text);
border-radius:6px;padding:5px 10px;cursor:pointer;transition:.15s;
}
button:hover{background:#232c37;border-color:#3a4756}
button.primary{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
button.primary:hover{background:#6fa9dd}
button.on{background:var(--ui-accent);border-color:var(--ui-accent);color:#08121c;font-weight:600}
input[type=text],input[type=number],select{
background:#0d1117;border:1px solid var(--ui-line);border-radius:6px;padding:4px 7px;width:100%;
}
input[type=number]{width:72px}
input[type=color]{
width:34px;height:26px;padding:0;border:1px solid var(--ui-line);border-radius:5px;background:none;cursor:pointer;
}
label.chk{font-size:11px;color:var(--ui-text2);display:inline-flex;gap:4px;align-items:center;font-weight:400}
h2{
font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--ui-text2);
margin:0 0 8px;display:flex;align-items:center;justify-content:space-between;font-weight:600;
}
h2 .r{display:flex;gap:6px}
.bar{
flex:0 0 auto;display:flex;align-items:center;gap:12px;padding:0 14px;height:48px;
background:var(--ui-panel);border-bottom:1px solid var(--ui-line);
}
.brand{font-weight:600;letter-spacing:.02em}
.bar .slug{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ui-text2)}
.bar .slug input{width:150px}
.bar .spacer{flex:1}
.conn{font-size:11px;padding:3px 8px;border-radius:999px;border:1px solid var(--ui-line)}
.conn.ok{color:var(--ui-ok);border-color:#2f5744}
.conn.bad{color:var(--ui-bad);border-color:#5a3036}
main{flex:1;display:grid;grid-template-columns:352px minmax(0,1fr);min-height:0}
.panel{overflow:auto;border-right:1px solid var(--ui-line);background:var(--ui-panel);padding:14px}
.panel section{margin-bottom:18px}
.scopes{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.sc{
text-align:left;padding:7px 9px;border-radius:7px;line-height:1.3;background:var(--ui-panel2);
border:1px solid var(--ui-line);
}
.sc b{display:block;font-size:12px;font-weight:600}
.sc span{font-size:10px;color:var(--ui-text2)}
.sc.on{border-color:var(--ui-accent);background:#16303f}
.sc.on b{color:#9fc8ea}
.sc .dirty{color:var(--ui-warn);font-size:10px}
.hint{font-size:11px;color:var(--ui-text2);margin:8px 0 0;line-height:1.5}
.layers{display:flex;flex-direction:column;gap:5px}
.ly{
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);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;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}
.editor{margin-top:10px;padding:10px;border-radius:8px;background:#12171e;border:1px solid var(--ui-line)}
.row{display:flex;align-items:center;gap:8px;margin-bottom:7px}
.row:last-child{margin-bottom:0}
.row > span{font-size:11px;color:var(--ui-text2);min-width:58px;flex:0 0 auto;white-space:nowrap}
.grid9{display:grid;grid-template-columns:repeat(3,24px);gap:3px}
.grid9 button{padding:0;height:22px;border-radius:4px;font-size:0}
.grid9 button.on{background:var(--ui-accent)}
.assets{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}
.as{
position:relative;aspect-ratio:1;border-radius:6px;overflow:hidden;background:#0b0f14;
border:1px solid var(--ui-line);cursor:pointer;
}
.as img{width:100%;height:100%;object-fit:contain;display:block}
.as.on{border-color:var(--ui-accent)}
.as .del{
position:absolute;top:1px;right:2px;font-size:11px;line-height:1;color:#fff;background:#0008;
border-radius:4px;padding:1px 3px;display:none;
}
.as:hover .del{display:block}
.as .meta{
position:absolute;inset:auto 0 0 0;font-size:9px;text-align:center;background:#000a;color:#cbd6e4;
padding:1px 0;
}
/* 处理入口常驻(不靠 hover):处理过的素材要能一眼看出来,否则
同一张图有「原图」和「压白 16% 版」两副样子,改完就分不清哪张是现在的 */
.as .fx{
position:absolute;top:2px;left:1px;font-size:10px;line-height:1;font-weight:600;
color:#cbd6e4;background:#0009;border-radius:4px;padding:2px 3px;cursor:pointer;
}
.as .fx:hover{background:var(--ui-accent);color:#08121c}
.as .fx.on{background:var(--ui-accent);color:#08121c}
/* ---------- 图片处理弹窗 ---------- */
.modal{position:fixed;inset:0;background:#000b;z-index:50;display:none;align-items:center;justify-content:center}
.modal.on{display:flex}
.mbox{
width:min(900px,94vw);max-height:90vh;display:flex;flex-direction:column;
background:var(--ui-panel);border:1px solid var(--ui-line);border-radius:10px;
overflow:hidden;box-shadow:0 24px 64px #000a;
}
.mbox header{display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid var(--ui-line)}
.mbox header .t{font-weight:600}
.mbox header .sub{font-size:11px;color:var(--ui-text2)}
.mbox header .spacer{flex:1}
.mbody{display:grid;grid-template-columns:minmax(0,1fr) 252px;flex:1;min-height:0}
.mprev{
display:flex;align-items:center;justify-content:center;padding:18px;min-height:0;min-width:0;
/* 棋盘格:不透明度是这一屏唯一看不出来的东西,必须有背景参照 */
background:conic-gradient(#1a2029 25%, #0d1117 0 50%, #1a2029 0 75%, #0d1117 0) 0 0/16px 16px;
}
.mprev canvas{max-width:100%;max-height:58vh;display:block;box-shadow:0 6px 20px #0006}
.mparams{padding:14px;border-left:1px solid var(--ui-line);overflow:auto}
.fxrow{margin-bottom:13px}
.fxrow .lab{display:flex;justify-content:space-between;align-items:baseline;font-size:11px;color:var(--ui-text2);margin-bottom:2px}
.fxrow .lab b{color:var(--ui-text);font-weight:600;font-variant-numeric:tabular-nums}
.fxrow input[type=range]{width:100%;accent-color:var(--ui-accent);margin:0}
.fxrow .sub{font-size:10px;color:#6f7c8d;margin-top:2px}
.fxrow.chkrow{margin-bottom:8px}
.mbox footer{display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--ui-line)}
.mbox footer .info{flex:1;font-size:11px;color:var(--ui-text2)}
.pal{display:flex;flex-direction:column;gap:5px}
.pal .p{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--ui-text2)}
.pal .p input[type=text]{width:100%;font-size:11px;padding:2px 6px}
.stage{display:flex;flex-direction:column;min-width:0;min-height:0;background:#0b0e12}
.stagebar{
flex:0 0 auto;display:flex;align-items:center;gap:8px;padding:7px 12px;
border-bottom:1px solid var(--ui-line);background:var(--ui-panel);font-size:11px;color:var(--ui-text2);
}
.stagebar .g{display:flex;gap:4px}
.stagebar .url{margin-left:auto;font-family:ui-monospace,Consolas,monospace;font-size:10px;color:#5f6c7c}
.canvas{flex:1;overflow:auto;padding:14px;min-height:0;min-width:0;position:relative;
display:flex;justify-content:center;align-items:center}
.frame{position:relative;overflow:hidden;border-radius:8px;background:#fff;box-shadow:0 12px 40px -12px #000a}
iframe{display:block;border:0;transform-origin:top left;background:#fff}
.out{
flex:0 0 auto;padding:7px 12px 9px;font-size:11px;color:var(--ui-text2);white-space:pre-wrap;
word-break:break-word;max-height:96px;overflow:auto;border-top:1px solid var(--ui-line);background:var(--ui-panel);
}
.out .e{color:var(--ui-bad)} .out .ok{color:var(--ui-ok)} .out .w{color:var(--ui-warn)}
.empty{position:absolute;inset:0;display:flex;flex-direction:column;gap:8px;align-items:center;justify-content:center;
color:#7c8a9c;font-size:13px;text-align:center;padding:24px;z-index:5;background:#0b0e12}
.empty b{color:#a9b8ca}
.empty code{background:#161b22;border:1px solid var(--ui-line);border-radius:5px;padding:6px 9px;
font-family:ui-monospace,Consolas,monospace;font-size:11px;color:#9fc8ea}
</style>
</head>
<body>
<header class="bar">
<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>
<span class="spacer"></span>
<span class="conn" id="conn">检测中…</span>
<button id="btn-export" class="primary">导出 zip</button>
</header>
<main>
<aside class="panel">
<section>
<h2>页面</h2>
<div class="scopes" id="scopes"></div>
<p class="hint" id="scope-hint"></p>
</section>
<section>
<h2>装饰图层 <span class="r"><button id="add-layer">+ 加一层</button></span></h2>
<div class="layers" id="layers"></div>
<div id="layer-editor"></div>
</section>
<section>
<h2>整页背景 <label class="chk"><input type="checkbox" id="inherit"> 跟随全局</label></h2>
<div id="bg-editor"></div>
</section>
<section>
<h2>窄屏</h2>
<div id="narrow-editor"></div>
</section>
<section>
<h2>图片池 <span class="r"><button id="add-asset">上传</button></span></h2>
<div class="assets" id="assets"></div>
<p class="hint">透明底 PNG / WebP 最好。上传后按下面的画质设置转 WebP。</p>
<input type="file" id="file" accept="image/*" multiple hidden>
<input type="file" id="projfile" accept=".json,application/json" hidden>
</section>
<section>
<h2>导出画质</h2>
<div id="export-editor"></div>
</section>
<section>
<h2>配色</h2>
<div class="pal" id="palette"></div>
</section>
</aside>
<section class="stage">
<div class="stagebar">
<span>视口</span>
<span class="g" id="vps"></span>
<span style="margin-left:10px">模式</span>
<span class="g" id="modes"></span>
<span class="url" id="stage-url"></span>
</div>
<div class="canvas" id="canvas">
<div class="frame" id="frame"><iframe id="preview"></iframe></div>
<div class="empty" id="empty">
<b>需要本地服务才能预览真实页面</b>
<span>在项目根执行:</span>
<code>python dev/theme-builder/serve.py</code>
<span>然后打开 http://127.0.0.1:8777/dev/theme-builder/</span>
</div>
</div>
<div class="out" id="out"></div>
</section>
</main>
<!-- 图片处理:非破坏。参数存在 fx 里,每次都从 orig 重算,
所以「压白 16% → 再改 20%」不会叠加两次压缩,也不会累积锯齿。 -->
<div class="modal" id="fxmodal">
<div class="mbox">
<header>
<span class="t">处理图片</span>
<span class="sub" id="fx-name"></span>
<span class="spacer"></span>
<button id="fx-close">×</button>
</header>
<div class="mbody">
<div class="mprev"><canvas id="fx-canvas"></canvas></div>
<div class="mparams" id="fx-params"></div>
</div>
<footer>
<span class="info" id="fx-info"></span>
<button id="fx-reset">重置</button>
<button id="fx-apply" class="primary">完成</button>
</footer>
</div>
</div>
<script>
'use strict';
/* ============================================================================
* 页面作用域:必须与 Go 侧 internal/context.pageScope() 的返回值一一对应,
* 否则生成的 data-page-scope 选择器在线上永远匹配不到。
* ========================================================================== */
/* 注意:首页 / 与 /explore 不在这里 —— 全站公开页不注入 ThemeSlug,也就不会加载主题 css,
给它们配装饰不会有任何效果(Go 侧 pageScope 仍会返回 home/explore,留给以后做站点默认主题)。
这里列出的每一个都确认过线上会加载主题。 */
const SCOPES = [
{k:'global', label:'全局', desc:'所有页面默认', page:null},
{k:'box', label:'箱主页', desc:'/_/{domain}', page:'box'},
{k:'question', label:'问题页', desc:'/_/{domain}/{id}', page:'question'},
{k:'admin', label:'题目列表', desc:'/user/questions', page:'admin'},
{k:'profile', label:'设置页', desc:'/user/*', page:'profile'},
{k:'vote', label:'投票页', desc:'/vote/{token}', page:'vote'},
];
const SLOT = 10; // 装饰层槽位数,必须与 internal/theme.DecoSlots 一致
/* 视口预设带高度:桌面主题几乎全部按「宽」调,但装饰层的裁切是「高」决定的
—— 同一个 400px 立绘在 900 高的笔记本上占 44%,在 768 的旧笔电上占 52%,贴底时就开始吃人物。
列表里放一档真机竖屏尺寸,才能当场看见「手机上被裁成一条」。 */
const VIEWPORTS = [
{ w: 1512, h: 900, label: '1512×900 桌机' },
{ w: 1280, h: 800, label: '1280×800 笔记本' },
{ w: 1024, h: 768, label: '1024×768 小笔电' },
{ w: 820, h: 1180, label: '820×1180 平板竖' },
{ w: 390, h: 844, label: '390×844 手机' },
];
const vpOf = w => VIEWPORTS.find(v => v.w === w) || VIEWPORTS[0];
/* 自适应尺寸的设计基准视口高:`fit` 单位把「当前 h」当成 900 高下的效果,
视口更高就封顶、更矮就等比缩小(下限 55%),一次解决「小屏被裁、大屏太小」。 */
const DESIGN_H = 900;
/* `fitw` 按宽度自适应的基准视口宽(桌机档)。宽幅素材(横幅、整屏插画)只能按宽缩:
按高度缩的话宽度会跟着比例涨,1280 的笔记本上照样裁两侧。 */
const DESIGN_W = 1512;
const MOB_SCALE_DEFAULT = 0.62;
/* 默认配色/窄屏抽出来,「新建」时能原样还原;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',
},
/* 窄屏:手机是「卡片占满宽度」,桌面那套 16:9 场景会被 cover 裁成一条无意义纹理。
这一份是全局的(不分页面)—— 窄屏的问题是空间不够,各页面诉求差别不大。
scale 是「每层策略选缩放」时的系数;hideDeco 是「一次全藏」的快捷开关。 */
narrow: { enabled: true, bg: null, hideDeco: false, maxWidth: 900, scale: MOB_SCALE_DEFAULT },
/* 导出画质:webp 体积 ≈ 像素数 × 质量。压体积最有效的不是 q,是先砍像素
—— CSS 里显示 400px 高的图,2 倍屏只需要 800px,上传 3000px 的立绘纯属白扛。 */
export: { quality: 0.88, maxSide: 1600 },
dark: {
bg:'#141a22', surface:'#1d2530', navbar:'#141a22',
text:'#e3eaf4', primary:'#6fa3e0', border:'#334052',
},
};
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),
export: Object.assign({}, D.export),
dark: Object.assign({}, D.dark),
};
const PALETTE_KEYS = [
['bg','页面底色'], ['surface','卡片'], ['navbar','顶栏'],
['text','正文'], ['primary','主色'], ['border','描边'],
];
function blankScope(k){
return { inherit: k !== 'global', bg: null, pattern: null, patternSize: '320px 320px',
bgPos: 'center center', layers: [] };
}
SCOPES.forEach(s => { S.scopes[s.k] = blankScope(s.k); });
/* ---------------------------- 小工具 ---------------------------- */
const $ = id => document.getElementById(id);
const esc = s => String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const clamp = (v,a,b) => Math.min(b, Math.max(a, v));
const uid = p => p + (++S.seq);
function out(msg, cls){
const el = $('out');
el.innerHTML = cls ? `<span class="${cls}">${esc(msg)}</span>` : esc(msg);
}
function log(msg, cls){
const el = $('out');
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = msg + '\n';
el.appendChild(span);
el.scrollTop = el.scrollHeight;
}
function assetById(id){ return S.assets.find(a => a.id === id) || null; }
function slugOK(s){ return /^[a-z0-9][a-z0-9-]{0,31}$/.test(s); }
/** 当前作用域生效的配置:没勾「独立配置」就用全局那套。 */
function cfgOf(k){
const c = S.scopes[k];
if (k === 'global' || !c.inherit) return c;
return S.scopes.global;
}
/* ---------------------- 位置 / 尺寸 → CSS ---------------------- */
/* background-position 的百分比是相对 (容器 - 图片),不是相对容器。
统一写成 calc(0%|50%|100% ± N) 两值形式:0% = 左/上对齐,50% = 居中,100% = 右/下对齐。
不用 left/top 关键字,是因为「水平居中 + 垂直贴底」会变成三值/四值语法,
一旦不合规范整条声明作废,图层会静默掉到左上角。 */
const unitOf = l => (l.unit === 'fit' || l.unit === 'vh') ? 'vh'
: (l.unit === 'fitw') ? 'vw' : 'px';
function axisPos(anchor, off, unit){
const pct = anchor === 'center' ? '50%' : (anchor === 'start' ? '0%' : '100%');
// 自适应/视口单位下偏移也跟着缩:贴底 36px 在 844 高的手机上应该是 34px 左右,
// 写死 px 会让小屏的留白看起来突然大了一截
const base = unit === 'vw' ? DESIGN_W : DESIGN_H;
const vunit = unit === 'vw' ? 'vw' : 'vh';
const mag = (unit === 'vh' || unit === 'vw')
? (+(Math.abs(off) / base * 100).toFixed(2)) + vunit
: Math.abs(Math.round(off)) + 'px';
if (!off) return `calc(${pct} + 0px)`;
// 写成 calc(100% - 36px) 而不是 calc(100% + -36px):后者虽然合法,
// 但用 calc 手算时容易被误读成「加负数」,见过的样式事故都出在这种地方。
return off < 0 ? `calc(${pct} - ${mag})` : `calc(${pct} + ${mag})`;
}
/** 横向偏移在 fitw 下按 vw 缩,其余情况跟纵向一起按 vh 缩。 */
const POS = l => {
const u = unitOf(l);
return `${axisPos(l.ah, l.dx, u === 'vw' ? 'vw' : u)} `
+ `${axisPos(l.av, l.dy, u === 'vw' ? 'vh' : u)}`;
};
/** 尺寸单位 → 某视口下应有的像素宽高。
layerSize 出 CSS、layerMetrics 出预览框,两者都从这里取值:
各算一套迟早会漂,表现就是「框显示没超、图已经被裁」。
下限取「基准的 55%」与「视口 - 24px」中更小的那个 —— 屏幕小到连 55% 都放不下时,
让位给视口,这样自适应单位永远不会把图撑出屏幕。 */
const FIT_PAD = 24;
function unitGeom(l, ratio, VW, VH){
const h0 = Math.max(1, Math.round(l.h) || 200);
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
if (l.unit === 'vh'){
const h = h0 / DESIGN_H * VH;
return { w: h * ratio, h };
}
if (l.unit === 'fit'){
const lo = Math.max(40, Math.min(h0 * 0.55, VH - FIT_PAD));
const h = Math.min(h0, Math.max(lo, h0 / DESIGN_H * VH));
return { w: h * ratio, h };
}
if (l.unit === 'fitw'){
const lo = Math.max(40, Math.min(w0 * 0.55, VW - FIT_PAD));
const w = Math.min(w0, Math.max(lo, w0 / DESIGN_W * VW));
return { w, h: w / ratio };
}
return { w: w0, h: h0 };
}
/** 尺寸 → background-size。
只定一个维度,另一个交给 `auto` 让浏览器按图片自身比例算:少一个可以填错的数,
也保证缩放时绝不发生横向拉伸。`px` 模式保留手填宽度(宽高比可以故意改)。 */
function layerSize(l, a){
const ratio = (a && a.h) ? (a.w / a.h) : 1;
const h0 = Math.max(1, Math.round(l.h) || 200);
const w0 = l.w > 0 ? Math.round(l.w) : Math.round(h0 * ratio);
if (l.unit === 'vh') return `auto ${+(h0 / DESIGN_H * 100).toFixed(2)}vh`;
if (l.unit === 'fit'){
const lo = Math.round(h0 * 0.55), hi = h0;
// clamp 的 min 若 ≥ max,浏览器按 min 处理 —— 图反而比设定值还大,所以先兜住
if (lo >= hi) return `auto ${hi}px`;
// 下限再和「视口高 - 24px」取小值:小屏上 55% 仍放不下时自动让位
return `auto clamp(min(calc(100vh - ${FIT_PAD}px), ${lo}px), `
+ `${+(h0 / DESIGN_H * 100).toFixed(2)}vh, ${hi}px)`;
}
if (l.unit === 'fitw'){
const lo = Math.round(w0 * 0.55), hi = w0;
if (lo >= hi) return `${hi}px auto`;
return `clamp(min(calc(100vw - ${FIT_PAD}px), ${lo}px), `
+ `${+(w0 / DESIGN_W * 100).toFixed(2)}vw, ${hi}px) auto`;
}
return l.w > 0 ? `${w0}px ${h0}px` : `auto ${h0}px`;
}
/** 某一层在给定视口下「实际占多大、落在哪」。拖拽框、溢出检测、导出 CSS 共用。 */
function layerMetrics(l, a, VW, VH){
const ratio = (a && a.h) ? (a.w / a.h) : 1;
const g = unitGeom(l, ratio, VW, VH);
const w = g.w, h = g.h;
const px = l.ah === 'center' ? 0.5 : (l.ah === 'start' ? 0 : 1);
const py = l.av === 'center' ? 0.5 : (l.av === 'start' ? 0 : 1);
const u = unitOf(l);
// 偏移与尺寸同单位缩放(fitw 横向按 vw、纵向按 vh)
const kx = u === 'vw' ? VW / DESIGN_W : (u === 'vh' ? VH / DESIGN_H : 1);
const ky = u === 'px' ? 1 : VH / DESIGN_H;
const left = (VW - w) * px + l.dx * kx;
const top = (VH - h) * py + l.dy * ky;
return {
w, h, left, top,
overL: Math.max(0, -left),
overR: Math.max(0, left + w - VW),
overT: Math.max(0, -top),
overB: Math.max(0, top + h - VH),
};
}
/** 一键按当前视口修好:改成固定 px,取「宽不超 96%、高不超 92%」的那个尺寸。
fit / fitw 只能按单一维度缩,遇到极端比例仍会裁;给一个算好的确定值,
比让人自己试 clamp 快得多。窄屏交给「窄屏」策略,不要指望一个 px 值通吃。 */
function fitToViewport(l, a, vp){
const ratio = a.w / a.h;
const h = Math.max(20, Math.min(Math.round(l.h) || 200,
Math.floor(vp.h * 0.92), Math.floor(vp.w * 0.96 / ratio)));
l.unit = 'px';
l.h = h;
if (l.w > 0) l.w = Math.round(h * ratio);
if (l.av !== 'center'){ l.av = 'center'; l.dy = 0; }
renderAll();
applyTheme();
log(`已按 ${vp.w}×${vp.h} 调整为 ${Math.round(h * ratio)}×${h}px(固定 px,更窄的屏请用「窄屏」策略)`, 'ok');
}
function rgba(hex, a){
const h = hex.replace('#','');
const v = h.length === 3 ? h.split('').map(c=>c+c).join('') : h;
const n = parseInt(v, 16);
return `rgba(${(n>>16)&255}, ${(n>>8)&255}, ${n&255}, ${a})`;
}
function layerCSS(l, preview){
if (l.kind === 'image'){
const a = assetById(l.asset);
if (!a) return {image:'none', size:'auto', position:'left top'};
const url = preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
return { image:`url("${url}")`, size: layerSize(l, a), position: POS(l) };
}
if (l.kind === 'radial'){
const c = rgba(l.color, l.alpha);
return {
image: `radial-gradient(circle ${Math.round(l.r)}px at ${l.cx}% ${l.cy}%, ${c} 0%, ${rgba(l.color,0)} 72%)`,
size: '100% 100%', position: 'left top',
};
}
const c = rgba(l.color, l.alpha);
return {
image: `linear-gradient(${Math.round(l.angle)}deg, ${c} 0%, ${rgba(l.color,0)} ${Math.round(l.stop)}%)`,
size: '100% 100%', position: 'left top',
};
}
function newLayer(kind){
return {
id: uid('L'), kind,
ah:'center', av:'end', dx:0, dy:-40,
h:360, w:0, asset:null,
// unit 默认 fit:固定 px 的立绘在小屏必被裁,默认值就该是「不容易出错」的那个
unit:'fit', mob:'inherit',
color:'#fff8e4', alpha:0.55, r:320, cx:50, cy:50,
angle:112, stop:44,
};
}
/* ---------------------------- 导出 CSS ---------------------------- */
function urlOf(a, preview){
return preview ? a.dataURL : `/static/images/themes/${S.slug}/${a.id}.webp`;
}
function bgVars(cfg, preview){
const img = cfg.bg ? assetById(cfg.bg) : null;
const pat = cfg.pattern ? assetById(cfg.pattern) : null;
return [
` --tb-page-image: ${img ? `url("${urlOf(img, preview)}")` : 'none'};`,
` --tb-page-pattern: ${pat ? `url("${urlOf(pat, preview)}")` : 'none'};`,
` --tb-page-pattern-size: ${cfg.patternSize || '320px 320px'};`,
// 背景图是 cover:宽高比不匹配就必裁。焦点决定「裁掉哪一边」——
// 16:9 场景在竖屏上只能留中间一条,把焦点挪到人物那一侧才不至于裁到脸。
` --tb-page-position: ${cfg.bgPos || 'center center'};`,
].join('\n');
}
/** 窄屏下某一层的三个值(图片 / 尺寸 / 位置)。
策略来自图层自己的 mob,全局 hideDeco 高于一切(那是「一次全藏」的急停开关)。 */
function decoMobile(l){
if (!l) return null;
if (S.narrow.hideDeco) return null;
const mob = l.mob || 'inherit';
if (mob === 'hide') return null;
if (mob === 'scale' && l.kind === 'image'){
const s = clamp(Number(S.narrow.scale) || MOB_SCALE_DEFAULT, 0.2, 1);
return Object.assign({}, l, {
h: Math.max(1, Math.round((Math.round(l.h) || 200) * s)),
w: l.w > 0 ? Math.round(l.w * s) : 0,
dx: Math.round(l.dx * s),
dy: Math.round(l.dy * s),
});
}
return l;
}
function decoVars(cfg, preview, mobile){
const layers = cfg.layers.slice(0, SLOT);
const imgs = [], sizes = [], poss = [];
for (let i = 0; i < SLOT; i++){
const raw = layers[i];
const l = mobile ? decoMobile(raw) : raw;
// 隐藏的层必须把 image 一起写成 none:只清 size 会留一张按 auto 尺寸铺开的原图
if (!l){ imgs.push('none'); sizes.push('auto'); poss.push('left top'); continue; }
const c = layerCSS(l, preview);
imgs.push(c.image); sizes.push(c.size); poss.push(c.position);
}
return [
` --tb-deco: ${imgs.join(',\n ')};`,
` --tb-deco-size: ${sizes.join(', ')};`,
` --tb-deco-position: ${poss.join(',\n ')};`,
].join('\n');
}
/* 主题至少要提供 10 个令牌(见 internal/theme/theme_test.go 的 requiredTokens),
少一个不会报错,只会让深色模式或次级面板变成默认配色。这里只让用户填 6 个基础色,
其余 4 个按「往文字色 / 主色方向混」派生:
- surface-2 = surface 往 text 靠一点(浅色下更深、深色下更亮,方向自动正确)
- surface-hover = surface 往 primary 靠一点
- text-secondary / text-muted = text 往 bg 靠(浅色下变淡、深色下变暗)
- primary-weak = primary 加 12% 透明度 */
function parseColor(v){
if (typeof v === 'string' && v.startsWith('#')){
const h = v.slice(1);
const x = h.length === 3 ? h.split('').map(c => c + c).join('') : h;
const n = parseInt(x, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, 1];
}
const m = String(v).match(/rgba?\(([^)]+)\)/);
if (m){
const a = m[1].split(',').map(x => parseFloat(x));
return [a[0] | 0, a[1] | 0, a[2] | 0, a.length > 3 ? a[3] : 1];
}
return [0, 0, 0, 1];
}
function mixRGB(a, b, t){
const A = parseColor(a), B = parseColor(b);
const ch = i => Math.round(A[i] + (B[i] - A[i]) * t);
return A[3] >= 1 ? `rgb(${ch(0)}, ${ch(1)}, ${ch(2)})`
: `rgba(${ch(0)}, ${ch(1)}, ${ch(2)}, ${A[3]})`;
}
function withAlpha(c, a){
const A = parseColor(c);
return `rgba(${A[0]}, ${A[1]}, ${A[2]}, ${a})`;
}
function paletteVars(p){
return [
` --tb-bg: ${p.bg};`,
` --tb-surface: ${p.surface};`,
` --tb-surface-2: ${mixRGB(p.surface, p.text, 0.06)};`,
` --tb-surface-hover: ${mixRGB(p.surface, p.primary, 0.10)};`,
` --tb-navbar: ${p.navbar};`,
` --tb-border: ${p.border};`,
` --tb-text: ${p.text};`,
` --tb-text-secondary: ${mixRGB(p.text, p.bg, 0.36)};`,
` --tb-text-muted: ${mixRGB(p.text, p.bg, 0.55)};`,
` --tb-primary: ${p.primary};`,
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
].join('\n');
}
const NONE10 = ' --tb-deco: ' + Array(SLOT).fill('none').join(', ') + ';\n' +
' --tb-deco-size: ' + Array(SLOT).fill('auto').join(', ') + ';\n' +
' --tb-deco-position: ' + Array(SLOT).fill('left top').join(', ') + ';';
function buildCSS(preview){
const slug = S.slug || 'my-theme';
const L = [];
L.push('/* ' + slug + ' —— 由 dev/theme-builder 生成,可直接手改。');
L.push(' 装饰层固定 ' + SLOT + ' 个槽位(对应 base.css 的 12 层背景);');
L.push(' 按页面覆盖时也必须写满 ' + SLOT + ' 个,否则图层会与 size/position 错位。 */');
L.push(`html[data-theme-style="${slug}"] {`);
L.push(paletteVars(S.palette));
L.push(bgVars(S.scopes.global, preview));
L.push(decoVars(S.scopes.global, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"] {`);
L.push(paletteVars(S.dark));
L.push(` --tb-page-image: linear-gradient(180deg, ${S.dark.bg} 0%, ${S.dark.bg} 60%, #0e1319 100%);`);
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
for (const sc of SCOPES){
if (sc.k === 'global') continue;
const cfg = S.scopes[sc.k];
if (cfg.inherit) continue;
const sel = `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push('');
L.push(`/* ${sc.label} ${sc.desc} */`);
L.push(sel + ' {');
L.push(bgVars(cfg, preview));
L.push(decoVars(cfg, preview));
L.push('}');
L.push('');
L.push(`html[data-theme="dark"][data-theme-style="${slug}"][data-page-scope="${sc.k}"] {`);
L.push(' --tb-page-image: none;');
L.push(' --tb-page-pattern: none;');
L.push(NONE10);
L.push('}');
}
// 窄屏段:只写浅色规则。深色块特异性更高(0,2,1 > 0,1,1),
// 本来就压住这里,不需要再为深色写一份 —— 少一层嵌套少一处出错。
// 注意 `fit` / `vh` 单位本身就是视口相对的,浏览器会自己缩,不需要这里插手;
// 这一段只负责「换竖版图」「某层要缩小或退场」这类必须改值的事。
const nb = S.narrow.bg ? assetById(S.narrow.bg) : null;
const hasMob = SCOPES.some(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return false;
return (c.layers || []).some(l => (l.mob || 'inherit') !== 'inherit');
});
if (S.narrow.enabled && (nb || S.narrow.hideDeco || hasMob)){
L.push('');
L.push('/* 窄屏:卡片几乎占满宽度,两侧没有留白,桌面那套背景与装饰在这里只会被裁/被盖。');
L.push(' 每个独立配置的页面都要写一条 —— 页面覆盖块特异性更高(0,2,1),');
L.push(' 只写 html[data-theme-style] 的话,独立配置的页面在手机上根本不生效。 */');
L.push(`@media (max-width: ${S.narrow.maxWidth || 900}px) {`);
for (const sc of SCOPES){
const cfg = S.scopes[sc.k];
if (sc.k !== 'global' && cfg.inherit) continue;
const sel = sc.k === 'global'
? `html[data-theme-style="${slug}"]`
: `html[data-theme-style="${slug}"][data-page-scope="${sc.k}"]`;
L.push(` ${sel} {`);
// 只替换「本来就有背景图」的页面:某页故意配成纯色(--tb-page-image: none),
// 手机上新塞一张竖图就是改了他的设计
if (nb && cfg.bg) L.push(` --tb-page-image: url("${urlOf(nb, preview)}");`);
if (cfg.bg) L.push(` --tb-page-position: ${nb ? 'center center' : (cfg.bgPos || 'center center')};`);
decoVars(cfg, preview, true).split('\n').forEach(line => L.push(' ' + line));
L.push(' }');
}
L.push('}');
}
return L.join('\n') + '\n';
}
/* ---------------------------- iframe ---------------------------- */
let doc = null, frameReady = false;
function previewFile(){
const sc = SCOPES.find(x => x.k === S.scope);
const page = (sc && sc.page) || 'box';
return `../../outputs/preview-${page}-${S.mode}.html`;
}
function loadPreview(){
frameReady = false;
const url = previewFile();
$('stage-url').textContent = url.replace('../../','');
$('preview').src = url;
}
function onFrameLoad(){
const ifr = $('preview');
try {
doc = ifr.contentDocument || ifr.contentWindow.document;
if (!doc || !doc.documentElement) throw new Error('no document');
} catch (e){
doc = null;
$('empty').style.display = 'flex';
$('conn').className = 'conn bad';
$('conn').textContent = '需要本地服务';
out('无法访问 iframe 的 DOM。请用 python dev/theme-builder/serve.py 启动,再用 http://127.0.0.1:8777/dev/theme-builder/ 打开。', 'e');
return;
}
$('empty').style.display = 'none';
$('conn').className = 'conn ok';
$('conn').textContent = '画板已连接';
frameReady = true;
applyTheme();
}
function applyTheme(){
if (!frameReady || !doc) return;
const html = doc.documentElement;
const sc = SCOPES.find(x => x.k === S.scope);
html.setAttribute('data-theme-style', S.slug || 'my-theme');
// global 要看「纯全局」的效果:给一个不会匹配任何覆盖块的值,
// 否则箱主页自己的独立配置会混进全局预览里,看到的东西是假的。
html.setAttribute('data-page-scope', (sc && sc.page) || '__global');
if (S.mode === 'dark') html.setAttribute('data-theme', 'dark');
else html.removeAttribute('data-theme');
let st = doc.getElementById('__tb_theme');
if (!st){
st = doc.createElement('style');
st.id = '__tb_theme';
doc.head.appendChild(st);
}
st.textContent = buildCSS(true);
syncOverlay();
}
/* ------------------------- 拖拽蒙层 ------------------------- */
function guideGeom(l){
const a = assetById(l.asset);
if (!a) return null;
const vw = doc.documentElement.clientWidth;
const vh = doc.documentElement.clientHeight;
// 与 background-size/position 走同一套换算式(layerMetrics),
// 否则 fit/vh 单位下框和实际图会错位
const m = layerMetrics(l, a, vw, vh);
return { w: m.w, h: m.h, left: Math.round(m.left), top: Math.round(m.top) };
}
function syncOverlay(){
if (!doc) return;
let ov = doc.getElementById('__tb_ov');
if (!ov){
ov = doc.createElement('div');
ov.id = '__tb_ov';
ov.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483000;';
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;
const g0 = guideGeom(l);
if (!g0) return;
const g = doc.createElement('div');
g.dataset.layer = l.id;
g.style.cssText = [
'position:absolute', `width:${g0.w}px`, `height:${g0.h}px`,
`left:${g0.left}px`, `top:${g0.top}px`,
'border:1px dashed rgba(91,155,213,.95)', 'border-radius:2px',
'pointer-events:auto', 'cursor:move', 'background:none',
].join(';');
const tag = doc.createElement('span');
tag.textContent = '拖动';
tag.style.cssText = 'position:absolute;left:0;top:0;font:10px/14px system-ui;z-index:1;' +
'color:#0b1219;background:rgba(91,155,213,.92);padding:0 4px;border-radius:2px 0 2px 0';
g.appendChild(tag);
g.title = '拖动调整位置';
attachDrag(g, l);
ov.appendChild(g);
});
highlightGuide(S.curLayer);
}
function attachDrag(el, l){
el.addEventListener('pointerdown', e => {
e.preventDefault(); e.stopPropagation();
try { el.setPointerCapture(e.pointerId); } catch (_) {}
const sx = e.clientX, sy = e.clientY, dx0 = l.dx, dy0 = l.dy;
// 视口单位下 CSS 会把偏移按视口比缩放,鼠标的 1px 不等于 dx 的 1 —— 不除回来手感会发飘。
// fitw 横向按 vw、纵向仍按 vh,与 POS/axisPos 的规则一致。
const u0 = unitOf(l);
const kvx = u0 === 'vw' ? (doc.documentElement.clientWidth || DESIGN_W) / DESIGN_W
: u0 === 'vh' ? (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H : 1;
const kvy = u0 === 'px' ? 1 : (doc.documentElement.clientHeight || DESIGN_H) / DESIGN_H;
const move = ev => {
l.dx = Math.round(dx0 + (ev.clientX - sx) / kvx);
l.dy = Math.round(dy0 + (ev.clientY - sy) / kvy);
// 快速路径:只改 CSS + 蒙层位置,不重建 DOM(否则指针捕获会被打断)
const st = doc.getElementById('__tb_theme');
if (st) st.textContent = buildCSS(true);
const g0 = guideGeom(l);
if (g0){ el.style.left = g0.left + 'px'; el.style.top = g0.top + 'px'; }
renderLayerEditor(cfgOf(S.scope).layers.find(x => x.id === l.id) || null);
};
const up = () => {
el.removeEventListener('pointermove', move);
el.removeEventListener('pointerup', up);
el.removeEventListener('pointercancel', up);
try { el.releasePointerCapture(e.pointerId); } catch (_) {}
};
el.addEventListener('pointermove', move);
el.addEventListener('pointerup', up);
el.addEventListener('pointercancel', up);
});
}
/* ---------------------------- 缩放画板 ---------------------------- */
function fitStage(){
const canvas = $('canvas');
const frame = $('frame');
const ifr = $('preview');
const vp = vpOf(S.viewport);
// 高度必须跟着预设走:裁切几乎全由「视口高」决定,
// 一律按 900 渲染的话,768 的旧笔电和 844 的手机根本模拟不出来
const k = Math.min(1, (canvas.clientWidth - 28) / vp.w, (canvas.clientHeight - 28) / vp.h);
ifr.style.width = vp.w + 'px';
ifr.style.height = vp.h + 'px';
ifr.style.transform = `scale(${k})`;
frame.style.width = Math.round(vp.w * k) + 'px';
frame.style.height = Math.round(vp.h * k) + 'px';
}
/* ---------------------------- 面板渲染 ---------------------------- */
function renderScopes(){
const wrap = $('scopes');
wrap.innerHTML = '';
SCOPES.forEach(sc => {
const b = document.createElement('button');
const own = sc.k === 'global' || !S.scopes[sc.k].inherit;
const c = S.scopes[sc.k];
const dirty = own && (c.layers.length || c.bg || c.pattern);
b.className = 'sc' + (sc.k === S.scope ? ' on' : '');
b.innerHTML = `<b>${esc(sc.label)}${dirty ? ' <span class="dirty">●</span>' : ''}</b>` +
`<span>${esc(own ? sc.desc : '跟随全局')}</span>`;
b.onclick = () => {
S.scope = sc.k;
S.curLayer = null;
renderAll();
loadPreview(); // 切页面 = 换一张真实画板
};
wrap.appendChild(b);
});
const sc = SCOPES.find(x => x.k === S.scope);
$('scope-hint').textContent = sc.k === 'global'
? '这里的图层是所有页面的默认装饰。下面选具体页面可以单独覆盖。'
: `${sc.desc} —— 勾掉「跟随全局」后可单独配这一页的背景与图层。`;
}
function renderBgEditor(){
const isGlobal = S.scope === 'global';
const inherit = !isGlobal && S.scopes[S.scope].inherit;
// 展示用 cfg:跟随全局时就是 global 那套(下拉里要能看到当前实际生效的值)
const cfg = cfgOf(S.scope);
$('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';
const s = document.createElement('span');
s.textContent = label;
row.appendChild(s);
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 = cfg[key] || '';
sel.onchange = () => {
ensureOwn(true);
const c = S.scopes[S.scope];
c[key] = sel.value || null;
renderAll();
};
row.appendChild(sel);
el.appendChild(row);
};
mkPick('背景图', 'bg');
mkPick('平铺图案', 'pattern');
// 背景图是 cover 铺满:宽高比不匹配就必裁,焦点决定裁掉哪一边。
// 16:9 场景在竖屏上只剩中间一条,人物偏一侧时把焦点挪过去才不会裁到脸。
const frow = document.createElement('div');
frow.className = 'row';
const fs = document.createElement('span');
fs.textContent = '焦点';
const grid = document.createElement('div');
grid.className = 'grid9';
['top','center','bottom'].forEach(v => {
['left','center','right'].forEach(h => {
const val = h + ' ' + v;
const b = document.createElement('button');
b.title = val;
b.className = (cfg.bgPos || 'center center') === val ? 'on' : '';
b.onclick = () => {
ensureOwn(true);
S.scopes[S.scope].bgPos = val;
renderAll();
};
grid.appendChild(b);
});
});
frow.append(fs, grid);
el.appendChild(frow);
const row = document.createElement('div');
row.className = 'row';
const s = document.createElement('span');
s.textContent = '图案尺寸';
const inp = document.createElement('input');
inp.type = 'text';
inp.value = cfg.patternSize || '320px 320px';
inp.style.width = '110px';
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';
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);
}
function layerName(l){
if (l.kind === 'image'){
const a = assetById(l.asset);
return a ? a.name : '(未选图片)';
}
return l.kind === 'radial' ? '光斑' : '线性光';
}
function moveLayer(cfg, idx, delta){
const j = idx + delta;
if (j < 0 || j >= cfg.layers.length) return;
const [it] = cfg.layers.splice(idx, 1);
cfg.layers.splice(j, 0, it);
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 own = S.scope === 'global' || !S.scopes[S.scope].inherit;
const cfg = cfgOf(S.scope);
const wrap = $('layers');
wrap.innerHTML = '';
// 「加一层」始终可用:跟随全局时点了会自动切成独立配置。
// 之前这里 inherit 就 disabled,用户在箱主页点不动,只会以为工具坏了。
$('add-layer').disabled = cfg.layers.length >= SLOT;
if (!own){
const p = document.createElement('p');
p.className = 'hint';
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 = '还没有图层。点「+ 加一层」,可选图片 / 光斑 / 线性光。图片层在画板上可以直接拖。';
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 mobOff = S.narrow.enabled && (S.narrow.hideDeco || (l.mob || 'inherit') === 'hide');
const color = l.kind === 'image' ? '#5b9bd5' : l.kind === 'radial' ? '#d8a657' : '#9b7fd0';
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) + ' 层'}${mobOff ? ' · 窄屏隐藏' : ''}</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.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);
});
renderLayerEditor(cfg.layers.find(l => l.id === S.curLayer) || cfg.layers[0] || null);
}
function highlightGuide(id){
if (!doc) return;
doc.querySelectorAll('#__tb_ov > div').forEach(g => {
const on = g.dataset.layer === id;
g.style.borderColor = on ? '#ffd479' : 'rgba(91,155,213,.95)';
g.style.background = 'none';
const tag = g.querySelector('span');
if (tag) tag.style.background = on ? 'rgba(255,212,121,.95)' : 'rgba(91,155,213,.92)';
});
}
function renderLayerEditor(l){
const host = $('layer-editor');
host.innerHTML = '';
if (!l){ S.curLayer = null; return; }
S.curLayer = l.id;
const box = document.createElement('div');
box.className = 'editor';
host.appendChild(box);
const row = (label, ...nodes) => {
const r = document.createElement('div');
r.className = 'row';
const s = document.createElement('span');
s.textContent = label;
r.appendChild(s);
nodes.forEach(n => r.appendChild(n));
box.appendChild(r);
return r;
};
if (l.kind === 'image'){
// 换图的下拉在图层行里,这里只放尺寸,避免同一件事有两个入口
const sel = (opts, val, on, title) => {
const s = document.createElement('select');
opts.forEach(([k, t]) => {
const o = document.createElement('option');
o.value = k; o.textContent = t;
s.appendChild(o);
});
s.value = val;
s.onchange = () => on(s.value);
if (title) s.title = title;
return s;
};
// 尺寸单位 = 「屏幕变矮/变窄时这张图怎么办」。固定 px 是绝大多数「被裁」的根源。
// 下拉的 value 必须用原始 l.unit('fit'),不能过 unitOf —— 那会把 fit 归一成 vh,
// 于是选了「按视口高比例」却拿到 clamp 的输出,看起来像开关失灵。
row('尺寸单位', sel([['fit','自适应·按高度'],['fitw','自适应·按宽度'],['px','固定 px'],['vh','按视口高比例']],
l.unit || 'px', v => { l.unit = v; renderAll(); applyTheme(); },
'按高度:人物/竖幅用,屏幕更矮就等比缩(最低 55%);' +
'按宽度:横幅/整屏插画用,屏幕更窄就等比缩(按高度缩的话宽度会跟着涨,窄屏照样裁);' +
'按视口高比例:永远占同样比例的屏高,无下限'));
row('高度 px', numInput(l.h, 20, 4000, v => { l.h = v; renderAll(); }));
if (unitOf(l) === 'px'){
const wIn = numInput(l.w, 0, 4000, v => { l.w = v; renderAll(); });
wIn.title = '0 表示按图片原始比例自动算宽度';
row('宽度 0=自动', wIn);
}
// 窄屏策略:手机上「全藏」太粗暴,缩小往往就够用
row('窄屏', sel([['inherit','原样'],['scale',`缩小到 ${Math.round((S.narrow.scale||MOB_SCALE_DEFAULT)*100)}%`],['hide','隐藏']],
l.mob || 'inherit', v => { l.mob = v; renderAll(); },
'只在 @media 断点以下生效;缩小是把高度与偏移一起乘系数,位置锚点不变'));
// 全局「全藏」开着时这一项不生效 —— 不写出来,用户会以为自己设的缩小没起作用
if (S.narrow.enabled && S.narrow.hideDeco){
const ntip = document.createElement('p');
ntip.className = 'hint';
ntip.style.color = '#e0a33e';
ntip.textContent = '↑ 被「窄屏隐藏全部装饰层」覆盖,这一项当前不生效';
box.appendChild(ntip);
}
const a0 = assetById(l.asset);
if (a0){
const vp = vpOf(S.viewport);
const m = layerMetrics(l, a0, vp.w, vp.h);
const over = [];
if (m.overL > 0.5) over.push(`左 ${Math.round(m.overL)}px`);
if (m.overR > 0.5) over.push(`右 ${Math.round(m.overR)}px`);
if (m.overT > 0.5) over.push(`上 ${Math.round(m.overT)}px`);
if (m.overB > 0.5) over.push(`下 ${Math.round(m.overB)}px`);
const tip = document.createElement('p');
tip.className = 'hint';
if (over.length){
tip.style.color = '#e0a33e';
tip.textContent = `⚠ ${vp.w}×${vp.h}:${over.join(' / ')} 越界`;
} else {
// 一律取整:131.25 这种小数在窄面板里既占位置又没意义
tip.textContent = `${vp.w}×${vp.h} 下显示 ${Math.round(m.w)}×${Math.round(m.h)},完整可见`;
}
box.appendChild(tip);
if (over.length){
const fix = document.createElement('button');
fix.textContent = `按 ${vp.w}×${vp.h} 修好`;
fix.style.cssText = 'width:100%;margin-top:5px;font-size:11px';
fix.title = '改成固定 px,并取「宽 ≤96%、高 ≤92% 视口」的尺寸;更窄的屏请配合「窄屏」策略';
fix.onclick = () => fitToViewport(l, a0, vp);
box.appendChild(fix);
}
}
}
const g = document.createElement('div');
g.className = 'grid9';
[['start','start'],['center','start'],['end','start'],
['start','center'],['center','center'],['end','center'],
['start','end'],['center','end'],['end','end']].forEach(([hh, vv]) => {
const b = document.createElement('button');
b.className = (l.ah === hh && l.av === vv) ? 'on' : '';
b.title = `${hh} / ${vv}`;
b.onclick = () => { l.ah = hh; l.av = vv; renderAll(); };
g.appendChild(b);
});
row('锚点', g);
row('偏移 x/y', numInput(l.dx, -4000, 4000, v => { l.dx = v; renderAll(); }),
numInput(l.dy, -4000, 4000, v => { l.dy = v; renderAll(); }));
if (l.kind !== 'image'){
const c = document.createElement('input');
c.type = 'color'; c.value = l.color;
c.oninput = () => { l.color = c.value; applyTheme(); };
row('颜色', c);
row('不透明度', rangeInput(l.alpha, 0, 1, 0.01, v => { l.alpha = v; applyTheme(); }));
}
if (l.kind === 'radial') row('半径 px', numInput(l.r, 10, 3000, v => { l.r = v; renderAll(); }));
if (l.kind === 'linear'){
row('角度', numInput(l.angle, 0, 360, v => { l.angle = v; renderAll(); }));
row('衰减%', numInput(l.stop, 1, 100, v => { l.stop = v; renderAll(); }));
}
}
function numInput(val, min, max, on){
const i = document.createElement('input');
i.type = 'number'; i.value = val; i.min = min; i.max = max;
i.onchange = () => on(clamp(Number(i.value) || 0, min, max));
return i;
}
function rangeInput(val, min, max, step, on){
const i = document.createElement('input');
i.type = 'range'; i.value = val; i.min = min; i.max = max; i.step = step;
i.style.width = '100%';
i.oninput = () => on(Number(i.value));
return i;
}
function renderAssets(){
const wrap = $('assets');
wrap.innerHTML = '';
if (!S.assets.length){
const p = document.createElement('p');
p.className = 'hint';
p.textContent = '还没有图片。点「上传」加入透明底 PNG / WebP。';
wrap.appendChild(p);
return;
}
S.assets.forEach(a => {
const d = document.createElement('div');
d.className = 'as';
const fxOn = !fxIsEmpty(a.fx);
const ow = (a.orig && a.orig.w) || a.w, oh = (a.orig && a.orig.h) || a.h;
d.title = `${a.name} · ${a.w}×${a.h}(原始 ${ow}×${oh})· ${a.kb}KB`
+ (fxOn ? '\n已处理:' + fxSummary(a.fx) : '\n点击左上角 fx 可处理');
d.innerHTML = `<img src="${a.dataURL}" alt="">
<span class="fx${fxOn ? ' on' : ''}" data-fx="${a.id}" title="处理:透明度 / 压白 / 去白底 / 裁边">fx</span>
<span class="meta">${a.w}×${a.h}</span>
<span class="del" data-del="${a.id}">×</span>`;
d.querySelector('[data-fx]').onclick = e => {
e.stopPropagation();
openFx(a);
};
d.querySelector('[data-del]').onclick = e => {
e.stopPropagation();
S.assets = S.assets.filter(x => x.id !== a.id);
Object.values(S.scopes).forEach(c => {
if (c.bg === a.id) c.bg = null;
if (c.pattern === a.id) c.pattern = null;
c.layers = c.layers.filter(l => l.asset !== a.id);
});
renderAll();
};
wrap.appendChild(d);
});
}
/** 素材卡片的 tooltip 用:把 fx 说成人话,不然「压白 16%」这种参数要开弹窗才想得起来 */
function fxSummary(f){
const p = [];
if (f.alpha !== 100) p.push(`不透明 ${f.alpha}%`);
if (f.wash) p.push(`压白 ${f.wash}%`);
if (f.gray) p.push(`去色 ${f.gray}%`);
if (f.trim) p.push('裁透明边');
if (f.key && f.key.on) p.push(`去白底 容差 ${f.key.tol}`);
return p.length ? p.join(' · ') : '无';
}
function toHex(v){
if (!v) return '#000000';
if (v.startsWith('#')) return v.length === 4
? '#' + v.slice(1).split('').map(c=>c+c).join('') : v;
const m = v.match(/rgba?\(([^)]+)\)/);
if (!m) return '#000000';
const p = m[1].split(',').map(x => parseInt(x.trim(), 10));
return '#' + p.slice(0,3).map(n => n.toString(16).padStart(2,'0')).join('');
}
function renderNarrowEditor(){
const el = $('narrow-editor');
el.innerHTML = '';
const n = S.narrow;
const mk = (text, checked, onChange) => {
const lab = document.createElement('label');
lab.className = 'chk';
lab.style.cssText = 'font-size:12px;color:var(--ui-text);display:flex;gap:6px;margin-bottom:7px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = checked;
cb.onchange = () => onChange(cb.checked);
lab.append(cb, document.createTextNode(text));
return lab;
};
el.appendChild(mk('导出 @media 块', n.enabled, v => { n.enabled = v; renderAll(); }));
const row = document.createElement('div');
row.className = 'row';
const sp = document.createElement('span');
sp.textContent = '断点 px';
const mw = document.createElement('input');
mw.type = 'number'; mw.min = 320; mw.max = 1400; mw.value = n.maxWidth;
mw.onchange = () => { n.maxWidth = clamp(Number(mw.value) || 900, 320, 1400); renderAll(); };
row.append(sp, mw);
el.appendChild(row);
const rowS = document.createElement('div');
rowS.className = 'row';
const spS = document.createElement('span');
spS.textContent = '缩放系数';
const sc = document.createElement('input');
sc.type = 'number'; sc.min = 20; sc.max = 100; sc.step = 5;
sc.value = Math.round((n.scale || MOB_SCALE_DEFAULT) * 100);
sc.title = '图层「窄屏 = 缩小」时乘的系数。0.6 左右通常就够,太小会让装饰变成噪点';
sc.onchange = () => {
n.scale = clamp((Number(sc.value) || 62) / 100, 0.2, 1);
renderAll();
};
rowS.append(spS, sc);
el.appendChild(rowS);
const row2 = document.createElement('div');
row2.className = 'row';
const sp2 = document.createElement('span');
sp2.textContent = '竖版图';
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 = n.bg || '';
sel.onchange = () => { n.bg = sel.value || null; renderAll(); };
row2.append(sp2, sel);
el.appendChild(row2);
el.appendChild(mk('窄屏隐藏全部装饰层', n.hideDeco, v => { n.hideDeco = v; renderAll(); }));
// 这个开关一旦勾上,用户新加的素材在手机上也一起消失,而桌面预览完全正常
// —— 是「素材在窄屏下不见了」最常见的来源。所以勾上时必须当场把后果写出来,
// 不能只靠一个复选框的标题。
if (n.hideDeco){
let cnt = 0;
SCOPES.forEach(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return;
cnt += (c.layers || []).length;
});
const warn = document.createElement('p');
warn.className = 'hint';
warn.style.color = '#e0a33e';
warn.textContent = `⚠ 断点以下 ${cnt} 层全不显示,新加的层也一样。`
+ '要在手机上保留素材:取消勾选,改用每层「窄屏」。';
el.appendChild(warn);
}
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '手机断点下卡片几乎占满宽度:16:9 的桌面场景会被裁成一条纹理。'
+ '三层优先级从高到低:这个「全部隐藏」→ 每层的窄屏策略(原样 / 缩小 / 隐藏)→ 图层的尺寸单位。'
+ '尺寸单位选「自适应 / 视口高比例」的层自己不依赖这段规则,浏览器会按视口缩。'
+ '只写浅色规则——深色块特异性更高,本来就压住它。';
el.appendChild(tip);
}
/* ---------------------- 导出画质 ---------------------- */
/* WebP 体积 ≈ 像素数 × 质量的乘积。真正的大头是像素:
CSS 里只显示 400px 高的立绘,2 倍屏也只要 800px,上传 3000px 纯属白扛。
所以这里给两个旋钮,先砍长边、再降质量,顺序别反。 */
function renderExportEditor(){
const el = $('export-editor');
if (!el) return;
el.innerHTML = '';
const row = () => {
const r = document.createElement('div');
r.className = 'row';
el.appendChild(r);
return r;
};
const label = (t, w) => {
const s = document.createElement('span');
s.textContent = t;
if (w) s.style.width = w;
return s;
};
// 质量滑块:松手(change)才重编码,拖动过程中不反复跑全量像素。
// 数值放进 label 里而不是单独一个 flex 项:面板只有 240px,三个 flex 项时
// range 的固有宽度会把数值顶出可视区(表现为"滑块没有数字")。
const r1 = row();
const q = clamp(Number(S.export.quality) || 0.88, 0.5, 0.95);
const qlab = document.createElement('span');
qlab.innerHTML = '质量 <b>' + q.toFixed(2) + '</b>';
const qv = qlab.querySelector('b');
qv.style.cssText = 'color:var(--ui-main);font-weight:600';
const qr = document.createElement('input');
qr.type = 'range'; qr.min = 0.5; qr.max = 0.95; qr.step = 0.01; qr.value = q;
// width:0 + flex:1 1 0 是让 range 只吃「剩余空间」的标准写法:
// 光给 flex:1 时它的固有宽度(Chrome 约 129px)会撑破 240px 的面板。
qr.style.cssText = 'flex:1 1 0;width:0;min-width:0';
qr.title = '0.85~0.9 之间肉眼几乎看不出差别;低于 0.8 才会明显糊。上限 0.95 —— WebP 的 q=1 会切无损,体积反而失控';
qr.oninput = () => { qv.textContent = (+qr.value).toFixed(2); };
qr.onchange = async () => {
S.export.quality = clamp(+qr.value, 0.5, 0.95);
qv.textContent = S.export.quality.toFixed(2);
await applyQuality('质量 ' + S.export.quality.toFixed(2));
};
r1.append(qlab, qr);
const r2 = row();
const sides = [['0','不限制'],['2048','2048'],['1600','1600'],['1200','1200'],['800','800']];
const side = document.createElement('select');
side.innerHTML = sides.map(([v, t]) => `<option value="${v}">${t}</option>`).join('');
side.value = String(S.export.maxSide || 0);
side.title = '长边上限。整页背景图建议 1920/不限制;装饰人物 1200 就够(显示 400px × 2 倍屏)';
side.onchange = async () => {
S.export.maxSide = parseInt(side.value, 10) || 0;
await applyQuality('长边 ≤ ' + (S.export.maxSide || '不限'));
};
r2.append(label('长边上限', '62px'), side);
const stat = document.createElement('p');
stat.className = 'hint';
const total = S.assets.reduce((n, a) => n + (a.kb || 0), 0);
if (!S.assets.length){
stat.textContent = '还没有图片。上传时会按上面的设置直接编码。';
} else {
const big = S.assets.slice().sort((a, b) => b.kb - a.kb)[0];
stat.textContent = `${S.assets.length} 张,共 ${total >= 1024 ? (total / 1024).toFixed(1) + 'MB' : total + 'KB'}`
+ `;最大一张 ${big.id}.webp ${big.kb}KB(${big.w}×${big.h})`;
}
el.appendChild(stat);
const tip = document.createElement('p');
tip.className = 'hint';
tip.textContent = '压体积的顺序:先砍长边,再降质量。改完按「原图 + 处理参数」重新编码全部素材,'
+ '不叠加压缩 —— 来回试不会越调越糊。注意平涂/线稿素材降质量可能反而变大(有损会引入噪点),'
+ '这类图优先调长边。';
el.appendChild(tip);
}
/** 改画质后重编全部素材,并把前后体积说清楚 —— 否则用户不知道有没有生效。 */
async function applyQuality(what){
if (!S.assets.length){ renderAll(); return; }
const { before, after } = await rebakeAll();
renderAll();
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(1) + 'MB' : kb + 'KB';
log(`按「${what}」重编 ${S.assets.length} 张:${fmt(before)} → ${fmt(after)}`
+ (before > 0 && after < before ? `(省 ${Math.round((1 - after / before) * 100)}%)` : ''), 'ok');
}
function renderPalette(){
const wrap = $('palette');
wrap.innerHTML = '';
PALETTE_KEYS.forEach(([k, label]) => {
const r = document.createElement('div');
r.className = 'p';
const s = document.createElement('span');
s.textContent = label; s.style.width = '46px'; s.style.flex = '0 0 auto';
const c = document.createElement('input');
c.type = 'color';
c.value = toHex(S.palette[k]);
c.oninput = () => { S.palette[k] = c.value; applyTheme(); };
const t = document.createElement('input');
t.type = 'text'; t.value = S.palette[k];
t.onchange = () => { S.palette[k] = t.value.trim(); renderAll(); };
r.append(s, c, t);
wrap.appendChild(r);
});
const note = document.createElement('p');
note.className = 'hint';
note.textContent = '深色配色写在生成结果里(深色不加载图片);要改深色请直接改 CSS。';
wrap.appendChild(note);
}
function renderViewportBar(){
const vp = $('vps'); vp.innerHTML = '';
VIEWPORTS.forEach(v => {
const b = document.createElement('button');
b.textContent = v.w;
b.title = v.label;
b.className = v.w === S.viewport ? 'on' : '';
b.onclick = () => { S.viewport = v.w; renderViewportBar(); renderLayers(); fitStage(); };
vp.appendChild(b);
});
const md = $('modes'); md.innerHTML = '';
[['light','浅色'],['dark','深色']].forEach(([k, t]) => {
const b = document.createElement('button');
b.textContent = t;
b.className = k === S.mode ? 'on' : '';
b.onclick = () => { S.mode = k; renderViewportBar(); loadPreview(); };
md.appendChild(b);
});
}
function renderAll(){
S.slug = ($('slug').value || '').trim() || 'my-theme';
renderScopes();
renderBgEditor();
renderLayers();
renderAssets();
renderExportEditor();
renderNarrowEditor();
renderPalette();
applyTheme();
scheduleSave();
}
/* ---------------------- 工程持久化(留得住配置) ---------------------- */
/* 没有这两样东西,工具就只能用一次:关掉标签页,刚调好的十个图层和六个页面全没。
localStorage 存草稿(自动),json 存工程(手动、可进版本库、可发给别人)。 */
const LS_KEY = 'tamabox-theme-builder-v2';
let saveTimer = null, draftWarned = false;
function toJSON(){
return {
v: 4,
savedAt: new Date().toISOString(),
slug: S.slug,
scope: S.scope,
viewport: S.viewport,
mode: S.mode,
palette: S.palette,
dark: S.dark,
narrow: S.narrow,
export: S.export,
// 图片内嵌 base64:少一层「图片放哪」的约定,导入时一个文件就够。
// 存的是 orig(原始字节)+ fx(处理参数),dataURL 只在真有处理时才存 ——
// 未处理时它必然等于 orig,再存一份等于让 json 平白翻倍。
assets: S.assets.map(a => ({
id: a.id, name: a.name,
orig: a.orig || { dataURL: a.dataURL, w: a.w, h: a.h },
fx: fxIsEmpty(a.fx) ? null : normFx(a.fx),
dataURL: fxIsEmpty(a.fx) ? null : a.dataURL,
w: a.w, h: a.h, kb: a.kb,
})),
scopes: S.scopes,
};
}
function fromJSON(o){
// v2 是加图片处理之前的格式(只有 dataURL):把 dataURL 当 orig 用,
// fx 全空,用户打开旧工程还能接着改参数。v4 只多了 export(画质)字段,缺了取默认。
if (!o || typeof o !== 'object' || (o.v !== 2 && o.v !== 3 && o.v !== 4)){
throw new Error('不是本工具导出的工程文件(版本 ' + (o && o.v) + ',支持 2 / 3 / 4)');
}
S.slug = o.slug || 'my-theme';
S.scope = SCOPES.some(x => x.k === o.scope) ? o.scope : 'global';
S.viewport = VIEWPORTS.some(v => v.w === o.viewport) ? o.viewport : 1512;
S.mode = o.mode === 'dark' ? 'dark' : 'light';
S.palette = Object.assign({}, S.palette, o.palette || {});
S.dark = Object.assign({}, S.dark, o.dark || {});
S.narrow = Object.assign({ enabled: true, bg: null, hideDeco: false, maxWidth: 900,
scale: MOB_SCALE_DEFAULT }, o.narrow || {});
S.export = Object.assign({}, D.export, o.export || {});
S.assets = (o.assets || []).map(a => {
const orig = a.orig || { dataURL: a.dataURL, w: a.w, h: a.h };
const dataURL = a.dataURL || orig.dataURL;
return {
id: a.id, name: a.name, orig, fx: normFx(a.fx),
dataURL, bytes: dataURLToBytes(dataURL),
w: a.w || orig.w, h: a.h || orig.h, kb: a.kb || 0,
};
});
S.scopes = {};
SCOPES.forEach(sc => {
const src = (o.scopes || {})[sc.k] || {};
S.scopes[sc.k] = Object.assign(blankScope(sc.k), src, {
layers: Array.isArray(src.layers) ? src.layers : [],
});
// 图层字段补齐:工程文件可能是旧版本写的,缺的字段用默认值兜住,
// 否则读进来会是一堆 undefined,渲染时静默变成 none
S.scopes[sc.k].layers = S.scopes[sc.k].layers.map(l =>
Object.assign(newLayer(l && l.kind ? l.kind : 'radial'), l));
});
S.curLayer = null;
}
function saveDraft(){
try {
localStorage.setItem(LS_KEY, JSON.stringify(toJSON()));
} catch (e){
// 图片多时 base64 会顶到 5MB 配额,只提示一次,别刷屏
if (!draftWarned){
draftWarned = true;
log('草稿太大存不进浏览器(localStorage 配额),请用「保存工程」落成 json。', 'w');
}
}
}
/** 清空到空白状态,并删掉浏览器里的草稿。
之前只有 ?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.export = Object.assign({}, D.export);
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);
}
function restoreDraft(){
let raw = null;
try { raw = localStorage.getItem(LS_KEY); } catch (_) { return false; }
if (!raw) return false;
try { fromJSON(JSON.parse(raw)); return true; }
catch (e){ return false; }
}
function refreshUIFromState(){
$('slug').value = S.slug;
S.curLayer = null;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
}
/* ---------------------------- 导出前校验 ---------------------------- */
/* 这些规则本来要导出后跑 go test 才知道。能在点导出时就拦住的,不该让人去等编译。 */
function splitTopLevel(v){
const out = []; let buf = '', depth = 0;
for (const ch of String(v)){
if (ch === '(') depth++;
else if (ch === ')') depth--;
if (ch === ',' && depth === 0){ out.push(buf.trim()); buf = ''; }
else buf += ch;
}
if (buf.trim()) out.push(buf.trim());
return out;
}
function validate(){
const errs = [];
const slug = ($('slug').value || '').trim();
if (!slugOK(slug)) errs.push('slug 只能是小写字母、数字、短横线,1~32 字符');
let hasContent = false;
SCOPES.forEach(sc => {
const raw = S.scopes[sc.k];
if (sc.k !== 'global' && raw.inherit) return; // 跟随全局的不单独检查
const cfg = raw;
if (cfg.layers.length > SLOT) errs.push(`${sc.label}:装饰层 ${cfg.layers.length} 个,超过 ${SLOT} 个槽位`);
cfg.layers.forEach((l, i) => {
if (l.kind === 'image' && !assetById(l.asset)) errs.push(`${sc.label} 第 ${i+1} 层是图片层,但没选图片`);
});
if (cfg.bg && !assetById(cfg.bg)) errs.push(`${sc.label}:整页背景引用了已删除的图片`);
if (cfg.pattern && !assetById(cfg.pattern)) errs.push(`${sc.label}:平铺图案引用了已删除的图片`);
if (cfg.layers.length || cfg.bg || cfg.pattern) hasContent = true;
});
if (!hasContent) errs.push('所有页面都是空的 —— 导出的会是一个什么都没配的主题');
if (S.narrow.bg && !assetById(S.narrow.bg)) errs.push('窄屏竖版图引用了已删除的图片');
// 结构自检:三组装饰值必须都是 10 个(与 Go 的 TestDecoSlotCount 同一条规则)。
// 这条是给工具自己兜底的 —— 真跑出问题说明生成逻辑有 bug,而不是用户配错。
const css = buildCSS(false);
['--tb-deco:', '--tb-deco-size:', '--tb-deco-position:'].forEach(key => {
let rest = css, n = 0;
for (;;){
const i = rest.indexOf(key);
if (i < 0) break;
const j = rest.indexOf(';', i);
if (j < 0) break;
n++;
const cnt = splitTopLevel(rest.slice(i + key.length, j)).length;
if (cnt !== SLOT) errs.push(`生成的 ${key} 第 ${n} 处只有 ${cnt} 个值(应为 ${SLOT})—— 这是工具自身的 bug,请反馈`);
rest = rest.slice(j + 1);
}
if (n === 0) errs.push(`生成的 CSS 里找不到 ${key} —— 工具自身的 bug,请反馈`);
});
return errs;
}
/* ---------------------------- 导出 zip ---------------------------- */
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++){
let c = i;
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
t[i] = c >>> 0;
}
return t;
})();
function crc32(buf){
let c = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
const u16 = n => new Uint8Array([n & 255, (n >>> 8) & 255]);
const u32 = n => new Uint8Array([n & 255, (n >>> 8) & 255, (n >>> 16) & 255, (n >>> 24) & 255]);
const enc = s => new TextEncoder().encode(s);
function concat(arrs){
let n = 0;
arrs.forEach(a => n += a.length);
const o = new Uint8Array(n);
let p = 0;
arrs.forEach(a => { o.set(a, p); p += a.length; });
return o;
}
/** 只做 STORE(不压缩):素材本来就是 WebP,再压收益接近 0,换来 40 行可审计的实现。 */
function zipStore(files){
const local = [], central = [];
let offset = 0;
const dt = new Date();
const dosTime = ((dt.getHours() << 11) | (dt.getMinutes() << 5) | (dt.getSeconds() >> 1)) & 0xFFFF;
const dosDate = (((dt.getFullYear() - 1980) << 9) | ((dt.getMonth() + 1) << 5) | dt.getDate()) & 0xFFFF;
for (const f of files){
const name = enc(f.name);
const crc = crc32(f.data);
const head = concat([
u32(0x04034b50), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length), u16(name.length), u16(0),
]);
local.push(head, name, f.data);
central.push(concat([
u32(0x02014b50), u16(20), u16(20), u16(0), u16(0), u16(dosTime), u16(dosDate),
u32(crc), u32(f.data.length), u32(f.data.length),
u16(name.length), u16(0), u16(0), u16(0), u16(0), u32(0), u32(offset),
]), name);
offset += head.length + name.length + f.data.length;
}
const cd = concat(central);
return concat([...local, cd, concat([
u32(0x06054b50), u16(0), u16(0), u16(files.length), u16(files.length),
u32(cd.length), u32(offset), u16(0),
])]);
}
function dataURLToBytes(url){
const b64 = url.split(',')[1] || '';
const bin = atob(b64);
const o = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) o[i] = bin.charCodeAt(i);
return o;
}
function installTxt(slug){
const own = SCOPES.filter(s => s.k !== 'global').map(s => {
const st = S.scopes[s.k].inherit ? '跟随全局' : '独立配置';
return ` ${s.k.padEnd(9)} ${s.desc.padEnd(22)} ${st}`;
}).join('\n');
return `主题 ${slug} —— 安装说明
=====================================
1) 解压(保持目录结构,覆盖即可)
static/css/themes/${slug}.css
static/images/themes/${slug}/*.webp
2) 登记主题(conf/app.ini)
[theme]
available = default,${slug}
⚠️ 没登记就「设置页看不到这个主题」,现象和部署没生效一模一样。
启动日志会打 warn:[theme] available 未登记以下主题
3) 让缓存失效
internal/conf/static.go 的 StaticVersion 改一下(末尾字母 +1),
然后重新 go build —— 模板与静态资源都是编译期 embed,只重启不重编不会变。
4) 箱主设置页 → 账号 → 外观,选择「${slug}」
下拉里显示的就是 slug。想要中文/日文名,到 internal/i18n/dict_*.go 加
theme.name.${slug} 词条(四份词典都要加)。不加也不会显示成 key ——
模板函数 themeName 会回退成 slug。
页面作用域
----------
${own}
窄屏
----
${S.narrow.enabled
? `已导出 @media (max-width: ${S.narrow.maxWidth}px):${S.narrow.bg ? '换成竖版背景图' : '背景沿用桌面'}`
+ `\n 缩放系数 ${Math.round((S.narrow.scale || MOB_SCALE_DEFAULT) * 100)}%(作用于策略为「缩小」的层)`
+ (S.narrow.hideDeco
? '\n ⚠ 「窄屏隐藏全部装饰层」已勾选:断点以下所有素材都不显示(桌面正常)。\n'
+ ' 想保留就把这个勾去掉,改用每层「窄屏 = 原样 / 缩小」。'
: '')
: '未导出窄屏规则(桌面装饰会在手机上被卡片盖住 / 背景被 cover 裁成一条)'}
尺寸适配
--------
尺寸单位(图层编辑器里选,本质是「屏幕变小时这张图怎么办」):
fit 自适应·按高度 —— 人物/竖幅。按 ${DESIGN_H}px 高视口换算,屏幕更矮等比缩
(下限 55%,小到放不下时让位给视口),更高不超过设定值
fitw 自适应·按宽度 —— 横幅/场景。按 ${DESIGN_W}px 宽视口换算,同上。
宽幅素材必须用它:按高度缩的话宽度会跟着涨,窄屏照样裁两侧
vh 按视口高比例 —— 永远占同样比例的屏高,无下限
px 固定像素 —— 不随视口变,小屏容易裁
尺寸只定一个维度,另一个交给 auto 按图片比例算,缩放时不会拉伸。
每一层在编辑器里都会显示「当前视口下实际显示多大 / 是否越界」。
导出画质
--------
质量 ${(+S.export.quality).toFixed(2)},长边上限 ${S.export.maxSide || '不限制'}
素材共 ${S.assets.reduce((n, a) => n + (a.kb || 0), 0)}KB(${S.assets.length} 张)
作用域由服务端按请求路径输出 <html data-page-scope="...">,
主题 CSS 用 html[data-theme-style="${slug}"][data-page-scope="xxx"] 覆盖。
没写覆盖块的作用域自动沿用全局,不会出现「某页装饰消失」。
注意
----
装饰层固定 ${SLOT} 个槽位(--tb-deco / -size / -position 必须同长)。
手动改 CSS 时别删值 —— 少一个会让图层与 size/position 错位,
表现是整页重复同一张插画、或人物跑到左上角。
`;
}
function doExport(){
const errs = validate();
if (errs.length){
out('先修这几处再导出:\n' + errs.map(e => '· ' + e).join('\n'), 'e');
return;
}
const slug = ($('slug').value || '').trim();
const used = new Set();
if (S.narrow.bg) used.add(S.narrow.bg);
const collect = c => {
if (c.bg) used.add(c.bg);
if (c.pattern) used.add(c.pattern);
c.layers.forEach(l => { if (l.asset) used.add(l.asset); });
};
SCOPES.forEach(s => { if (s.k === 'global' || !S.scopes[s.k].inherit) collect(S.scopes[s.k]); });
const files = [{ name: `static/css/themes/${slug}.css`, data: enc(buildCSS(false)) }];
let imgKB = 0, imgN = 0;
for (const id of used){
const a = assetById(id);
if (a){
files.push({ name: `static/images/themes/${slug}/${id}.webp`, data: dataURLToBytes(a.dataURL) });
imgKB += a.kb || 0; imgN++;
}
}
// 工程文件也塞进包里:下次想改直接「打开工程」,不必从零重搭
files.push({ name: `theme-${slug}.json`, data: enc(JSON.stringify(toJSON())) });
files.push({ name: `INSTALL-${slug}.txt`, data: enc(installTxt(slug)) });
const blob = new Blob([zipStore(files)], { type: 'application/zip' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${slug}.zip`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
const fmt = kb => kb >= 1024 ? (kb / 1024).toFixed(2) + 'MB' : kb + 'KB';
const over = overflowReport();
// 窄屏全藏是「素材不见了」的头号原因,导出时必须写进结果里 ——
// 用户只看桌面预览是发现不了的。
let narrowNote = '';
if (S.narrow.enabled && S.narrow.hideDeco){
let cnt = 0;
SCOPES.forEach(sc => {
const c = S.scopes[sc.k];
if (sc.k !== 'global' && c.inherit) return;
cnt += (c.layers || []).length;
});
narrowNote = `\n\n⚠ 窄屏规则(≤${S.narrow.maxWidth}px)会隐藏全部 ${cnt} 层装饰。`
+ '这是「窄屏隐藏全部装饰层」勾选的结果;要在手机上保留素材就取消它,改用每层「窄屏」策略。';
}
out(`已导出 theme-${slug}.zip(${files.length} 个文件,${fmt(Math.round(blob.size / 1024))};`
+ `其中 ${imgN} 张图共 ${fmt(imgKB)})。\n`
+ `解压到项目根,按 INSTALL-${slug}.txt 走三步;包里的 theme-${slug}.json 是工程源文件,下次用「打开工程」接着改。`
+ (over.length ? `\n\n⚠ ${over.length} 处素材在部分视口下会被裁(不阻止导出,供你判断):\n`
+ over.slice(0, 6).map(s => '· ' + s).join('\n')
+ (over.length > 6 ? `\n…… 另有 ${over.length - 6} 处` : '') : '')
+ narrowNote, (over.length || narrowNote) ? 'w' : 'ok');
}
/** 全站扫一遍:哪些层在预设视口下会溢出被裁。
导出前提醒而不是硬拦 —— 有时故意让插画出血是想要的效果。 */
function overflowReport(){
const rows = [];
for (const sc of SCOPES){
const cfg = S.scopes[sc.k];
if (sc.k !== 'global' && cfg.inherit) continue;
(cfg.layers || []).forEach((l, i) => {
if (l.kind !== 'image' || !l.asset) return;
const a = assetById(l.asset);
if (!a) return;
const bad = VIEWPORTS.filter(v => {
const m = layerMetrics(l, a, v.w, v.h);
return m.overL > 1 || m.overR > 1 || m.overT > 1 || m.overB > 1;
});
if (bad.length) rows.push(`${sc.label} 第 ${i + 1} 层(${a.id}):${bad.map(v => v.w + '×' + v.h).join('、')} 下超出视口`);
});
}
return rows;
}
/* ---------------------------- 图片处理 ---------------------------- */
/* 设计:orig 保存上传时的原始字节(不重编码),fx 只存参数,每次处理都从 orig 重算。
这样「压白 16% → 改成 20%」不会叠加两次压缩,反复调参也不会把边缘越磨越糊。 */
function fxDefault(){
return { alpha:100, wash:0, gray:0, trim:false,
// 容差 16 → 门槛 239。经验值:背景常是 253,白袜子/白衬衫是 237,
// 容差给到 20 以上就会把白袜子一起判成背景(吃穿成一个透明洞)。
key:{ on:false, tol:16, holes:true, feather:true } };
}
/** 读进来的 fx 一律过一遍归一化:工程文件可能是别的版本写的,缺字段不能让渲染变成 NaN。 */
function normFx(f){
f = f || {};
const k = Object.assign(fxDefault().key, f.key || {});
return {
alpha: f.alpha == null ? 100 : clamp(Math.round(+f.alpha), 0, 100),
wash: clamp(Math.round(+(f.wash || 0)), 0, 60),
gray: clamp(Math.round(+(f.gray || 0)), 0, 100),
trim: !!f.trim,
key: { on: !!k.on, tol: clamp(Math.round(+k.tol || 16), 4, 60),
holes: k.holes !== false, feather: k.feather !== false },
};
}
function fxIsEmpty(f){
return !f || (f.alpha === 100 && !f.wash && !f.gray && !f.trim && !(f.key && f.key.on));
}
/* 去白底。两步:① 与画布边缘连通的近白像素才是背景;② 不与边缘连通的「封闭白孔」
(手内侧、两腿之间那种)按面积单独掏。
⚠️ 容差别贪:背景常常是 (253,253,253) 而白袜子是 (237,237,237),
容差过了 30 就会把袜子一起吃穿。 */
function keyWhite(imgData, tol, holes){
const { data, width: W, height: H } = imgData;
const N = W * H;
const thr = 255 - Math.max(1, tol);
const white = new Uint8Array(N);
for (let p = 0, i = 0; p < N; p++, i += 4){
white[p] = (data[i] >= thr && data[i+1] >= thr && data[i+2] >= thr) ? 1 : 0;
}
const mark = new Uint8Array(N); // 1 = 判为背景
const stack = new Int32Array(N);
let sp = 0;
const push = p => { if (white[p] && !mark[p]){ mark[p] = 1; stack[sp++] = p; } };
for (let x = 0; x < W; x++){ push(x); push((H - 1) * W + x); }
for (let y = 0; y < H; y++){ push(y * W); push(y * W + W - 1); }
while (sp > 0){
const p = stack[--sp];
const x = p % W, y = (p - x) / W;
if (x > 0) push(p - 1);
if (x < W - 1) push(p + 1);
if (y > 0) push(p - W);
if (y < H - 1) push(p + W);
}
if (holes){
// 面积 >= 24 才掏:更小的白色多半是眼睛高光/反光,掏了会变成透明洞
const seen = new Uint8Array(N);
const q = new Int32Array(N);
for (let p0 = 0; p0 < N; p0++){
if (!white[p0] || mark[p0] || seen[p0]) continue;
let qh = 0, qt = 0;
q[qt++] = p0; seen[p0] = 1;
const cells = [];
while (qh < qt){
const p = q[qh++];
cells.push(p);
const x = p % W, y = (p - x) / W;
if (x > 0 && white[p-1] && !mark[p-1] && !seen[p-1]){ seen[p-1] = 1; q[qt++] = p-1; }
if (x < W - 1 && white[p+1] && !mark[p+1] && !seen[p+1]){ seen[p+1] = 1; q[qt++] = p+1; }
if (y > 0 && white[p-W] && !mark[p-W] && !seen[p-W]){ seen[p-W] = 1; q[qt++] = p-W; }
if (y < H - 1 && white[p+W] && !mark[p+W] && !seen[p+W]){ seen[p+W] = 1; q[qt++] = p+W; }
}
if (cells.length >= 24) for (const p of cells) mark[p] = 1;
}
}
for (let p = 0, i = 3; p < N; p++, i += 4) if (mark[p]) data[i] = 0;
}
/** alpha 做一次 3×3 最小值腐蚀:把抠图边缘残留的半透明白边收掉 1px。
代价是轮廓会缩一点,细线条(光环一类)可能被吃掉,所以是开关而不是写死。 */
function erodeAlpha1(imgData){
const { data, width: W, height: H } = imgData;
const a = new Uint8Array(W * H);
for (let p = 0, i = 3; p < a.length; p++, i += 4) a[p] = data[i];
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
const p = y * W + x;
let m = a[p];
if (m === 0) continue;
if (x === 0 || y === 0 || x === W - 1 || y === H - 1) m = 0;
else {
for (let dy = -1; dy <= 1 && m; dy++){
const row = (y + dy) * W;
for (let dx = -1; dx <= 1; dx++){
const v = a[row + x + dx];
if (v < m){ m = v; if (m === 0) break; }
}
}
}
data[p * 4 + 3] = m;
}
}
}
function fxPixels(imgData, fx){
const d = imgData.data;
if (fx.key && fx.key.on) keyWhite(imgData, fx.key.tol, fx.key.holes);
const washK = (fx.wash || 0) / 100;
const grayK = (fx.gray || 0) / 100;
const aK = (fx.alpha == null ? 100 : fx.alpha) / 100;
for (let i = 0; i < d.length; i += 4){
let r = d[i], g = d[i+1], b = d[i+2];
if (grayK){
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
r += (lum - r) * grayK; g += (lum - g) * grayK; b += (lum - b) * grayK;
}
if (washK){
r += (255 - r) * washK; g += (255 - g) * washK; b += (255 - b) * washK;
}
d[i] = r; d[i+1] = g; d[i+2] = b;
if (aK < 1) d[i+3] = d[i+3] * aK;
}
if (fx.key && fx.key.on && fx.key.feather) erodeAlpha1(imgData);
}
/** 只保留不透明的外接矩形。生成图四周留白多时用它裁掉,
否则 CSS 里按高度定尺寸会让主体看起来比实际小一圈。 */
function trimCanvas(cv){
const g = cv.getContext('2d');
const W = cv.width, H = cv.height;
const d = g.getImageData(0, 0, W, H).data;
let x0 = W, y0 = H, x1 = -1, y1 = -1;
for (let y = 0; y < H; y++){
for (let x = 0; x < W; x++){
if (d[(y * W + x) * 4 + 3] > 8){
if (x < x0) x0 = x;
if (x > x1) x1 = x;
if (y < y0) y0 = y;
if (y > y1) y1 = y;
}
}
}
if (x1 < 0 || (x0 === 0 && y0 === 0 && x1 === W - 1 && y1 === H - 1)) return cv;
const w = x1 - x0 + 1, h = y1 - y0 + 1;
const out = document.createElement('canvas');
out.width = w; out.height = h;
out.getContext('2d').drawImage(cv, x0, y0, w, h, 0, 0, w, h);
return out;
}
/** 按 fx 出一张 canvas。maxW > 0 时先等比缩到该宽度(预览用,拖动滑杆才跟手);
limit > 0 时长边不超过 limit(导出画质用,压体积最有效的一刀在这里)。 */
function renderFxCanvas(img, fx, maxW, limit){
fx = normFx(fx);
const W0 = img.naturalWidth || img.width, H0 = img.naturalHeight || img.height;
let k = maxW && W0 > maxW ? maxW / W0 : 1;
if (limit > 0){
const k2 = Math.min(limit / W0, limit / H0, 1); // 长边上限:只缩不放
if (k2 < k) k = k2;
}
let cv = document.createElement('canvas');
cv.width = Math.max(1, Math.round(W0 * k));
cv.height = Math.max(1, Math.round(H0 * k));
const g = cv.getContext('2d');
// 缩小默认是双线性,一次缩太多会糊成一团;high 会走更好的降采样,
// 代价只有一次性耗时(2M 像素级别仍是毫秒级)
g.imageSmoothingEnabled = true;
g.imageSmoothingQuality = 'high';
g.drawImage(img, 0, 0, cv.width, cv.height);
// 没有任何处理时直接跳过 getImageData:2M 像素图省下的这一趟够明显
if (!fxIsEmpty(fx)){
const id = g.getImageData(0, 0, cv.width, cv.height);
fxPixels(id, fx);
g.putImageData(id, 0, 0);
}
if (fx.trim) cv = trimCanvas(cv);
return cv;
}
async function decodeAsset(a){
if (a._img) return a._img;
const src = (a.orig && a.orig.dataURL) || a.dataURL;
a._img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码素材 ' + a.id));
im.src = src;
});
return a._img;
}
/** 把处理结果写回 asset:dataURL/bytes/w/h/kb 全部换成处理后的。
每次都从 orig + fx + 当前画质设置重算,所以「先调 q 0.7 看看,再调回 0.88」
不会两代有损叠加 —— 这与 fx 的非破坏设计是同一条原则。 */
async function bakeAsset(a){
const img = await decodeAsset(a);
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
Object.assign(a, canvasAsset(cv, S.export.quality));
}
/** 画质/长边改了之后重编全部素材。返回前后体积对比,用来给一句「省了多少」。 */
async function rebakeAll(){
let before = 0, after = 0;
for (const a of S.assets){
before += a.kb || 0;
await bakeAsset(a);
after += a.kb || 0;
}
return { before, after };
}
function loadImage(file){
return new Promise((res, rej) => {
const img = new Image();
img.onload = () => res(img);
img.onerror = () => rej(new Error('无法解码 ' + file.name));
img.src = URL.createObjectURL(file);
});
}
/** 原文件原样读成 dataURL:orig 要保真,绝不能在这里就重编码。 */
function readAsDataURL(file){
return new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + file.name));
r.readAsDataURL(file);
});
}
/** 画布尺寸 = 处理后的尺寸:不缩放、不留透明边,导出后的宽高比才与实际一致。
q 是 WebP 质量,体积与观感的唯一旋钮 —— 0.85~0.9 之间肉眼几乎看不出差别。
⚠️ 上限卡在 0.95:Skia 在 quality=1.0 时会切到 WebP 无损模式,实测输出反常
(同一张噪声图 q=1 只有 52KB,q=0.95 是 440KB),不能当「最高画质」用。 */
function canvasAsset(cv, q){
const qq = clamp(Number(q) || 0.88, 0.3, 0.95);
const url = cv.toDataURL('image/webp', qq);
const bytes = dataURLToBytes(url);
return { dataURL: url, bytes, w: cv.width, h: cv.height, kb: Math.round(bytes.length / 1024) };
}
async function addFiles(list){
for (const f of list){
if (!f.type.startsWith('image/')) continue;
let img, origURL;
try {
img = await loadImage(f);
origURL = await readAsDataURL(f);
} catch (e){ log(e.message, 'e'); continue; }
const base = f.name.replace(/\.[^.]+$/, '').toLowerCase()
.replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'img';
let id = base, n = 2;
while (S.assets.some(a => a.id === id)) id = base + '-' + (n++);
const a = {
id, name: f.name,
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(),
_img: img,
};
const cv = renderFxCanvas(img, a.fx, 0, S.export.maxSide);
Object.assign(a, canvasAsset(cv, S.export.quality));
S.assets.push(a);
log(`加入 ${f.name} → ${id}.webp(${a.w}×${a.h}, ${a.kb}KB`
+ (a.kb > 400 ? ',偏大:把「导出画质」的长边调小一档' : '') + ')', 'ok');
}
renderAll();
}
/** 从站点路径直接载入素材:不用先下载到本地再上传,
也方便把已有主题的图拉回来重新调参数。 */
async function addUrlAsset(url, id){
const blob = await (await fetch(url)).blob();
const origURL = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = () => res(r.result);
r.onerror = () => rej(new Error('读取失败 ' + url));
r.readAsDataURL(blob);
});
const img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('无法解码 ' + url));
im.src = origURL;
});
let n = 2, use = id;
while (S.assets.some(a => a.id === use)) use = id + '-' + (n++);
const a = {
id: use, name: url.split('/').pop(),
orig: { dataURL: origURL, w: img.naturalWidth, h: img.naturalHeight },
fx: fxDefault(), _img: img,
};
Object.assign(a, canvasAsset(renderFxCanvas(img, a.fx, 0, S.export.maxSide), S.export.quality));
S.assets.push(a);
return a;
}
/* ---------------------- 图片处理弹窗 ---------------------- */
let fxTarget = null;
let fxSeq = 0; // 拖动滑杆会连续触发预览:只让最后一次落地,避免旧结果覆盖新结果
let fxSnapshot = ''; // 打开时的参数快照
async function fxPreview(){
if (!fxTarget) return;
const my = ++fxSeq;
const a = fxTarget;
const img = await decodeAsset(a);
if (my !== fxSeq) return; // 已经有更新的调用了,这次结果作废
// 预览按 420px 宽算:滑杆要能实时跟手,全分辨率(可能 2M 像素)拖一次卡一次
const cv = renderFxCanvas(img, a.fx, 420);
const out = $('fx-canvas');
out.width = cv.width; out.height = cv.height;
const g = out.getContext('2d');
g.clearRect(0, 0, cv.width, cv.height);
g.drawImage(cv, 0, 0);
$('fx-info').textContent = `输出 ${cv.width}×${cv.height} · ` +
(fxIsEmpty(a.fx) ? '未处理' : '预览已按比例缩小,完成时用全分辨率重算');
}
function renderFxParams(){
const el = $('fx-params');
el.innerHTML = '';
const f = fxTarget.fx;
const slider = (label, val, min, max, unit, sub, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow';
const lab = document.createElement('div');
lab.className = 'lab';
const t = document.createElement('span'); t.textContent = label;
const b = document.createElement('b'); b.textContent = val + (unit || '');
lab.append(t, b);
const r = document.createElement('input');
r.type = 'range'; r.min = min; r.max = max; r.step = 1; r.value = val;
r.oninput = () => { b.textContent = r.value + (unit || ''); onChange(+r.value); fxPreview(); };
box.append(lab, r);
if (sub){
const s = document.createElement('div'); s.className = 'sub'; s.textContent = sub;
box.append(s);
}
el.appendChild(box);
};
const check = (label, val, onChange) => {
const box = document.createElement('div');
box.className = 'fxrow chkrow';
const l = document.createElement('label');
l.className = 'chk';
l.style.cssText = 'font-size:11.5px;color:var(--ui-text);display:flex;gap:6px;align-items:center;cursor:pointer';
const cb = document.createElement('input');
cb.type = 'checkbox'; cb.checked = val;
cb.onchange = () => onChange(cb.checked);
l.append(cb, document.createTextNode(label));
box.appendChild(l);
el.appendChild(box);
};
slider('不透明度', f.alpha, 0, 100, '%', '背景图没法单独设 opacity,只能烘焙进像素', v => f.alpha = v);
slider('压白 / 雾度', f.wash, 0, 60, '%',
'v×(1-k)+255×k。场景压白 38%,人物常用 12~26% 才不显突兀', v => f.wash = v);
slider('去色', f.gray, 0, 100, '%', '往灰度混。彩色素材压不住素场景时用它', v => f.gray = v);
check('裁掉透明边', f.trim, v => { f.trim = v; fxPreview(); });
check('去白底', f.key.on, v => { f.key.on = v; renderFxParams(); fxPreview(); });
if (f.key.on){
slider('容差', f.key.tol, 4, 60, '', '背景常是 253、白袜子 237 —— 容差过 18 就把袜子吃穿', v => f.key.tol = v);
check('掏掉封闭白孔(手内侧、腿之间)', f.key.holes, v => { f.key.holes = v; fxPreview(); });
check('边缘收 1px(去白边)', f.key.feather, v => { f.key.feather = v; fxPreview(); });
}
}
async function openFx(a){
fxTarget = a;
a.fx = normFx(a.fx);
fxSnapshot = JSON.stringify(a.fx);
$('fx-name').textContent = `${a.id}.webp · 原始 ${a.orig.w}×${a.orig.h}`;
renderFxParams();
$('fxmodal').classList.add('on');
$('fx-info').textContent = '处理中…';
await fxPreview();
}
/** 关闭一律保存:预览是所见即所得,点 × 若不落盘,用户会以为已经改了。 */
async function closeFx(){
const a = fxTarget;
$('fxmodal').classList.remove('on');
fxTarget = null;
if (!a) return;
// 参数没动就一次像素都别碰:只是打开看一眼,不该让图被重新编码一遍(有损,会累积)
if (JSON.stringify(normFx(a.fx)) === fxSnapshot){
renderAll();
return;
}
const before = `${a.w}×${a.h}`;
await bakeAsset(a);
renderAll();
log(fxIsEmpty(a.fx)
? `${a.id}.webp 恢复原图(${a.w}×${a.h})`
: `${a.id}.webp 已处理:${before} → ${a.w}×${a.h},${a.kb}KB`, 'ok');
}
/* ---------------------- 处理算法自检(?dump=fx) ---------------------- */
/* 这几个断言都是踩过的坑,不能靠肉眼在缩略图上看:
- 封闭白孔(手内侧、两腿之间)四边漫水进不去,必须单独掏;
- 白袜子 237 与背景 253 只差 16,容差稍大就吃穿;
- 压白要按 v×(1-k)+255×k 算,否则和场景的雾度对不上。 */
function fxSelfTest(){
const W = 200, H = 220;
const mk = () => {
const cv = document.createElement('canvas');
cv.width = W; cv.height = H;
const g = cv.getContext('2d');
g.fillStyle = '#fdfdfd'; g.fillRect(0, 0, W, H); // 背景:真实场景就是 253
g.fillStyle = '#3b6ea5';
g.beginPath(); g.arc(100, 110, 60, 0, Math.PI * 2); g.fill(); // 主体
g.fillStyle = '#fdfdfd';
g.beginPath(); g.arc(100, 110, 12, 0, Math.PI * 2); g.fill(); // 封闭白孔(应被掏)
g.fillStyle = '#ededed'; // 白袜子 / 白衬衫 237
g.fillRect(70, 150, 20, 14);
return g;
};
const run = fx => {
const g = mk();
const id = g.getImageData(0, 0, W, H);
fxPixels(id, normFx(fx));
const at = (x, y) => {
const i = (y * W + x) * 4;
return { r: id.data[i], g: id.data[i+1], b: id.data[i+2], a: id.data[i+3] };
};
return { at, id };
};
const key = (tol, holes, feather) => ({ key: { on: true, tol, holes, feather } });
const checks = [];
const T = (name, got, want) => checks.push({ name, got, want, ok: JSON.stringify(got) === JSON.stringify(want) });
// 1) 四边漫水:角落应透明,主体内应不透明
const a = run(key(16, true, false));
T('角落(与边缘连通的白)', a.at(2, 2).a, 0);
T('主体内部', a.at(100, 60).a, 255);
// 2) 封闭白孔:开 holes 时要被掏掉
T('封闭白孔(holes 开)', a.at(100, 110).a, 0);
const b = run(key(16, false, false));
T('封闭白孔(holes 关,应保留)', b.at(100, 110).a, 255);
// 3) 白袜子 237 在容差 16 下保住、容差 30 下被吃穿
T('白袜子 237(容差 16)', a.at(80, 157).a, 255);
const c = run(key(30, true, false));
T('白袜子 237(容差 30,应被吃穿)', c.at(80, 157).a, 0);
// 4) 边缘收 1px:轮廓外扩一圈变透明,主体中心不受影响
const f = run(key(16, true, true));
T('收边后轮廓 1px', f.at(100, 50).a, 0);
T('收边后中心仍实心', f.at(100, 80).a, 255);
// 5) 不透明度 50% → alpha 减半
T('不透明度 50%', run({ alpha: 50 }).at(100, 80).a, 128);
// 6) 压白 20%:237 → round(237×0.8 + 255×0.2) = 241
const w = run({ wash: 20 });
T('压白 20%(237)', w.at(80, 157).r, 241);
T('压白 20%(背景 253)', w.at(2, 2).r, 253);
// 7) 去色 100%:蓝块变灰(三通道相等)
const gr = run({ gray: 100 }).at(100, 60);
T('去色 100% 后 R=G=B', [gr.r === gr.g, gr.g === gr.b], [true, true]);
// 8) 裁透明边:四周透明 20px 的图应裁到内容尺寸
const tc = document.createElement('canvas');
tc.width = 200; tc.height = 160;
const tg = tc.getContext('2d');
tg.fillStyle = '#000'; tg.fillRect(50, 60, 80, 40);
const cut = trimCanvas(tc);
T('裁透明边尺寸', [cut.width, cut.height], [80, 40]);
return {
pass: checks.every(c => c.ok),
failed: checks.filter(c => !c.ok).length,
checks,
};
}
/* ---------------------------- 示例 ---------------------------- */
function makeDemo(){
// 重复点「载入示例」不该把素材叠起来(会撞 id,导出时取到的是判断顺序里靠前的那个)
S.assets = [];
const bg = document.createElement('canvas');
bg.width = 1920; bg.height = 1080;
const g = bg.getContext('2d');
const gr = g.createLinearGradient(0, 0, 1920, 1080);
gr.addColorStop(0, '#dfeaf6'); gr.addColorStop(0.55, '#eef3f9'); gr.addColorStop(1, '#f7f2e8');
g.fillStyle = gr; g.fillRect(0, 0, 1920, 1080);
g.fillStyle = 'rgba(255,255,255,.5)';
for (let i = 0; i < 6; i++) g.fillRect(i * 340 + 60, 0, 46, 1080);
// 示例人物:只为看清「相对地面的比例」,所以用剪影而不追求好看。
// 比例按真实立绘取(1 : 3.2),头不要画大 —— 头一大就会读成方块人。
const ch = document.createElement('canvas');
ch.width = 300; ch.height = 960;
const c = ch.getContext('2d');
c.fillStyle = '#42506a';
c.strokeStyle = 'rgba(126,205,215,.95)';
c.lineWidth = 9;
c.beginPath(); c.ellipse(150, 34, 60, 16, -0.12, 0, Math.PI * 2); c.stroke(); // 光环
c.beginPath(); c.ellipse(150, 122, 52, 60, 0, 0, Math.PI * 2); c.fill(); // 头
c.fillRect(138, 174, 24, 24); // 颈
c.beginPath(); // 上身
c.moveTo(96, 198); c.lineTo(204, 198); c.lineTo(188, 468); c.lineTo(112, 468);
c.closePath(); c.fill();
c.beginPath(); // 裙摆
c.moveTo(112, 460); c.lineTo(188, 460); c.lineTo(216, 600); c.lineTo(84, 600);
c.closePath(); c.fill();
c.fillRect(112, 596, 30, 252); // 腿
c.fillRect(158, 596, 30, 252);
c.fillStyle = '#1e2634';
c.beginPath(); c.ellipse(127, 862, 27, 15, 0, 0, Math.PI * 2); c.fill(); // 鞋
c.beginPath(); c.ellipse(173, 862, 27, 15, 0, 0, Math.PI * 2); c.fill();
// 示例素材也走完整结构(orig + fx):否则打开弹窗时 decodeAsset 找不到原图
const bgA = canvasAsset(bg, 0.82);
const chA = canvasAsset(ch, 0.9);
S.assets.push({ id:'bg', name:'示例背景', orig:{ dataURL:bgA.dataURL, w:bgA.w, h:bgA.h }, fx: fxDefault(), ...bgA });
S.assets.push({ id:'char', name:'示例人物', orig:{ dataURL:chA.dataURL, w:chA.w, h:chA.h }, fx: fxDefault(), ...chA });
// 竖版背景:手机上 16:9 会被 cover 裁成中间一条,示例里给一张竖图,
// 「窄屏」那一栏才有东西可演示、@media 段也才会真的生成
const mb = document.createElement('canvas');
mb.width = 1080; mb.height = 1920;
const mg = mb.getContext('2d');
const mgr = mg.createLinearGradient(0, 0, 0, 1920);
mgr.addColorStop(0, '#dfeaf6'); mgr.addColorStop(0.6, '#eef3f9'); mgr.addColorStop(1, '#f7f2e8');
mg.fillStyle = mgr; mg.fillRect(0, 0, 1080, 1920);
const mba = canvasAsset(mb, 0.8);
S.assets.push({ id:'bg-m', name:'示例竖版背景', orig:{ dataURL:mba.dataURL, w:mba.w, h:mba.h }, fx: fxDefault(), ...mba });
const gc = S.scopes.global;
gc.bg = 'bg';
gc.layers = [
Object.assign(newLayer('image'),
{ asset:'char', h:420, ah:'start', av:'end', dx:40, dy:-36, mob:'scale' }),
Object.assign(newLayer('radial'), { color:'#ffd9a0', alpha:0.42, r:320, cx:20, cy:86 }),
];
const ac = S.scopes.admin;
ac.inherit = false;
ac.bg = null;
ac.layers = [Object.assign(newLayer('radial'), { color:'#ffcf9a', alpha:0.38, r:380, cx:86, cy:10 })];
S.curLayer = null;
S.scopes.box = blankScope('box');
S.scopes.question = blankScope('question');
S.scopes.profile = blankScope('profile');
S.scopes.vote = blankScope('vote');
S.narrow.bg = 'bg-m';
renderAll();
log('已载入示例。左上角切换 1512 / 390 两个视口,能直接看到「被裁」与「自适应」的差别;'
+ '题目列表页是「独立配置」的例子。', 'ok');
}
/* ---------------------------- 初始化 ---------------------------- */
function boot(){
const params = new URLSearchParams(location.search);
const k = params.get('scope');
if (k && SCOPES.some(s => s.k === k)) S.scope = k;
// 自动恢复上次的草稿:调主题是连续的活,每次重开都从零开始等于没有工具
const restored = params.get('fresh') ? false : restoreDraft();
$('slug').value = S.slug;
renderViewportBar();
renderAll();
loadPreview();
fitStage();
if (restored){
log('已恢复上次的草稿(换 slug / 项目时可点「打开工程」,或加 ?fresh=1 从空白开始)。', 'ok');
} else {
out('在项目根执行 python dev/theme-builder/serve.py,用 http://127.0.0.1:8777/dev/theme-builder/ 打开,才能预览真实页面。');
}
$('preview').addEventListener('load', onFrameLoad);
$('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 = () => {
// 跟随全局时先切成独立配置(并把全局那几层复制过来当起点),
// 否则改动会写到 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');
}
if (S.narrow.enabled && S.narrow.hideDeco){
log('注意:「窄屏隐藏全部装饰层」开着 —— 这一层在手机/窄窗口下也不会显示', 'w');
}
};
$('btn-save').onclick = () => {
const errs = validate();
const warn = errs.length ? '(注意:还有 ' + errs.length + ' 处未处理,工程仍可保存)' : '';
const j = JSON.stringify(toJSON());
const blob = new Blob([j], { type: 'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `theme-${S.slug}.json`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 4000);
log(`已保存 theme-${S.slug}.json(${Math.round(blob.size / 1024)}KB)${warn}`, 'ok');
};
$('btn-open').onclick = () => $('projfile').click();
$('projfile').onchange = async e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (!f) return;
try {
fromJSON(JSON.parse(await f.text()));
refreshUIFromState();
log(`已打开工程 ${f.name}`, 'ok');
} catch (err){
log('打开失败:' + err.message, 'e');
}
};
$('add-asset').onclick = () => $('file').click();
$('file').onchange = e => { addFiles([...e.target.files]); e.target.value = ''; };
// 图片处理弹窗:× / 完成 / 点遮罩都保存(预览即所见,取消会让人以为已经改了)
$('fx-close').onclick = () => closeFx();
$('fx-apply').onclick = () => closeFx();
$('fxmodal').onclick = e => { if (e.target === $('fxmodal')) closeFx(); };
$('fx-reset').onclick = () => {
if (!fxTarget) return;
fxTarget.fx = fxDefault();
renderFxParams();
fxPreview();
};
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && fxTarget) closeFx();
});
$('inherit').onchange = e => {
if (S.scope === 'global') return;
S.scopes[S.scope].inherit = e.target.checked;
renderAll();
};
window.addEventListener('resize', fitStage);
if (params.get('demo')) makeDemo();
// ?narrow=hide 直接落在「窄屏隐藏全部装饰层」状态:截图与回归用。
// 这个状态是「素材在手机上不见了」最典型的来源,得能一键复现。
if (params.get('narrow') === 'hide'){
S.narrow.hideDeco = true;
renderAll();
}
// ?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){
const want = params.get('name') || loadURL.split('/').pop().replace(/\.[^.]+$/, '');
addUrlAsset(loadURL, want).then(a => {
// ?fxjson={"key":{"on":true}} 用来直接给出初始处理参数(截图 / 复现某组参数用)
const fj = params.get('fxjson');
if (fj){ try { a.fx = normFx(JSON.parse(fj)); } catch (_) {} }
renderAll();
if (params.get('fx')) openFx(a);
}).catch(e => log('载入失败:' + e.message, 'e'));
} else if (params.get('fx')){
// ?fx=<素材id> 直接打开处理弹窗(截图和人工核对用)
const a0 = assetById(params.get('fx'));
if (a0) setTimeout(() => openFx(a0), 60);
}
// 自检钩子:?dump=css 导出最终 CSS,?dump=zip 导出 zip 的 base64。
// 无头浏览器里用 --dump-dom 抓这两个值,就能在没有 UI 交互的情况下做回归。
const dump = params.get('dump');
if (dump){
const pre = document.createElement('pre');
pre.id = 'dump';
if (dump === 'css'){
pre.textContent = buildCSS(false);
} else if (dump === 'validate'){
pre.textContent = JSON.stringify(validate(), null, 1);
} else if (dump === 'install'){
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% 透明)对比,差值应很小。
(async () => {
const r = {};
try {
const a = await addUrlAsset(params.get('src') || '/outputs/fxtest-whitebg.png', 'cut');
r.origSize = [a.w, a.h];
const stat = (tol, holes, feather) => {
const cv = renderFxCanvas(a._img, { key:{ on:true, tol, holes, feather } });
const d = cv.getContext('2d').getImageData(0, 0, cv.width, cv.height).data;
let t = 0;
for (let i = 3; i < d.length; i += 4) if (d[i] < 8) t++;
return { size:[cv.width, cv.height], clearPct: +(t / (d.length/4) * 100).toFixed(1) };
};
r.tol16 = stat(16, true, false);
r.tol16_noHoles = stat(16, false, false);
r.tol16_feather = stat(16, true, true);
r.tol30 = stat(30, true, false);
r.expect = 46.6;
r.ok = Math.abs(r.tol16.clearPct - 46.6) < 4;
} catch (e){
r.error = e.message;
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fxpipe'){
// 异步链路冒烟:弹窗打开 → 改参数 → 预览渲染 → 关闭落盘。
// 结论写进 pre,靠 --dump-dom 在 virtual-time-budget 结束后抓。
(async () => {
const r = {};
try {
makeDemo();
const a = assetById('char');
const sig = x => x.dataURL.slice(-48);
const before = sig(a), bw = a.w;
await openFx(a);
r.modalOn = $('fxmodal').classList.contains('on');
r.previewW = $('fx-canvas').width;
r.previewH = $('fx-canvas').height;
r.sliderCount = $('fx-params').querySelectorAll('input[type=range]').length;
a.fx.wash = 20;
a.fx.gray = 30;
await fxPreview();
r.previewW2 = $('fx-canvas').width;
await closeFx();
r.modalOff = !$('fxmodal').classList.contains('on');
r.wash = a.fx.wash;
r.fxOn = !fxIsEmpty(a.fx);
r.summary = fxSummary(a.fx);
r.rerendered = before !== sig(a);
r.sizeKept = a.w === bw;
r.kb = a.kb;
// 再打开一次:参数要还在(normFx 不能把值吃掉)
const sigKeep = sig(a);
await openFx(a);
r.reopenWash = (() => {
const s = [...$('fx-params').querySelectorAll('input[type=range]')];
return s.length ? +s[1].value : -1;
})();
await closeFx();
// 打开看一眼就关:像素必须一个字节都不变(否则有损编码会累积)
r.untouchedKept = sigKeep === sig(a);
r.ok = r.modalOn && r.modalOff && r.rerendered && r.reopenWash === 20
&& r.previewW > 0 && r.untouchedKept;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'fit'){
// 尺寸自适应自检:同一层在「桌机」与「手机」下的实际占用、溢出检测、
// 以及老工程(没有 unit 字段)的向后兼容
(async () => {
const r = {};
try {
makeDemo();
const l = S.scopes.global.layers[0];
const a = assetById('char');
const m900 = layerMetrics(l, a, 1512, 900);
const m844 = layerMetrics(l, a, 390, 844);
r.desktop = { w: Math.round(m900.w), h: Math.round(m900.h), overR: Math.round(m900.overR) };
r.mobile = { w: Math.round(m844.w), h: Math.round(m844.h), overR: Math.round(m844.overR) };
r.shorterSmaller = m844.h < m900.h;
const c0 = layerCSS(l, true);
r.size = c0.size;
r.pos = c0.position;
r.hasClamp = /clamp\(/.test(c0.size);
// 固定 px + 居中 + 高图:必须报上下溢出(这就是「像站在桌子上」那一类)
const big = Object.assign({}, l, { unit:'px', h:1200, w:0, ah:'center', dx:0 });
const mb = layerMetrics(big, a, 1512, 900);
r.pxOverflow = { overT: Math.round(mb.overT), overB: Math.round(mb.overB) };
r.pxDetected = mb.overT > 0 || mb.overB > 0;
// 老工程没有 unit/mob 字段:必须退回 px,不能算成 NaN
const legacy = Object.assign({}, l);
delete legacy.unit; delete legacy.mob;
r.legacyUnit = unitOf(legacy);
// fitw(按宽度自适应):宽幅素材在窄屏必须跟着缩,且 55% 下限要让位给视口
const bgA = assetById('bg');
const wide = Object.assign({}, l, { unit:'fitw', h:600, w:0, ah:'center', av:'center', dx:0, dy:0 });
const wWide = [[1512,900],[1024,768],[390,844]]
.map(([W, H]) => Math.round(layerMetrics(wide, bgA, W, H).w));
r.fitw = wWide;
r.fitwCss = layerSize(wide, bgA);
r.fitwShrinks = wWide[0] > wWide[1];
r.fitwNoOverflow = wWide[2] <= 390;
// 窄屏策略
S.narrow.hideDeco = false;
S.narrow.scale = 0.62;
r.mobScaled = decoMobile(Object.assign({}, l, { mob:'scale' })).h;
r.mobHidden = decoMobile(Object.assign({}, l, { mob:'hide' }));
const inh = Object.assign({}, l, { mob:'inherit' });
r.mobInherit = decoMobile(inh) === inh; // 原样返回,不复制
r.media = /@media \(max-width: 900px\)/.test(buildCSS(false));
// 窄屏里独立配置的页面也必须有一条(否则特异性不够,规则不生效)
r.mediaAdmin = /@media[\s\S]*data-page-scope="admin"/.test(buildCSS(false));
r.ok = r.shorterSmaller && r.hasClamp && r.pxDetected && r.legacyUnit === 'px'
&& r.mobScaled === 260 && r.mobHidden === null && r.mobInherit
&& r.fitwShrinks && r.fitwNoOverflow
&& r.media && r.mediaAdmin;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else if (dump === 'quality'){
// 画质自检:质量越低体积越小;长边上限必须真的把像素压下来。
// ⚠️ 必须用「有噪声」的图来验:平涂线稿在 WebP 下 q=1 常常比 q=0.6 还小
// (无损模式对纯色更高效,有损反而引入噪点),所以「降 q 一定省体积」不成立。
(async () => {
const r = {};
try {
const cv = document.createElement('canvas');
cv.width = 800; cv.height = 600;
const g = cv.getContext('2d');
const id = g.createImageData(800, 600);
let seed = 12345;
for (let i = 0; i < id.data.length; i += 4){
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
id.data[i] = seed & 255;
id.data[i+1] = (seed >> 8) & 255;
id.data[i+2] = (seed >> 16) & 255;
id.data[i+3] = 255;
}
g.putImageData(id, 0, 0);
const q6 = canvasAsset(cv, 0.6), q9 = canvasAsset(cv, 0.95), q1 = canvasAsset(cv, 1);
r.kb60 = q6.kb; r.kb95 = q9.kb; r.kb1 = q1.kb;
r.monotonic = q6.bytes.length < q9.bytes.length;
// q=1 会被夹到 0.95,否则会走 Skia 的 WebP 无损路径,体积反而小得离谱
r.oneClamped = q1.bytes.length === q9.bytes.length;
// 平涂对照:只记录,不作为判定条件(有损对纯色会引入噪点,可能变大)
const flat = document.createElement('canvas');
flat.width = 800; flat.height = 600;
const fg = flat.getContext('2d');
fg.fillStyle = '#4a7fc1'; fg.fillRect(0, 0, 800, 600);
r.flat_kb60 = canvasAsset(flat, 0.6).kb;
r.flat_kb95 = canvasAsset(flat, 0.95).kb;
const img = await new Promise((res, rej) => {
const im = new Image();
im.onload = () => res(im);
im.onerror = () => rej(new Error('canvas → Image 失败'));
im.src = cv.toDataURL('image/png');
});
const lim = renderFxCanvas(img, fxDefault(), 0, 200);
r.limited = [lim.width, lim.height]; // 长边 200 → 200×150
const noLim = renderFxCanvas(img, fxDefault(), 0, 0);
r.unlimited = [noLim.width, noLim.height]; // 800×600
r.ok = r.monotonic && r.oneClamped && r.limited[0] === 200 && r.limited[1] === 150
&& r.unlimited[0] === 800;
} catch (e){
r.error = e.message + ' | ' + (e.stack || '').split('\n')[1];
r.ok = false;
}
pre.textContent = JSON.stringify(r, null, 1);
})();
} else {
const files = [{ name: `${S.slug}.css`, data: enc(buildCSS(false)) }];
const bytes = zipStore(files);
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
pre.textContent = btoa(bin);
}
pre.style.display = 'none';
document.body.appendChild(pre);
}
}
boot();
</script>
</body>
</html>