feat: 初始化画个标仓库 - AI驱动设计系统(BYOK模式) - 224源/371504图标+25海报源+130技能

This commit is contained in:
sgp
2026-10-03 21:44:18 +08:00
commit 9e222877ea
497 changed files with 43611 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
/**
* 画个标 · UI:画布编辑器交互
* 职责:拖拽移动、缩放手柄、旋转手柄、双击编辑文字、快捷键
*/
const HGB_Editor = (function () {
'use strict';
let svg, stage;
let drag = null; // 当前拖拽状态
let clipboard = []; // 复制/粘贴剪贴板
const MIN_SIZE = 16;
/* ---------- 初始化 ---------- */
function init() {
svg = HGB_Renderer.svg;
stage = document.getElementById('canvas-stage');
// 画布空白处点击取消选中
svg.addEventListener('mousedown', onCanvasMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
// 双击编辑文字
svg.addEventListener('dblclick', onDblClick);
// 快捷键
document.addEventListener('keydown', onKeyDown);
// 阻止 SVG 内元素默认拖拽(避免浏览器拖拽行为)
svg.addEventListener('dragstart', (e) => e.preventDefault());
}
/* ---------- 鼠标事件 ---------- */
function onCanvasMouseDown(e) {
const target = e.target;
const t = target.tagName;
// 1) 手柄缩放/旋转
const handle = target.getAttribute && target.getAttribute('data-handle');
if (target.classList && target.classList.contains('hgb-handle')) {
e.preventDefault();
const el = HGB_Store.selected();
if (!el) return;
const start = HGB_Renderer.toSvgPoint(e.clientX, e.clientY);
// 旋转起始角:以元素中心为原点
const center = { x: el.x + el.w / 2, y: el.y + el.h / 2 };
const startAngle = Math.atan2(start.y - center.y, start.x - center.x) * 180 / Math.PI;
drag = {
type: 'resize',
handle,
elId: el.id,
startX: start.x, startY: start.y,
startAngle: startAngle - (el.rotation || 0),
orig: { ...el },
moved: false
};
return;
}
// 2) 元素命中
const el = HGB_Renderer.hitTest(e.clientX, e.clientY);
if (el) {
e.preventDefault();
// 已选中组内元素 → 保持当前多选状态,整体拖动;未选中 → 选中(Shift 追加)
if (!HGB_Store.get().selectedIds.includes(el.id)) {
HGB_Store.select(el.id, e.shiftKey);
}
const start = HGB_Renderer.toSvgPoint(e.clientX, e.clientY);
drag = {
type: 'move',
elId: el.id,
startX: start.x, startY: start.y,
orig: { ...el },
moved: false
};
return;
}
// 3) 空白处:取消选中
HGB_Store.select(null);
}
function onMouseMove(e) {
if (!drag) return;
const p = HGB_Renderer.toSvgPoint(e.clientX, e.clientY);
const dx = p.x - drag.startX;
const dy = p.y - drag.startY;
if (!drag.moved && Math.abs(dx) + Math.abs(dy) > 2) drag.moved = true;
const el = HGB_Store.get().elements.find(x => x.id === drag.elId);
if (!el) return;
if (drag.type === 'move') {
// 以主元素原始位置为基准计算位移,整体移动全部选中元素(moveSelection 内部 emit change → refreshAll)
const nX = Math.round(drag.orig.x + dx);
const nY = Math.round(drag.orig.y + dy);
const boundX = Math.max(-drag.orig.w * 0.5, Math.min(HGB_Store.CANVAS_W - drag.orig.w * 0.5, nX));
const boundY = Math.max(-drag.orig.h * 0.5, Math.min(HGB_Store.CANVAS_H - drag.orig.h * 0.5, nY));
HGB_Store.moveSelection(boundX - drag.orig.x, boundY - drag.orig.y);
} else if (drag.type === 'resize') {
if (drag.handle === 'rotate') {
rotateElement(el, drag, p);
} else {
resizeElement(el, drag, p, e);
}
}
}
function onMouseUp() {
if (!drag) return;
// 拖拽/缩放完成后记录历史
if (drag.moved) {
HGB_Store.pushHistory();
}
drag = null;
HGB_Renderer.render();
HGB_UI.refreshAll();
}
/* ---------- 缩放逻辑 ---------- */
function resizeElement(el, drag, p, ev) {
const o = drag.orig;
let x = o.x, y = o.y, w = o.w, h = o.h;
const isE = drag.handle.includes('e');
const isW = drag.handle.includes('w');
const isS = drag.handle.includes('s');
const isN = drag.handle.includes('n');
if (isE) w = Math.max(MIN_SIZE, o.w + (p.x - (o.x + o.w)));
if (isS) h = Math.max(MIN_SIZE, o.h + (p.y - (o.y + o.h)));
if (isW) {
const nw = Math.max(MIN_SIZE, o.w + (o.x - p.x));
x = o.x + o.w - nw; w = nw;
}
if (isN) {
const nh = Math.max(MIN_SIZE, o.h + (o.y - p.y));
y = o.y + o.h - nh; h = nh;
}
// 等比缩放(按住 Shift)
if (ev && ev.shiftKey) {
const ratio = o.w / o.h;
if (w / h > ratio) h = w / ratio; else w = h * ratio;
// 重新计算锚点
if (isW) x = o.x + o.w - w;
if (isN) y = o.y + o.h - h;
}
el.x = Math.round(x); el.y = Math.round(y);
el.w = Math.round(w); el.h = Math.round(h);
HGB_Renderer.render();
HGB_UI.refreshProps();
}
/* ---------- 旋转逻辑 ---------- */
function rotateElement(el, drag, p) {
const center = { x: drag.orig.x + drag.orig.w / 2, y: drag.orig.y + drag.orig.h / 2 };
const startAngle = drag.startAngle;
const angle = Math.atan2(p.y - center.y, p.x - center.x) * 180 / Math.PI;
el.rotation = Math.round((angle - startAngle) * 10) / 10;
HGB_Renderer.render();
HGB_UI.refreshProps();
}
/* ---------- 双击编辑文字 ---------- */
function onDblClick(e) {
const el = HGB_Renderer.hitTest(e.clientX, e.clientY);
if (!el || el.type !== 'text' || el.locked) return;
HGB_Store.select(el.id);
const val = prompt('编辑文字内容:', el.text || '');
if (val !== null) {
HGB_Store.updateElement(el.id, { text: val });
}
}
/* ---------- 快捷键 ---------- */
function onKeyDown(e) {
const tag = (document.activeElement && document.activeElement.tagName) || '';
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') {
// 输入框内不触发全局快捷键(Ctrl+Z 撤销文字除外)
if (e.ctrlKey || e.metaKey) {
if (e.key === 'z' || e.key === 'Z') { e.preventDefault(); }
}
return;
}
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && (e.key === 'z' || e.key === 'Z')) {
e.preventDefault();
e.shiftKey ? HGB_Store.redo() : HGB_Store.undo();
HGB_UI.refreshAll();
return;
}
if (ctrl && (e.key === 'y' || e.key === 'Y')) {
e.preventDefault();
HGB_Store.redo();
HGB_UI.refreshAll();
return;
}
if (ctrl && (e.key === 'd' || e.key === 'D')) {
e.preventDefault();
HGB_Store.duplicateSelection();
HGB_UI.refreshAll();
return;
}
if (ctrl && (e.key === 'c' || e.key === 'C')) {
e.preventDefault();
clipboard = HGB_Store.selectedElements().map(el => JSON.parse(JSON.stringify(el)));
HGB_UI.toast(`📋 已复制 ${clipboard.length} 个元素`);
return;
}
if (ctrl && (e.key === 'v' || e.key === 'V')) {
e.preventDefault();
if (clipboard.length) { HGB_Store.pasteElements(clipboard); HGB_UI.refreshAll(); }
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
HGB_Store.removeSelection();
HGB_UI.refreshAll();
return;
}
if (e.key === 'Escape') {
HGB_Store.select(null);
HGB_UI.refreshAll();
}
}
/* ---------- 添加到画布 ---------- */
function addText() {
const el = {
id: HGB_Store.uid(),
type: 'text',
name: '文字',
text: 'EDUCATION',
x: 200, y: 260, w: 200, h: 60,
rotation: 0,
fontSize: 36,
fontWeight: 700,
fontFamily: 'Arial',
letterSpacing: 2,
fill: '#1B4F72',
stroke: 'none',
strokeWidth: 0,
opacity: 1,
visible: true, locked: false
};
HGB_Store.addElement(el);
HGB_UI.refreshAll();
return el;
}
function addIcon(iconId, name) {
const el = {
id: HGB_Store.uid(),
type: 'icon',
name: name || iconId,
iconId: iconId,
svg: HGB_Store.iconSvg(iconId),
x: 220, y: 170, w: 160, h: 160,
rotation: 0,
fill: 'none',
stroke: '#1B4F72',
strokeWidth: 2,
opacity: 1,
visible: true, locked: false
};
HGB_Store.addElement(el);
HGB_UI.refreshAll();
return el;
}
/* ---------- 导出 ---------- */
function exportPngFlow() {
// 打开导出对话框
HGB_UI.openExportModal('png');
}
return { init, addText, addIcon, exportPngFlow };
})();
+542
View File
@@ -0,0 +1,542 @@
/**
* 画个标 · 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 };
})();
+1524
View File
File diff suppressed because it is too large Load Diff