285 lines
10 KiB
JavaScript
285 lines
10 KiB
JavaScript
/**
|
||
* 画个标 · 核心:SVG 渲染引擎
|
||
* 职责:将 store 中的元素渲染到画布 SVG,绘制选中框/手柄,支持缩放显示
|
||
*/
|
||
const HGB_Renderer = (function () {
|
||
'use strict';
|
||
|
||
let svg, defs, contentLayer, overlayLayer;
|
||
let zoom = 1; // 当前缩放
|
||
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||
const MIN_ZOOM = 0.2, MAX_ZOOM = 4;
|
||
|
||
/* ---------- 初始化 ---------- */
|
||
function init() {
|
||
svg = document.getElementById('logo-canvas');
|
||
// 清空并重建分层
|
||
svg.innerHTML = '';
|
||
defs = svgCreate('defs');
|
||
svg.appendChild(defs);
|
||
|
||
contentLayer = svgCreate('g');
|
||
contentLayer.setAttribute('class', 'hgb-content');
|
||
svg.appendChild(contentLayer);
|
||
|
||
overlayLayer = svgCreate('g');
|
||
overlayLayer.setAttribute('class', 'hgb-overlay');
|
||
overlayLayer.setAttribute('pointer-events', 'none');
|
||
svg.appendChild(overlayLayer);
|
||
|
||
// 背景
|
||
const bgRect = svgCreate('rect');
|
||
bgRect.setAttribute('x', 0); bgRect.setAttribute('y', 0);
|
||
bgRect.setAttribute('width', HGB_Store.CANVAS_W);
|
||
bgRect.setAttribute('height', HGB_Store.CANVAS_H);
|
||
bgRect.setAttribute('fill', HGB_Store.get().bg);
|
||
bgRect.setAttribute('data-hgb-bg', '1');
|
||
contentLayer.appendChild(bgRect);
|
||
}
|
||
|
||
function svgCreate(tag) {
|
||
return document.createElementNS(SVG_NS, tag);
|
||
}
|
||
|
||
/**
|
||
* 生成圆弧路径 d(环形文字用)
|
||
* arc = { cx, cy, r, start, sweep }:圆心 / 半径 / 起始角度(度,0=右) / 扫过角度(度,顺时针)
|
||
*/
|
||
function arcPathD(arc) {
|
||
const a1 = arc.start * Math.PI / 180;
|
||
const a2 = (arc.start + arc.sweep) * Math.PI / 180;
|
||
const x1 = arc.cx + arc.r * Math.cos(a1);
|
||
const y1 = arc.cy + arc.r * Math.sin(a1);
|
||
const x2 = arc.cx + arc.r * Math.cos(a2);
|
||
const y2 = arc.cy + arc.r * Math.sin(a2);
|
||
const large = Math.abs(arc.sweep) > 180 ? 1 : 0;
|
||
const sweepFlag = arc.sweep < 0 ? 0 : 1; // sweep-flag=0 逆时针(y向下坐标系中负角走下半弧)
|
||
return 'M ' + x1.toFixed(2) + ' ' + y1.toFixed(2) +
|
||
' A ' + arc.r + ' ' + arc.r + ' 0 ' + large + ' ' + sweepFlag + ' ' + x2.toFixed(2) + ' ' + y2.toFixed(2);
|
||
}
|
||
|
||
/* ---------- 主渲染 ---------- */
|
||
function render() {
|
||
const s = HGB_Store.get();
|
||
init(); // 简单起见全量重绘
|
||
|
||
// 背景色同步到 input
|
||
const bgInput = document.getElementById('canvas-bg');
|
||
if (bgInput && bgInput.value !== s.bg) bgInput.value = s.bg;
|
||
|
||
// 绘制元素(按数组顺序,后画的在上层)
|
||
for (const el of s.elements) {
|
||
if (!el.visible) continue;
|
||
contentLayer.appendChild(renderElement(el));
|
||
}
|
||
|
||
// 选中框与手柄(overlay 层)
|
||
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
|
||
for (const sid of selIds) {
|
||
const el = s.elements.find(e => e.id === sid);
|
||
if (!el || !el.visible) continue;
|
||
drawSelection(el, sid === s.selectedId);
|
||
}
|
||
}
|
||
|
||
/** 渲染单个元素为 SVG 节点 */
|
||
function renderElement(el) {
|
||
let node;
|
||
if (el.type === 'icon') {
|
||
node = svgCreate('g');
|
||
node.innerHTML = el.svg;
|
||
// 应用图标颜色
|
||
node.querySelectorAll('path,circle,rect,polygon,polyline,line,ellipse').forEach(p => {
|
||
if (el.fill && el.fill !== 'none') p.setAttribute('fill', el.fill);
|
||
else p.setAttribute('fill', 'none');
|
||
if (el.stroke && el.stroke !== 'none') {
|
||
p.setAttribute('stroke', el.stroke);
|
||
p.setAttribute('stroke-width', el.strokeWidth);
|
||
p.setAttribute('stroke-linecap', 'round');
|
||
p.setAttribute('stroke-linejoin', 'round');
|
||
} else {
|
||
p.setAttribute('stroke', 'none');
|
||
}
|
||
});
|
||
} else if (el.type === 'text') {
|
||
node = svgCreate('text');
|
||
node.setAttribute('text-anchor', 'middle');
|
||
node.setAttribute('dominant-baseline', 'central');
|
||
node.setAttribute('font-family', el.fontFamily || 'sans-serif');
|
||
node.setAttribute('font-size', el.fontSize || 40);
|
||
node.setAttribute('font-weight', el.fontWeight || 400);
|
||
if (el.letterSpacing) node.setAttribute('letter-spacing', el.letterSpacing);
|
||
node.setAttribute('fill', el.fill || '#000');
|
||
if (el.stroke && el.stroke !== 'none') {
|
||
node.setAttribute('stroke', el.stroke);
|
||
node.setAttribute('stroke-width', el.strokeWidth);
|
||
} else {
|
||
node.setAttribute('stroke', 'none');
|
||
}
|
||
if (el.arc) {
|
||
// 环形文字:沿圆弧路径排列(品牌名环绕 mark 的排版能力)
|
||
const pathId = 'arc_' + el.id;
|
||
const p = svgCreate('path');
|
||
p.setAttribute('id', pathId);
|
||
p.setAttribute('d', arcPathD(el.arc));
|
||
p.setAttribute('fill', 'none');
|
||
p.setAttribute('stroke', 'none');
|
||
defs.appendChild(p);
|
||
const tp = svgCreate('textPath');
|
||
tp.setAttribute('href', '#' + pathId);
|
||
// 居中锚定:text-anchor=middle 配合 startOffset=50%,文字中心落在弧线中点
|
||
tp.setAttribute('startOffset', '50%');
|
||
tp.textContent = el.text || '';
|
||
node.appendChild(tp);
|
||
node.removeAttribute('x');
|
||
node.removeAttribute('y');
|
||
} else {
|
||
node.setAttribute('x', el.x + el.w / 2);
|
||
node.setAttribute('y', el.y + el.h / 2);
|
||
node.textContent = el.text || '';
|
||
}
|
||
} else {
|
||
// 预留:其他类型(图片等)
|
||
node = svgCreate('g');
|
||
}
|
||
|
||
// 通用变换:位置 + 旋转
|
||
const cx = el.x + el.w / 2;
|
||
const cy = el.y + el.h / 2;
|
||
if (el.rotation) {
|
||
node.setAttribute('transform',
|
||
`translate(${cx} ${cy}) rotate(${el.rotation}) translate(${-cx} ${-cy})`);
|
||
}
|
||
node.setAttribute('opacity', el.opacity != null ? el.opacity : 1);
|
||
|
||
// 元素定位
|
||
node.setAttribute('data-id', el.id);
|
||
node.setAttribute('data-type', el.type);
|
||
if (el.type === 'icon') {
|
||
node.setAttribute('data-icon', el.iconId || '');
|
||
}
|
||
node.classList.add('hgb-elem');
|
||
const s = HGB_Store.get();
|
||
const selIds = s.selectedIds || (s.selectedId ? [s.selectedId] : []);
|
||
if (selIds.includes(el.id)) node.classList.add('selected');
|
||
if (el.locked) node.classList.add('locked');
|
||
|
||
// 命中测试基于数据层(store),无需 DOM 命中框
|
||
node._el = el;
|
||
return node;
|
||
}
|
||
|
||
/* ---------- 选中框与手柄 ---------- */
|
||
function drawSelection(el, withHandles) {
|
||
const box = svgCreate('rect');
|
||
box.setAttribute('class', 'hgb-selection' + (withHandles ? '' : ' multi'));
|
||
box.setAttribute('x', el.x); box.setAttribute('y', el.y);
|
||
box.setAttribute('width', el.w); box.setAttribute('height', el.h);
|
||
overlayLayer.appendChild(box);
|
||
|
||
if (!withHandles) return; // 多选元素仅显示虚线框
|
||
|
||
// 8 个缩放手柄 + 1 个旋转手柄
|
||
const handles = [
|
||
{ cx: el.x, cy: el.y, cursor: 'nwse-resize', action: 'nw' },
|
||
{ cx: el.x + el.w / 2, cy: el.y, cursor: 'ns-resize', action: 'n' },
|
||
{ cx: el.x + el.w, cy: el.y, cursor: 'nesw-resize', action: 'ne' },
|
||
{ cx: el.x + el.w, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'e' },
|
||
{ cx: el.x + el.w, cy: el.y + el.h, cursor: 'nwse-resize', action: 'se' },
|
||
{ cx: el.x + el.w / 2, cy: el.y + el.h, cursor: 'ns-resize', action: 's' },
|
||
{ cx: el.x, cy: el.y + el.h, cursor: 'nesw-resize', action: 'sw' },
|
||
{ cx: el.x, cy: el.y + el.h / 2, cursor: 'ew-resize', action: 'w' }
|
||
];
|
||
for (const h of handles) {
|
||
const node = svgCreate('rect');
|
||
node.setAttribute('class', 'hgb-handle');
|
||
node.setAttribute('x', h.cx - 5); node.setAttribute('y', h.cy - 5);
|
||
node.setAttribute('width', 10); node.setAttribute('height', 10);
|
||
node.setAttribute('rx', 2);
|
||
node.setAttribute('style', 'cursor:' + h.cursor);
|
||
node.setAttribute('data-handle', h.action);
|
||
node.setAttribute('pointer-events', 'all');
|
||
overlayLayer.appendChild(node);
|
||
}
|
||
|
||
// 旋转手柄
|
||
const rx = el.x + el.w / 2;
|
||
const ry = el.y - 28;
|
||
const line = svgCreate('line');
|
||
line.setAttribute('class', 'hgb-rotate-line');
|
||
line.setAttribute('x1', rx); line.setAttribute('y1', el.y);
|
||
line.setAttribute('x2', rx); line.setAttribute('y2', ry);
|
||
overlayLayer.appendChild(line);
|
||
|
||
const rot = svgCreate('circle');
|
||
rot.setAttribute('class', 'hgb-handle rotate');
|
||
rot.setAttribute('cx', rx); rot.setAttribute('cy', ry);
|
||
rot.setAttribute('r', 7);
|
||
rot.setAttribute('data-handle', 'rotate');
|
||
rot.setAttribute('pointer-events', 'all');
|
||
overlayLayer.appendChild(rot);
|
||
}
|
||
|
||
/* ---------- 缩放与显示 ---------- */
|
||
function setZoom(z) {
|
||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
|
||
applyZoom();
|
||
return zoom;
|
||
}
|
||
function getZoom() { return zoom; }
|
||
|
||
function zoomIn() { return setZoom(zoom * 1.2); }
|
||
function zoomOut() { return setZoom(zoom / 1.2); }
|
||
|
||
function fitToStage() {
|
||
const stage = document.getElementById('canvas-stage');
|
||
const size = Math.min(stage.clientWidth - 48, stage.clientHeight - 48);
|
||
zoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, size / HGB_Store.CANVAS_W));
|
||
applyZoom();
|
||
return zoom;
|
||
}
|
||
|
||
function applyZoom() {
|
||
svg.style.width = (HGB_Store.CANVAS_W * zoom) + 'px';
|
||
svg.style.height = (HGB_Store.CANVAS_H * zoom) + 'px';
|
||
const lv = document.getElementById('zoom-level');
|
||
if (lv) lv.textContent = Math.round(zoom * 100) + '%';
|
||
}
|
||
|
||
/* ---------- 坐标转换(client -> svg user units) ---------- */
|
||
function toSvgPoint(clientX, clientY) {
|
||
const pt = svg.createSVGPoint();
|
||
pt.x = clientX; pt.y = clientY;
|
||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||
}
|
||
|
||
/** 命中测试:返回最上层可交互元素 */
|
||
function hitTest(clientX, clientY) {
|
||
const s = HGB_Store.get();
|
||
const p = toSvgPoint(clientX, clientY);
|
||
// 从顶层往下找
|
||
for (let i = s.elements.length - 1; i >= 0; i--) {
|
||
const el = s.elements[i];
|
||
if (!el.visible || el.locked) continue;
|
||
if (p.x >= el.x && p.x <= el.x + el.w &&
|
||
p.y >= el.y && p.y <= el.y + el.h) {
|
||
return el;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ---------- 画布状态 ---------- */
|
||
function setCanvasBg(color) {
|
||
const bgRect = svg.querySelector('[data-hgb-bg]');
|
||
if (bgRect) bgRect.setAttribute('fill', color);
|
||
}
|
||
|
||
return {
|
||
init, render, renderElement,
|
||
setZoom, getZoom, zoomIn, zoomOut, fitToStage,
|
||
toSvgPoint, hitTest, setCanvasBg, svgCreate,
|
||
get svg() { return svg; }
|
||
};
|
||
})();
|