467 lines
13 KiB
JavaScript
467 lines
13 KiB
JavaScript
/**
|
|
* 画个标 · 核心:状态管理(Store)
|
|
* 职责:维护设计数据、元素 CRUD、撤销/重做、事件订阅、版本快照
|
|
*/
|
|
const HGB_Store = (function () {
|
|
'use strict';
|
|
|
|
const CANVAS_W = 600;
|
|
const CANVAS_H = 600;
|
|
const MAX_HISTORY = 60;
|
|
const LS_KEY = 'hgb_design';
|
|
const LS_VERSION_KEY = 'hgb_versions';
|
|
|
|
/* ---------- 默认项目 ---------- */
|
|
function defaultProject() {
|
|
return {
|
|
name: '我的品牌',
|
|
bg: '#FFFFFF',
|
|
elements: [
|
|
{
|
|
id: uid(),
|
|
type: 'icon',
|
|
name: '学士帽',
|
|
iconId: 'graduation-cap',
|
|
svg: iconSvg('graduation-cap'),
|
|
x: 210, y: 150, w: 180, h: 180,
|
|
rotation: 0,
|
|
fill: 'none',
|
|
stroke: '#1B4F72',
|
|
strokeWidth: 2,
|
|
opacity: 1,
|
|
visible: true, locked: false
|
|
},
|
|
{
|
|
id: uid(),
|
|
type: 'text',
|
|
name: '品牌中文名',
|
|
text: '远渡教育',
|
|
x: 130, y: 360, w: 340, h: 64,
|
|
rotation: 0,
|
|
fontSize: 48,
|
|
fontWeight: 700,
|
|
fontFamily: 'SimHei',
|
|
letterSpacing: 8,
|
|
fill: '#1B4F72',
|
|
stroke: 'none',
|
|
strokeWidth: 0,
|
|
opacity: 1,
|
|
visible: true, locked: false
|
|
},
|
|
{
|
|
id: uid(),
|
|
type: 'text',
|
|
name: '品牌英文名',
|
|
text: 'FAR CROSS EDUCATION',
|
|
x: 130, y: 430, w: 340, h: 32,
|
|
rotation: 0,
|
|
fontSize: 18,
|
|
fontWeight: 400,
|
|
fontFamily: 'Georgia',
|
|
letterSpacing: 4,
|
|
fill: '#2980B9',
|
|
stroke: 'none',
|
|
strokeWidth: 0,
|
|
opacity: 1,
|
|
visible: true, locked: false
|
|
}
|
|
],
|
|
selectedId: null,
|
|
selectedIds: [],
|
|
currentStyle: null
|
|
};
|
|
}
|
|
|
|
/* ---------- 工具 ---------- */
|
|
function uid() {
|
|
return 'el_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
|
}
|
|
|
|
function iconSvg(iconId) {
|
|
// 行业模块图标 + 基础图形
|
|
for (const ind of HGB_Industries) {
|
|
const ic = (ind.icons || []).find(i => i.id === iconId);
|
|
if (ic) return ic.svg;
|
|
}
|
|
const ic = HGB_Icons.shapes.find(i => i.id === iconId);
|
|
return ic ? ic.svg : '';
|
|
}
|
|
|
|
/* ---------- 状态 ---------- */
|
|
let state = defaultProject();
|
|
let history = [];
|
|
let historyIdx = -1;
|
|
let listeners = {};
|
|
|
|
/* ---------- 历史记录 ---------- */
|
|
function snapshot() {
|
|
return JSON.parse(JSON.stringify(state));
|
|
}
|
|
|
|
function pushHistory() {
|
|
// 移除 redo 分支
|
|
history = history.slice(0, historyIdx + 1);
|
|
history.push(snapshot());
|
|
if (history.length > MAX_HISTORY) history.shift();
|
|
historyIdx = history.length - 1;
|
|
emit('history');
|
|
}
|
|
|
|
function undo() {
|
|
if (historyIdx <= 0) return false;
|
|
historyIdx--;
|
|
state = JSON.parse(JSON.stringify(history[historyIdx]));
|
|
emit('change');
|
|
return true;
|
|
}
|
|
|
|
function redo() {
|
|
if (historyIdx >= history.length - 1) return false;
|
|
historyIdx++;
|
|
state = JSON.parse(JSON.stringify(history[historyIdx]));
|
|
emit('change');
|
|
return true;
|
|
}
|
|
|
|
function canUndo() { return historyIdx > 0; }
|
|
function canRedo() { return historyIdx < history.length - 1; }
|
|
|
|
/* ---------- 元素操作 ---------- */
|
|
function addElement(el) {
|
|
state.elements.push(el);
|
|
state.selectedId = el.id;
|
|
pushHistory();
|
|
emit('change');
|
|
return el;
|
|
}
|
|
|
|
function updateElement(id, patch, record = true) {
|
|
const el = state.elements.find(e => e.id === id);
|
|
if (!el) return;
|
|
Object.assign(el, patch);
|
|
if (record) pushHistory();
|
|
emit('change');
|
|
}
|
|
|
|
function removeElement(id) {
|
|
state.elements = state.elements.filter(e => e.id !== id);
|
|
if (state.selectedId === id) state.selectedId = null;
|
|
pushHistory();
|
|
emit('change');
|
|
}
|
|
|
|
/** 载入智能方案(替换全部元素与背景) */
|
|
function loadPlan(plan) {
|
|
state.elements = (plan.elements || []).map(e => ({ ...e }));
|
|
state.bg = plan.bg || '#FFFFFF';
|
|
state.selectedId = null;
|
|
pushHistory();
|
|
emit('change');
|
|
}
|
|
|
|
function duplicateElement(id) {
|
|
const src = state.elements.find(e => e.id === id);
|
|
if (!src) return null;
|
|
const copy = JSON.parse(JSON.stringify(src));
|
|
copy.id = uid();
|
|
copy.name = src.name + ' 副本';
|
|
copy.x += 24; copy.y += 24;
|
|
// 副本放置到顶层
|
|
state.elements.push(copy);
|
|
state.selectedId = copy.id;
|
|
pushHistory();
|
|
emit('change');
|
|
return copy;
|
|
}
|
|
|
|
function select(id, additive) {
|
|
if (additive && id != null) {
|
|
// Shift 多选:切换选中
|
|
if (state.selectedIds.includes(id)) {
|
|
state.selectedIds = state.selectedIds.filter(x => x !== id);
|
|
state.selectedId = state.selectedIds.length ? state.selectedIds[state.selectedIds.length - 1] : null;
|
|
} else {
|
|
state.selectedIds.push(id);
|
|
state.selectedId = id;
|
|
}
|
|
} else {
|
|
state.selectedIds = id != null ? [id] : [];
|
|
state.selectedId = id;
|
|
}
|
|
emit('select');
|
|
emit('change');
|
|
}
|
|
|
|
/** 当前所有选中元素 */
|
|
function selectedElements() {
|
|
return state.selectedIds.map(id => state.elements.find(e => e.id === id)).filter(Boolean);
|
|
}
|
|
|
|
/** 移动全部选中元素 */
|
|
function moveSelection(dx, dy) {
|
|
for (const el of selectedElements()) {
|
|
el.x = Math.round(el.x + dx);
|
|
el.y = Math.round(el.y + dy);
|
|
}
|
|
emit('change');
|
|
}
|
|
|
|
/** 对齐全部选中元素 */
|
|
function alignSelection(mode) {
|
|
const els = selectedElements();
|
|
if (els.length < 2) return;
|
|
const xs = els.map(e => e.x), xes = els.map(e => e.x + e.w);
|
|
const ys = els.map(e => e.y), yes = els.map(e => e.y + e.h);
|
|
const minX = Math.min(...xs), maxX = Math.max(...xes);
|
|
const minY = Math.min(...ys), maxY = Math.max(...yes);
|
|
const boxCx = (minX + maxX) / 2, boxCy = (minY + maxY) / 2;
|
|
for (const el of els) {
|
|
if (mode === 'left') el.x = minX;
|
|
else if (mode === 'center') el.x = Math.round(boxCx - el.w / 2);
|
|
else if (mode === 'right') el.x = Math.round(maxX - el.w);
|
|
else if (mode === 'top') el.y = minY;
|
|
else if (mode === 'middle') el.y = Math.round(boxCy - el.h / 2);
|
|
else if (mode === 'bottom') el.y = Math.round(maxY - el.h);
|
|
}
|
|
pushHistory(); emit('change');
|
|
}
|
|
|
|
/** 复制全部选中元素 */
|
|
function duplicateSelection() {
|
|
const srcs = selectedElements();
|
|
if (!srcs.length) return 0;
|
|
for (const src of srcs) {
|
|
const copy = JSON.parse(JSON.stringify(src));
|
|
copy.id = uid();
|
|
copy.name = src.name + ' 副本';
|
|
copy.x += 24; copy.y += 24;
|
|
state.elements.push(copy);
|
|
state.selectedIds.push(copy.id);
|
|
state.selectedId = copy.id;
|
|
}
|
|
pushHistory(); emit('change');
|
|
return srcs.length;
|
|
}
|
|
|
|
/** 粘贴剪贴板元素(生成新副本并选中) */
|
|
function pasteElements(list) {
|
|
if (!list || !list.length) return [];
|
|
const newEls = list.map(src => {
|
|
const copy = JSON.parse(JSON.stringify(src));
|
|
copy.id = uid();
|
|
copy.name = src.name + ' 副本';
|
|
copy.x += 24; copy.y += 24;
|
|
state.elements.push(copy);
|
|
state.selectedIds.push(copy.id);
|
|
state.selectedId = copy.id;
|
|
return copy;
|
|
});
|
|
pushHistory(); emit('change');
|
|
return newEls;
|
|
}
|
|
|
|
/** 删除全部选中元素 */
|
|
function removeSelection() {
|
|
if (!state.selectedIds.length) return 0;
|
|
const ids = state.selectedIds.slice();
|
|
state.elements = state.elements.filter(e => !ids.includes(e.id));
|
|
state.selectedIds = [];
|
|
state.selectedId = null;
|
|
pushHistory(); emit('change');
|
|
return ids.length;
|
|
}
|
|
|
|
/* ---------- 图层排序 ---------- */
|
|
// elements 数组顺序即显示顺序(后画在上层)
|
|
function layerUp(id) {
|
|
const i = state.elements.findIndex(e => e.id === id);
|
|
if (i < 0 || i >= state.elements.length - 1) return;
|
|
[state.elements[i], state.elements[i + 1]] = [state.elements[i + 1], state.elements[i]];
|
|
pushHistory(); emit('change');
|
|
}
|
|
function layerDown(id) {
|
|
const i = state.elements.findIndex(e => e.id === id);
|
|
if (i <= 0) return;
|
|
[state.elements[i], state.elements[i - 1]] = [state.elements[i - 1], state.elements[i]];
|
|
pushHistory(); emit('change');
|
|
}
|
|
function layerFront(id) {
|
|
const el = state.elements.find(e => e.id === id);
|
|
if (!el) return;
|
|
state.elements = state.elements.filter(e => e.id !== id);
|
|
state.elements.push(el);
|
|
pushHistory(); emit('change');
|
|
}
|
|
function layerBack(id) {
|
|
const el = state.elements.find(e => e.id === id);
|
|
if (!el) return;
|
|
state.elements = state.elements.filter(e => e.id !== id);
|
|
state.elements.unshift(el);
|
|
pushHistory(); emit('change');
|
|
}
|
|
|
|
/** 拖拽图层:将 fromIndex 元素移动到 toIndex 位置(占据目标位置,其余元素顺移) */
|
|
function moveLayer(fromIndex, toIndex) {
|
|
const len = state.elements.length;
|
|
if (fromIndex < 0 || fromIndex >= len || toIndex < 0 || toIndex >= len) return;
|
|
if (fromIndex === toIndex) return;
|
|
const [el] = state.elements.splice(fromIndex, 1);
|
|
state.elements.splice(toIndex, 0, el);
|
|
pushHistory(); emit('change');
|
|
}
|
|
|
|
/* ---------- 风格应用 ---------- */
|
|
function applyStyle(style) {
|
|
state.currentStyle = style.id;
|
|
state.bg = style.bg;
|
|
for (const el of state.elements) {
|
|
if (el.type === 'icon') {
|
|
el.stroke = style.iconStroke;
|
|
el.strokeWidth = style.strokeWidth;
|
|
} else if (el.type === 'text') {
|
|
el.fill = style.colors.primary;
|
|
if (el.fontFamily === 'SimHei' || el.fontFamily === 'Microsoft YaHei' || el.fontFamily === '黑体') {
|
|
el.fontFamily = style.fontCn;
|
|
}
|
|
}
|
|
}
|
|
pushHistory();
|
|
emit('change');
|
|
emit('style');
|
|
}
|
|
|
|
/* ---------- 配色应用 ---------- */
|
|
function applyPalette(pal) {
|
|
let idx = 0;
|
|
for (const el of state.elements) {
|
|
if (el.type === 'icon') {
|
|
el.stroke = pal.colors[Math.min(idx, 1)];
|
|
idx++;
|
|
} else if (el.type === 'text') {
|
|
el.fill = pal.colors[0];
|
|
}
|
|
}
|
|
pushHistory();
|
|
emit('change');
|
|
emit('style');
|
|
}
|
|
|
|
/* ---------- 字体应用 ---------- */
|
|
function applyFont(family, web) {
|
|
const el = selected();
|
|
if (!el || el.type !== 'text') return;
|
|
if (web) HGB_Fonts.loadWebFont(family);
|
|
updateElement(el.id, { fontFamily: family });
|
|
}
|
|
|
|
/* ---------- 版本快照 ---------- */
|
|
function saveVersion() {
|
|
const versions = loadVersions();
|
|
const v = {
|
|
id: uid(),
|
|
time: Date.now(),
|
|
name: state.name || '未命名设计',
|
|
snapshot: snapshot()
|
|
};
|
|
versions.unshift(v);
|
|
saveVersions(versions);
|
|
emit('versions');
|
|
return v;
|
|
}
|
|
|
|
function loadVersions() {
|
|
try {
|
|
return JSON.parse(localStorage.getItem(LS_VERSION_KEY)) || [];
|
|
} catch (e) { return []; }
|
|
}
|
|
|
|
function saveVersions(list) {
|
|
localStorage.setItem(LS_VERSION_KEY, JSON.stringify(list.slice(0, 50)));
|
|
}
|
|
|
|
function restoreVersion(id) {
|
|
const v = loadVersions().find(x => x.id === id);
|
|
if (!v) return false;
|
|
state = JSON.parse(JSON.stringify(v.snapshot));
|
|
pushHistory();
|
|
emit('change');
|
|
return true;
|
|
}
|
|
|
|
function deleteVersion(id) {
|
|
saveVersions(loadVersions().filter(x => x.id !== id));
|
|
emit('versions');
|
|
}
|
|
|
|
function clearVersions() {
|
|
saveVersions([]);
|
|
emit('versions');
|
|
}
|
|
|
|
/* ---------- 事件订阅 ---------- */
|
|
function on(evt, fn) {
|
|
(listeners[evt] = listeners[evt] || []).push(fn);
|
|
}
|
|
|
|
function emit(evt) {
|
|
(listeners[evt] || []).forEach(fn => fn());
|
|
}
|
|
|
|
/* ---------- 选择器 ---------- */
|
|
function get() { return state; }
|
|
function selected() {
|
|
return state.elements.find(e => e.id === state.selectedId) || null;
|
|
}
|
|
function selectedIndex() {
|
|
return state.elements.findIndex(e => e.id === state.selectedId);
|
|
}
|
|
|
|
/* ---------- 本地持久化 ---------- */
|
|
function persistToLocal() {
|
|
try {
|
|
const data = { ...snapshot(), savedAt: Date.now() };
|
|
localStorage.setItem(LS_KEY, JSON.stringify(data));
|
|
return true;
|
|
} catch (e) { return false; }
|
|
}
|
|
|
|
function loadFromLocal() {
|
|
try {
|
|
const raw = localStorage.getItem(LS_KEY);
|
|
if (!raw) return false;
|
|
const data = JSON.parse(raw);
|
|
if (data && Array.isArray(data.elements)) {
|
|
state = data;
|
|
// 迁移遗留演示默认名:历史版本默认项目名「我的留学教育品牌」会污染各行业生成时的品牌回退
|
|
if (state.name === '我的留学教育品牌') state.name = '我的品牌';
|
|
history = [snapshot()];
|
|
historyIdx = 0;
|
|
return true;
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
return false;
|
|
}
|
|
|
|
/* ---------- 初始化 ---------- */
|
|
function init(restoreLocal = true) {
|
|
if (restoreLocal && loadFromLocal()) return;
|
|
state = defaultProject();
|
|
history = [snapshot()];
|
|
historyIdx = 0;
|
|
}
|
|
|
|
return {
|
|
CANVAS_W, CANVAS_H,
|
|
get, selected, selectedIndex,
|
|
selectedElements, moveSelection, alignSelection,
|
|
duplicateSelection, removeSelection, pasteElements,
|
|
addElement, updateElement, removeElement, duplicateElement, loadPlan, select,
|
|
layerUp, layerDown, layerFront, layerBack, moveLayer,
|
|
applyStyle, applyPalette, applyFont,
|
|
undo, redo, canUndo, canRedo, pushHistory,
|
|
saveVersion, loadVersions, restoreVersion, deleteVersion, clearVersions,
|
|
persistToLocal, loadFromLocal, init,
|
|
on, emit, uid, iconSvg
|
|
};
|
|
})();
|