Files
drawlogo/js/ui/emoji.js
T

543 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · 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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 => `
<button class="industry-tab ${ind.id === state.industry.id ? 'active' : ''}" data-industry="${ind.id}" title="${esc(ind.desc)}">
${ind.emoji}<span>${ind.name}</span>
</button>`).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 => `
<button class="industry-tab ${m.id === state.mood ? 'active' : ''}" data-mood="${m.id}"
title="影响动画动效:${m.emoji}${m.name}(如开心→弹跳、生气→抖动、惊讶→心跳、难过→漂浮、兴奋→弹转、平静→呼吸)">
${m.emoji}<span>${m.name}</span>
</button>`).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 = '<p class="pane-tip">未选择图标(AI 自动从行业与内置库选用)</p>';
return;
}
wrap.innerHTML = state.icons.map((ic, i) => `
<div class="design-icon" title="${esc(ic.name)}">
${svgInline(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">${ic.svg}</svg>`)}
<button class="icon-del" data-del="${i}">✕</button>
</div>`).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 `
<div class="plan-card ${active ? 'active' : ''} plan-card-emoji" data-plan="${p.id}">
<div class="plan-preview plan-preview-emoji" title="动态表情预览(自动播放)">${svgInline(markSvg)}</div>
<div class="plan-meta">
<div class="plan-name">${animLabel(p.anim)}</div>
${p.concept ? `<div class="plan-concept">${esc(p.name)} · ${esc(p.concept)}</div>` : ''}
<div class="plan-tags">
<span>🏷 ${esc(p.layoutName)}</span>
<span>🎨 ${esc(p.paletteName)}</span>
</div>
<div class="plan-actions">
<button class="tb-btn apply" data-pact="preview">👁 预览</button>
<button class="tb-btn" data-pact="png">导出 PNG</button>
</div>
</div>
</div>`;
}).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
? `<div class="plan-concept-inner">💡 设计理念:${esc(p.concept)}</div>` : '';
// 动画类型切换(预览内)
const aw = document.getElementById('plan-anim');
aw.innerHTML = HGB_Motion.animTypes().map(t => `
<button class="industry-tab ${p.anim.type === t.id ? 'active' : ''}" data-panim="${t.id}">${t.emoji}<span>${t.name}</span></button>`).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 = `
<div class="plan-audit-head">🔬 动画技能检查 <span class="plan-audit-sub">(表情专属 · ${items.filter(i => i.ok).length}/${items.length} 项通过)</span></div>
<div class="plan-audit-body">
<div class="plan-audit-items">
${items.map(it => `
<div class="plan-audit-item ${it.ok ? 'ok' : 'warn'}">
<span class="pa-mark">${it.ok ? '✓' : '⚠'}</span>
<span class="pa-name">${it.name}</span>
<span class="pa-detail">${esc(it.detail)}</span>
</div>`).join('')}
</div>
</div>`;
}
/* ================= 变体微调 ================= */
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 => `
<div class="gh-item ${selIds.has(ic.id) ? 'sel' : ''}" data-icid="${esc(ic.id)}" title="${esc(ic.name)}">
${svgInline(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="currentColor">${ic.svg}</svg>`)}
<span class="gh-item-name">${esc(ic.name)}</span>
</div>`).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 };
})();