/**
* 画个标 · UI:动态表情工作台(独立模块,不加载共享 panels.js)
* 职责:行业/动画类型/图标配置、表情方案批量生成、方案库动画预览、
* 预览弹窗(换动画/换配色/换布局)、GIF/WebM/PNG 导出、内置库选择
* 动画预览与导出同源:卡片/预览 rAF 驱动 HGB_Motion.apply,导出逐帧采样同一 apply
*/
const HGB_EmojiUI = (function () {
'use strict';
/* ================= 工具 ================= */
function esc(s) {
return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
}
function svgInline(svgStr) {
const div = document.createElement('div');
div.innerHTML = svgStr;
const svg = div.querySelector('svg');
return svg ? svg.outerHTML : '';
}
let toastTimer = null;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.add('hidden'), 2600);
}
function download(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 6000);
}
function safeName(plan) {
return (String(plan.name || '表情').replace(/[\\/:*?"<>|]/g, '') || '表情');
}
/* ================= 状态 ================= */
const state = {
industry: null,
mood: 'happy', // 情绪(影响动画动效;与 emotion-params 技能同源)
icons: [], // 已选图标 [{id,name,svg,source}]
plans: [], // 同图 × 全部动画类型的一组表情
activePlan: null,
genParam: null
};
// 情绪 → 动画决策选项(与 emotion-params 技能映射一致,AI 与内置引擎共用)
const MOODS = [
{ id: 'happy', emoji: '😄', name: '开心' },
{ id: 'angry', emoji: '😠', name: '生气' },
{ id: 'surprised', emoji: '😲', name: '惊讶' },
{ id: 'sad', emoji: '😢', name: '难过' },
{ id: 'excited', emoji: '🤩', name: '兴奋' },
{ id: 'calm', emoji: '😌', name: '平静' }
];
// 情绪 → emotion-fit 评审关键词(中文)
const MOOD_KEYWORD = { happy: '开心', angry: '生气', surprised: '惊讶', sad: '难过', excited: '兴奋', calm: '平静' };
// 动画预览节点(rAF 驱动)
let animNodes = [];
let rafId = null;
/* ================= 动画循环 ================= */
function collectNodes(svgEl, plan) {
const els = HGB_EmojiGen.markElements(plan).filter(e => e.visible && e.type === 'icon');
const gs = Array.from(svgEl.children).filter(c => c.tagName === 'g');
return gs.slice(0, els.length).map((g, i) => ({
g, el: els[i], i, base: g.getAttribute('transform') || ''
}));
}
function registerAnim(svgEl, plan) {
animNodes.push({ svg: svgEl, plan, nodes: collectNodes(svgEl, plan) });
}
function tick(now) {
animNodes = animNodes.filter(an => an.svg && an.svg.isConnected);
for (const an of animNodes) {
const t = HGB_Motion.previewT(now, an.plan.anim);
for (const nd of an.nodes) {
if (!nd.el) continue;
// 分层动画:各元素按索引错峰取时间,与导出帧同源
const tEl = HGB_Motion.elementT(t, an.plan.anim, nd.i, an.nodes.length);
const f = HGB_Motion.apply(nd.el, tEl, an.plan.anim);
nd.g.setAttribute('transform', HGB_Motion.composeTransform(nd.el, f, nd.base));
if (f.opacity < 1) nd.g.setAttribute('opacity', String(Math.round(f.opacity * 1000) / 1000));
else nd.g.removeAttribute('opacity');
}
}
rafId = requestAnimationFrame(tick);
}
function startLoop() {
if (!rafId) rafId = requestAnimationFrame(tick);
}
/* ================= 渲染:行业 ================= */
function renderIndustries() {
const wrap = document.getElementById('design-industry');
wrap.innerHTML = HGB_Industries.map(ind => `
`).join('');
wrap.querySelectorAll('.industry-tab').forEach(btn => {
btn.addEventListener('click', () => {
state.industry = HGB_Industries.find(btn.dataset.industry);
state.icons = [];
state.plans = [];
state.activePlan = null;
renderIndustries();
renderIcons();
renderPlans();
toast('🏭 已切换到「' + state.industry.name + '」行业');
});
});
}
/* ================= 渲染:情绪 ================= */
function renderMood() {
const wrap = document.getElementById('design-mood');
wrap.innerHTML = MOODS.map(m => `
`).join('');
wrap.querySelectorAll('[data-mood]').forEach(btn => {
btn.addEventListener('click', () => {
state.mood = btn.dataset.mood;
renderMood();
toast('😊 情绪已设为「' + MOODS.find(m => m.id === state.mood).name + '」');
});
});
}
/* ================= 渲染:选中图标 ================= */
function renderIcons() {
const wrap = document.getElementById('design-icons');
if (!state.icons.length) {
wrap.innerHTML = '
未选择图标(AI 自动从行业与内置库选用)
';
return;
}
wrap.innerHTML = state.icons.map((ic, i) => `
${svgInline(``)}
`).join('');
wrap.querySelectorAll('.icon-del').forEach(btn => {
btn.addEventListener('click', () => {
state.icons.splice(Number(btn.dataset.del), 1);
renderIcons();
});
});
}
/* ================= 生成 ================= */
async function generate() {
const genBtn = document.getElementById('btn-generate');
genBtn.disabled = true;
const old = genBtn.textContent;
genBtn.textContent = '⏳ 生成中…';
try {
const opt = {
industry: state.industry,
brand: '表情', // 纯图形无文字,品牌名不影响
sub: '',
desc: '',
mood: state.mood, // 情绪 → AI 编排/降级均按情绪匹配主动画
icons: state.icons.map(i => ({ id: i.id, name: i.name, svg: i.svg })),
styleIds: [],
skills: [],
count: 1 // 一张底图 → 展开为全部动画类型
};
const plans = await HGB_EmojiGen.generateEmojiSet(opt);
state.plans = plans;
state.activePlan = plans[0] || null;
state.genParam = opt;
renderPlans();
toast('✅ 已生成表情包:同图 × ' + plans.length + ' 种动画');
} catch (err) {
toast('❌ 生成失败:' + err.message);
} finally {
genBtn.disabled = false;
genBtn.textContent = old;
}
}
/* ================= 渲染:方案库卡片 ================= */
function animLabel(anim) {
const t = HGB_Motion.animTypes().find(x => x.id === (anim && anim.type));
return t ? t.emoji + ' ' + t.name : '动画';
}
function renderPlans() {
const grid = document.getElementById('plans-grid');
const empty = document.getElementById('plans-empty');
const aiTag = state.plans.length && state.plans[0]._aiDesign ? ' · 🤖 AI 设计' : '';
document.getElementById('plans-title').textContent = `😀 表情包(共 ${state.plans.length} 张 · 同图 × 全动画)${aiTag}`;
empty.classList.add('hidden');
if (!state.plans.length) {
empty.classList.remove('hidden');
grid.innerHTML = '';
animNodes = [];
return;
}
grid.innerHTML = state.plans.map(p => {
const markSvg = HGB_Exporter.buildSvgFrom(HGB_EmojiGen.markElements(p), p.bg, null, { trim: true });
const active = state.activePlan && state.activePlan.id === p.id;
return `
`;
}).join('');
animNodes = [];
grid.querySelectorAll('.plan-card').forEach(card => {
const plan = state.plans.find(p => p.id === card.dataset.plan);
if (!plan) return;
const svgEl = card.querySelector('.plan-preview-emoji svg');
if (svgEl) registerAnim(svgEl, plan);
card.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', async () => {
const act = btn.dataset.pact;
if (act === 'preview') {
openPreview(plan);
} else if (act === 'png') {
state.activePlan = plan;
renderPlans();
try {
const canvas = await HGB_EmojiGen.exportPng(plan, 512, plan.bg);
canvas.toBlob(b => { if (b) download(b, safeName(plan) + '_512px.png'); });
toast('✅ 已导出静态 PNG(512px)');
} catch (err) { toast('❌ 导出失败:' + err.message); }
}
});
});
});
startLoop();
}
/* ================= 预览弹窗 ================= */
function openPreview(plan) {
state.activePlan = plan;
renderPlans();
renderPreview();
document.getElementById('plan-modal').classList.remove('hidden');
}
function closePreview() {
document.getElementById('plan-modal').classList.add('hidden');
}
function renderPreview() {
const p = state.activePlan;
if (!p) return;
const wrap = document.getElementById('plan-mark');
wrap.innerHTML = '';
const markSvg = HGB_Exporter.buildSvgFrom(HGB_EmojiGen.markElements(p), p.bg, null, { trim: true });
wrap.innerHTML = svgInline(markSvg);
const svgEl = wrap.querySelector('svg');
if (svgEl) {
animNodes = animNodes.filter(an => an.svg !== svgEl && an.svg.isConnected);
registerAnim(svgEl, p);
startLoop();
}
document.getElementById('plan-modal-sub').textContent = `${p.layoutName} · ${p.paletteName} · ${animLabel(p.anim)}`;
document.getElementById('plan-concept').innerHTML = p.concept
? `💡 设计理念:${esc(p.concept)}
` : '';
// 动画类型切换(预览内)
const aw = document.getElementById('plan-anim');
aw.innerHTML = HGB_Motion.animTypes().map(t => `
`).join('');
aw.querySelectorAll('.industry-tab').forEach(btn => {
btn.addEventListener('click', () => {
p.anim = HGB_Motion.normalize({ type: btn.dataset.panim, speed: p.anim.speed, amplitude: p.anim.amplitude });
renderPreview();
renderPlans();
});
});
// 技能评审提示(表情专属技能)
renderStickerAudit(p);
}
function renderStickerAudit(p) {
const el = document.getElementById('plan-audit');
// 通用入参:表情技能按需取字段(追加 motion 类技能即自动生效)
const els = HGB_EmojiGen.markElements(p).filter(e => e.visible && e.type === 'icon');
const common = {
duration: p.anim.duration, fps: p.anim.fps, type: p.anim.type,
amplitude: p.anim.amplitude, speed: p.anim.speed,
layered: p.anim.layered, layerSpread: p.anim.layerSpread,
elementCount: els.length, size: 240, platform: 'wechat', styleId: p.styleId,
mood: state.mood, keyword: MOOD_KEYWORD[state.mood] || '' // 情绪评审(emotion-fit)生效
};
const items = HGB_StickerSkills.all()
.filter(s => s.category === 'motion') // 评审类技能全跑
.map(s => {
let r;
try { r = HGB_StickerSkills.run(s.id, common); } catch (e) { r = { error: e.message }; }
return { name: s.name, ok: !r.error && r.ok !== false, detail: r.error ? r.error : (r.tip || '') };
});
el.innerHTML = `
🔬 动画技能检查 (表情专属 · ${items.filter(i => i.ok).length}/${items.length} 项通过)
${items.map(it => `
${it.ok ? '✓' : '⚠'}
${it.name}
${esc(it.detail)}
`).join('')}
`;
}
/* ================= 变体微调 ================= */
function reVariant(opts) {
const p = state.activePlan;
if (!p) return;
const opt = {
...(state.genParam || {}),
layoutId: p.layoutId,
styleId: p.styleId,
anim: p.anim,
...opts
};
const v = HGB_EmojiGen.generateVariant(opt);
// 整组以新底图重新展开,保持「同图 × 全动画」一致性
state.plans = HGB_EmojiGen.expandAnimations(v);
state.activePlan = state.plans[0] || null;
renderPlans();
renderPreview();
}
function variantColor() {
// 换配色:轮换到下一套风格(配色/背景跟随风格)
const styles = HGB_Styles.slice();
const cur = styles.findIndex(s => s.id === state.activePlan.styleId);
const next = styles[(cur + 1) % styles.length];
reVariant({ styleId: next.id });
toast('🎨 已换底图配色/风格');
}
function variantLayout() {
const layouts = HGB_EmojiGen.EMOJI_LAYOUTS;
const cur = layouts.indexOf(state.activePlan.layoutId);
const next = layouts[(cur + 1) % layouts.length];
reVariant({ layoutId: next });
toast('🅰 已换底图布局');
}
/* ================= 换一张底图 ================= */
function regenerate() {
if (!state.activePlan) { generate(); return; }
// 随机组合风格 + 布局(保留用户图标),保证换出的底图与当前不同
const styles = HGB_Styles.slice();
const style = styles[Math.floor(Math.random() * styles.length)];
const layouts = HGB_EmojiGen.EMOJI_LAYOUTS;
const layout = layouts[Math.floor(Math.random() * layouts.length)];
reVariant({ styleId: style.id, layoutId: layout });
toast('🎲 已换一张底图(' + style.name + ' · ' + layout + ')');
}
/* ================= 导出 ================= */
function exportOpts() {
const size = Number(document.querySelector('input[name="em-size"]:checked').value);
const fps = Number(document.getElementById('em-fps').value);
const bg = document.querySelector('input[name="em-bg"]:checked').value;
const a = HGB_Motion.normalize({ ...state.activePlan.anim, fps });
return { size, fps, bg, anim: a };
}
async function exportGif() {
const p = state.activePlan;
if (!p) return;
const { size, bg, anim } = exportOpts();
toast('⏳ 正在生成 GIF(' + HGB_Motion.frameCount(anim) + ' 帧)…');
try {
const blob = await HGB_EmojiGen.exportGif(p, size, anim, { bg });
download(blob, safeName(p) + '_' + anim.type + '_' + size + 'px.gif');
toast('✅ GIF 已导出(' + (blob.size / 1024).toFixed(0) + 'KB)');
} catch (err) { toast('❌ GIF 导出失败:' + err.message); }
}
async function exportWebm() {
const p = state.activePlan;
if (!p) return;
const { size, bg, anim } = exportOpts();
toast('⏳ 正在录制 WebM…');
try {
const blob = await HGB_EmojiGen.exportWebm(p, size, anim, { bg });
download(blob, safeName(p) + '_' + anim.type + '_' + size + 'px.webm');
toast('✅ WebM 已导出');
} catch (err) { toast('❌ WebM 导出失败:' + err.message); }
}
async function exportPng() {
const p = state.activePlan;
if (!p) return;
const { size, bg } = exportOpts();
try {
const canvas = await HGB_EmojiGen.exportPng(p, size, bg);
canvas.toBlob(b => { if (b) download(b, safeName(p) + '_' + size + 'px.png'); });
toast('✅ 静态 PNG 已导出(' + size + 'px)');
} catch (err) { toast('❌ PNG 导出失败:' + err.message); }
}
/* ================= 内置库选择(简化版) ================= */
let builtinList = [], builtinOffset = 0, builtinQuery = '';
async function openBuiltin() {
builtinQuery = '';
document.getElementById('builtin-search').value = '';
document.getElementById('builtin-modal').classList.remove('hidden');
// 预载表情主力单色源,保证搜索可用
const core = (typeof HGB_AvatarGen !== 'undefined' && HGB_AvatarGen.AVATAR_CORE_SOURCES) ||
['lucide', 'tabler', 'feather', 'mdi', 'fluent', 'solar'];
await HGB_IconLib.preload(core);
await searchBuiltin();
}
async function searchBuiltin(append) {
const kw = builtinQuery.toLowerCase();
let hits;
if (kw) {
hits = HGB_IconLib.searchLoaded(kw, 60);
if (hits.length < 30) {
const idx = HGB_IconLib.searchIndex(kw, 60);
for (const it of idx) {
if (!HGB_IconLib.loaded(it.source)) { await HGB_IconLib.loadSource(it.source).catch(() => null); }
}
hits = HGB_IconLib.searchLoaded(kw, 60);
}
} else {
// 无关键词:遍历已加载源前若干
hits = [];
for (const sid of HGB_IconLib.loadedIds()) {
for (const ic of HGB_IconLib.getSource(sid).icons || []) {
hits.push({ id: sid + ':' + ic.name, name: ic.name, svg: ic.svg, source: sid, type: ic.type });
if (hits.length >= 200) break;
}
if (hits.length >= 200) break;
}
}
builtinList = hits;
builtinOffset = append ? builtinOffset : 0;
renderBuiltinGrid();
}
function renderBuiltinGrid() {
const grid = document.getElementById('builtin-grid');
const page = builtinList.slice(builtinOffset, builtinOffset + 30);
const selIds = new Set(state.icons.map(i => i.id));
if (builtinOffset === 0) grid.innerHTML = '';
grid.insertAdjacentHTML('beforeend', page.map(ic => `
${svgInline(``)}
${esc(ic.name)}
`).join(''));
builtinOffset += page.length;
document.getElementById('builtin-count').textContent = `已选 ${state.icons.length} / 最多 5`;
document.getElementById('builtin-load-more').classList.toggle('hidden', builtinOffset >= builtinList.length);
grid.querySelectorAll('.gh-item').forEach(item => {
item.addEventListener('click', () => {
const ic = builtinList.find(x => x.id === item.dataset.icid);
if (!ic) return;
if (state.icons.some(i => i.id === ic.id)) {
state.icons = state.icons.filter(i => i.id !== ic.id);
} else {
if (state.icons.length >= 5) { toast('⚠ 最多选择 5 个图标'); return; }
state.icons.push({ id: ic.id, name: ic.name, svg: ic.svg, source: ic.source });
}
renderBuiltinGrid();
renderIcons();
});
});
}
/* ================= 事件绑定 ================= */
function bindAll() {
document.getElementById('btn-generate').addEventListener('click', generate);
const regen = document.getElementById('btn-regenerate');
if (regen) regen.addEventListener('click', regenerate);
document.getElementById('btn-builtin-icons').addEventListener('click', openBuiltin);
document.getElementById('builtin-search').addEventListener('input', (e) => {
builtinQuery = e.target.value.trim();
searchBuiltin();
});
document.getElementById('builtin-load-more').addEventListener('click', () => searchBuiltin(true));
document.querySelectorAll('#builtin-modal [data-builtin-close]').forEach(b => {
b.addEventListener('click', () => document.getElementById('builtin-modal').classList.add('hidden'));
});
document.getElementById('builtin-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) e.currentTarget.classList.add('hidden');
});
// 预览弹窗
document.querySelectorAll('#plan-modal [data-plan-close]').forEach(b => {
b.addEventListener('click', closePreview);
});
document.getElementById('plan-modal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closePreview();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !document.getElementById('plan-modal').classList.contains('hidden')) closePreview();
});
document.getElementById('btn-var-color').addEventListener('click', variantColor);
document.getElementById('btn-var-layout').addEventListener('click', variantLayout);
document.getElementById('btn-export-gif').addEventListener('click', exportGif);
document.getElementById('btn-export-webm').addEventListener('click', exportWebm);
document.getElementById('btn-export-png').addEventListener('click', exportPng);
// 项目名持久化
const nameInput = document.getElementById('project-name');
if (nameInput) {
nameInput.addEventListener('change', () => {
HGB_Store.get().name = nameInput.value || '我的品牌';
HGB_Store.persistToLocal();
});
}
}
/* ================= 启动 ================= */
function init() {
state.industry = HGB_Industries.current();
bindAll();
renderIndustries();
renderMood();
renderIcons();
renderPlans();
startLoop();
}
function boot() {
HGB_Store.init(true);
init();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
return { init };
})();