主题素材只有一处引用:主题 css 的 url()(分享卡是 Go 用 imaging 画的,不读素材), 所以只要让 CSS 里的地址指向对象存储即可。 - 导出面板新增「素材前缀」:空 = /static/images/themes/<slug>/(图片随二进制发布,与以前一致); 填 https://cdn.example.com/themes = CSS 写绝对 URL,zip 里的图片改为「上传用」 - 地址统一由 urlOf() 决定。修了一个重复实现:layerCSS 里复制过一份路径拼接, 只改 urlOf 会出现「背景走 CDN、装饰还指本地」 - 前缀随意切换不影响预览(预览恒用内联 dataURL);存进工程 json;?assetbase= 可自检 - INSTALL 文案按是否托管分叉,写清图片传到哪、换域名要重新导出 - 新增 docs/theme-assets-cdn.md:三种做法(生成器前缀 / 应用内 301 / 边缘改写)、 缓存与 ?v=、CORS 不需要、深色无素材、迁移用的 sed 命令 - README 更新;UI 修正:240px 面板里 4 字标签会被 flex 压窄,需 flex:0 0 auto + 84px
3192 lines
137 KiB
HTML
3192 lines
137 KiB
HTML
<!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: {
|
||
/* primary 从 #4a7fc1 压深到 #3d6fac:前者在 #eef3f9 上只有 3.69:1,
|
||
14px 的链接/按钮文字不达 AA。默认值应当是可用的,不能一上手就踩红线。 */
|
||
bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)',
|
||
text:'#222d3b', primary:'#3d6fac', 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 },
|
||
/* 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
|
||
填了(如 https://cdn.example.com/themes)= 导出的 CSS 写绝对 URL,图片放对象存储。
|
||
前缀里不要带 slug,slug 由导出时自动拼在后面。 */
|
||
assetBase: '',
|
||
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),
|
||
assetBase: '',
|
||
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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'};
|
||
// 统一走 urlOf:素材前缀(CDN)只在这一处判定,别再复制一份拼接逻辑
|
||
return { image:`url("${urlOf(a, preview)}")`, 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){
|
||
if (preview) return a.dataURL; // 预览必须用内联 dataURL,否则改一张图要等一轮部署
|
||
const base = String(S.assetBase || '').trim().replace(/\/+$/, '');
|
||
return base ? `${base}/${S.slug}/${a.id}.webp` : `/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})`;
|
||
}
|
||
/** 半透明色压到某个不透明底色上,得到「实际看到」的颜色。
|
||
卡片的 --tb-surface 常写成 rgba(255,255,255,0.86),直接拿它算对比度是错的。 */
|
||
function flatten(c, under){
|
||
const A = parseColor(c), B = parseColor(under);
|
||
if (A[3] >= 1) return `rgb(${A[0]}, ${A[1]}, ${A[2]})`;
|
||
const m = i => Math.round(A[i] * A[3] + B[i] * (1 - A[3]));
|
||
return `rgb(${m(0)}, ${m(1)}, ${m(2)})`;
|
||
}
|
||
/* WCAG 相对亮度 / 对比度。文字色的下限不是审美问题,是硬指标:
|
||
正文 4.5:1(AA)。base.css 已经按这个标准重算过 --tb-text-muted,
|
||
主题若按「往底色混 55%」拍脑袋派生,等于每次生成都把那个修复覆盖回去。 */
|
||
function relLum(c){
|
||
const A = parseColor(c);
|
||
const f = v => {
|
||
v /= 255;
|
||
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
|
||
};
|
||
return 0.2126 * f(A[0]) + 0.7152 * f(A[1]) + 0.0722 * f(A[2]);
|
||
}
|
||
function contrastRatio(a, b){
|
||
const la = relLum(a), lb = relLum(b);
|
||
return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
|
||
}
|
||
/** 在 text→bg 的连线上取「最淡但仍达标」的点:二分找出刚好达标的混合比例。
|
||
文字本来就不达标(底色与文字太接近)时原样返回文字色 —— 宁可不够淡,
|
||
也不能产出一行看不见的字。bgs 传多个时按最不利的那个算(卡片叠在底色上)。 */
|
||
function mixToContrast(text, bgs, target){
|
||
const worst = t => Math.min(...bgs.map(b => contrastRatio(mixRGB(text, bgs[0], t), b)));
|
||
if (worst(0) <= target) return text;
|
||
let lo = 0, hi = 0.92;
|
||
for (let i = 0; i < 14; i++){
|
||
const mid = (lo + hi) / 2;
|
||
if (worst(mid) >= target) lo = mid; else hi = mid;
|
||
}
|
||
// 往下收半档:浮点四舍五入后掉回 4.49:1 这种「差一点」最难查
|
||
return mixRGB(text, bgs[0], Math.floor(lo * 100) / 100);
|
||
}
|
||
/** 文字要同时落在页面底色和卡片上:把卡片的不透明度压平后,取两者中更不利的那个。 */
|
||
function textBackdrops(p){
|
||
return [p.bg, flatten(p.surface, p.bg)];
|
||
}
|
||
|
||
/** 主色作背景时的文字色:白字和深字里挑对比度更高的那个。
|
||
浅色模式主色深 → 白字;深色模式主色普遍偏亮(#5c8dff 之流),白字只有 2~3:1,
|
||
必须换成深字。不写这个令牌就只能吃 base.css 的默认值,深浅两套必然错一套。 */
|
||
function onPrimary(primary){
|
||
return contrastRatio('#ffffff', primary) >= contrastRatio('#0b1016', primary) ? '#ffffff' : '#0b1016';
|
||
}
|
||
|
||
function paletteVars(p){
|
||
const back = textBackdrops(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: ${mixToContrast(p.text, back, 5.5)};`,
|
||
` --tb-text-muted: ${mixToContrast(p.text, back, 4.5)};`,
|
||
` --tb-primary: ${p.primary};`,
|
||
` --tb-primary-weak: ${withAlpha(p.primary, 0.12)};`,
|
||
` --tb-on-primary: ${onPrimary(p.primary)};`,
|
||
].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);
|
||
|
||
// 素材前缀:空 = 走站点自己的 /static/images/themes(图片随二进制发布);
|
||
// 填了 = 导出的 CSS 写绝对 URL,图片自己传对象存储(R2 / OSS)。
|
||
// 只影响导出,不影响预览 —— 预览永远用内联 dataURL。
|
||
const r3 = row();
|
||
const baseIn = document.createElement('input');
|
||
baseIn.type = 'url';
|
||
baseIn.placeholder = '空 = /static/images/themes';
|
||
baseIn.value = S.assetBase || '';
|
||
baseIn.style.cssText = 'flex:1 1 0;width:0;min-width:0';
|
||
baseIn.title = '填 https://cdn.example.com/themes 这种前缀(不要带 slug),导出的 CSS 就会写绝对地址;'
|
||
+ '图片仍会打进 zip,方便你上传。留空则沿用站点静态目录';
|
||
baseIn.onchange = () => {
|
||
S.assetBase = baseIn.value.trim().replace(/\/+$/, '');
|
||
renderAll();
|
||
};
|
||
// 标签固定宽度:flex 会把 4 个中文字的 label 压窄,末字被输入框切掉
|
||
const baseLbl = label('素材前缀', '84px');
|
||
baseLbl.style.cssText = 'flex:0 0 auto;white-space:nowrap';
|
||
r3.append(baseLbl, baseIn);
|
||
|
||
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);
|
||
|
||
if (S.assetBase){
|
||
const cdn = document.createElement('p');
|
||
cdn.className = 'hint';
|
||
cdn.textContent = `图片地址将写成 ${S.assetBase.replace(/\/+$/, '')}/${S.slug}/<id>.webp —— `
|
||
+ '记得把 zip 里的 static/images/themes/' + S.slug + '/ 整个传到这个路径下;'
|
||
+ '仓库里就不需要这些图了。换 CDN 域名要重新导出 CSS。';
|
||
el.appendChild(cdn);
|
||
}
|
||
|
||
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);
|
||
});
|
||
// 对比度读数:主题能改文字色,也就有能力把页面改到看不清。
|
||
// 派生值现在有下限(不达标就自动收淡),但用户手填的 text / primary 没有 —— 只提醒、不拦。
|
||
const back = textBackdrops(S.palette);
|
||
const worstOf = c => Math.min(...back.map(b => contrastRatio(c, b)));
|
||
const sec = mixToContrast(S.palette.text, back, 5.5);
|
||
const mut = mixToContrast(S.palette.text, back, 4.5);
|
||
const crL = document.createElement('p');
|
||
crL.className = 'hint';
|
||
const items = [
|
||
['正文', worstOf(S.palette.text), 4.5],
|
||
['次要', worstOf(sec), 4.5],
|
||
['淡文字', worstOf(mut), 4.5],
|
||
['主色', worstOf(S.palette.primary), 4.5],
|
||
];
|
||
crL.innerHTML = items.map(([n, v, lim]) =>
|
||
`<span style="color:${v < lim ? '#e0a33e' : 'inherit'}">${n} ${v.toFixed(1)}:1</span>`
|
||
).join(' · ') + '<br>低于 4.5:1(WCAG AA 正文)会变橙。次要/淡文字由正文自动派生并保底。';
|
||
wrap.appendChild(crL);
|
||
|
||
// 主色卡在中间调时,白字和深字都不到 4.5 —— 这不是文字色挑错,是主色本身要动。
|
||
const bestOn = Math.max(contrastRatio('#ffffff', S.palette.primary),
|
||
contrastRatio('#0b1016', S.palette.primary));
|
||
if (bestOn < 4.5){
|
||
const warn = document.createElement('p');
|
||
warn.className = 'hint';
|
||
warn.style.color = '#e0a33e';
|
||
warn.textContent = `⚠ 主色偏中间调:白字 ${contrastRatio('#ffffff', S.palette.primary).toFixed(2)}:1、`
|
||
+ `深字 ${contrastRatio('#0b1016', S.palette.primary).toFixed(2)}:1,按钮上的字都不够清楚。`
|
||
+ '把主色压深一点(配白字)或提亮一点(配深字)就能过。';
|
||
wrap.appendChild(warn);
|
||
}
|
||
|
||
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,
|
||
assetBase: S.assetBase,
|
||
// 图片内嵌 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.assetBase = typeof o.assetBase === 'string' ? o.assetBase : '';
|
||
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.assetBase = '';
|
||
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} —— 安装说明
|
||
=====================================
|
||
|
||
${S.assetBase
|
||
? `1) 解压
|
||
static/css/themes/${slug}.css ← 放进仓库(CSS 里已是绝对地址)
|
||
static/images/themes/${slug}/*.webp ← 传到 ${S.assetBase.replace(/\/+$/, '')}/${slug}/,仓库里不需要
|
||
|
||
素材托管在对象存储,CSS 写的是绝对地址:
|
||
换 CDN 域名 / 路径 = 必须重新导出(地址烘焙在文件里,不是运行时拼的)
|
||
换图 = 覆盖同名对象,建议把 Cache-Control 设成 immutable + 用 ?v= 或换文件名做失效`
|
||
: `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();
|
||
}
|
||
// ?assetbase=https://cdn.example.com/themes 模拟「素材放对象存储」的导出结果
|
||
if (params.get('assetbase')){
|
||
S.assetBase = params.get('assetbase');
|
||
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 if (dump === 'palette'){
|
||
// 派生色的对比度下限:base.css 把 --tb-text-muted 按 WCAG AA(4.5:1) 重算过,
|
||
// 生成器若还按固定比例往底色混,等于每次导出都把那个修复覆盖回去。
|
||
const r = { cases: {} };
|
||
try {
|
||
const cases = {
|
||
'默认亮色': { bg:'#eef3f9', surface:'rgba(255,255,255,0.86)', navbar:'rgba(255,255,255,0.52)', text:'#222d3b', primary:'#3d6fac', border:'#d6e0ec' },
|
||
'默认深色': { bg:'#141a22', surface:'#1d2530', navbar:'#141a22', text:'#e3eaf4', primary:'#6fa3e0', border:'#334052' },
|
||
// 极端:文字色本身就不达标 → 必须原样返回,不能继续往底色淡
|
||
'低对比手填': { bg:'#f4f4f4', surface:'#ffffff', navbar:'#ffffff', text:'#c8ccd2', primary:'#dddddd', border:'#e5e5e5' },
|
||
// 纯白卡片压在彩色底上:卡片侧才是更不利的那个
|
||
'白卡彩底': { bg:'#f6d9e4', surface:'#ffffff', navbar:'#f6d9e4', text:'#3b2b33', primary:'#c2557d', border:'#efd3de' },
|
||
};
|
||
let allOk = true;
|
||
const warns = [];
|
||
for (const name in cases){
|
||
const p = cases[name];
|
||
const back = textBackdrops(p);
|
||
const sec = mixToContrast(p.text, back, 5.5);
|
||
const mut = mixToContrast(p.text, back, 4.5);
|
||
const worst = c => Math.min(...back.map(b => contrastRatio(c, b)));
|
||
const row = {
|
||
secondary: +worst(sec).toFixed(2),
|
||
muted: +worst(mut).toFixed(2),
|
||
rawText: +worst(p.text).toFixed(2),
|
||
mutedDarkerThanSecondary: worst(mut) <= worst(sec), // 层级:淡文字必须比次要更淡
|
||
// 主色作底的文字:白字 vs 深字里必须挑更优的那个。
|
||
// 注意这里不断言「一定 ≥4.5」:主色卡在中间调时,两种文字色都不够,
|
||
// 这不是挑错了,而是主色本身要调 —— 列进 warnings 提醒,不判失败。
|
||
onPrimary: onPrimary(p.primary),
|
||
onPrimaryCr: +contrastRatio(onPrimary(p.primary), p.primary).toFixed(2),
|
||
onPrimaryPickedBest: Math.abs(
|
||
contrastRatio(onPrimary(p.primary), p.primary) -
|
||
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))
|
||
) < 0.01,
|
||
// 关键断言:只要有 4.5 的空间就必须达标;本来就不达标的不算错
|
||
ok: ((worst(p.text) < 4.5) ? (mut === p.text) : (worst(mut) >= 4.5 && worst(sec) >= 4.5))
|
||
&& Math.abs(contrastRatio(onPrimary(p.primary), p.primary) -
|
||
Math.max(contrastRatio('#ffffff', p.primary), contrastRatio('#0b1016', p.primary))) < 0.01,
|
||
};
|
||
if (row.onPrimaryCr < 4.5) warns.push(name + '(主色 ' + p.primary + ' 中间调,白字/深字都不到 4.5)');
|
||
// 卡片半透明时必须按压平后的颜色算,否则白卡 86% 会被当成 #fff
|
||
row.surfaceFlat = flatten(p.surface, p.bg);
|
||
r.cases[name] = row;
|
||
if (!row.ok) allOk = false;
|
||
}
|
||
// 阶梯:淡文字绝不能比次要文字更醒目
|
||
for (const name in r.cases){
|
||
if (!r.cases[name].mutedDarkerThanSecondary) allOk = false;
|
||
}
|
||
// demo 的两套配色也要过:导出后的 CSS 直接吃这两个值
|
||
r.demoLight = { primary: S.palette.primary, on: onPrimary(S.palette.primary),
|
||
cr: +contrastRatio(onPrimary(S.palette.primary), S.palette.primary).toFixed(2) };
|
||
r.demoDark = { primary: S.dark.primary, on: onPrimary(S.dark.primary),
|
||
cr: +contrastRatio(onPrimary(S.dark.primary), S.dark.primary).toFixed(2) };
|
||
if (r.demoLight.cr < 4.5) warns.push('demo 亮色(可改 palette.primary)');
|
||
if (r.demoDark.cr < 4.5) warns.push('demo 深色(可改 dark.primary)');
|
||
r.warnings = warns;
|
||
// 把生成结果里的派生值也打出来,方便和 base.css 的取值对照
|
||
const css = buildCSS(false);
|
||
r.mutedLine = (css.match(/--tb-text-muted:[^;]+;/) || [''])[0];
|
||
r.secondaryLine = (css.match(/--tb-text-secondary:[^;]+;/) || [''])[0];
|
||
r.ok = allOk;
|
||
} 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>
|