升级版,支持文字旋转,文字增加背景色 图片,二维码旋转
https://blog.youkuaiyun.com/weixin_44944193/article/details/155188958
效果截图
后台编辑效果

文字

图片

二维码

生成图片后效果

直接上代码(可以直接用的代码)
前端添加代码
ps:注意css路径问题
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>海报(添加)</title>
<link href="poster.css" rel="stylesheet"
type="text/css">
</head>
<body>
<div class="header">
<div class="toolbar">
<button class="btn btn-default" id="备用按钮" style="display:none;">备用</button>
</div>
</div>
<div class="container">
<div class="sidebar" id="sidebar">
<div class="sidebar-toggle" id="sidebar-toggle">≡</div>
<div class="component-list" id="component-list">
<div class="component-item" data-type="text">文本</div>
<div class="component-item" data-type="image">图片</div>
<div class="component-item" data-type="qrcode">二维码</div>
</div>
</div>
<div class="editor-area">
<div class="canvas-container" id="canvas-container">
<div class="canvas-drag-handle" id="canvas-drag-handle">☰</div>
<!-- 关键改动 2:画布默认宽高改为 422x750 -->
<canvas id="poster-canvas" width="422" height="750"></canvas>
<div class="element-overlay hidden" id="element-overlay"></div>
<div class="resize-handle resize-se canvas-resize-handle" id="canvas-resize-handle"></div>
</div>
<!-- 在这里添加新的输入框 HTML -->
<div class="json-name-input">
<label for="json-name">名称:</label>
<input type="text" id="json-name" autocomplete="off" placeholder="请输入海报名称">
</div>
<button class="btn btn-primary" id="save-json-btn">保存JSON</button>
</div>
<div class="properties-panel" id="properties-panel">
<div class="canvas-properties-wrapper">
<div class="property-group">
<div class="property-title">画布属性</div>
<div class="property-item">
<div class="property-label">宽度</div>
<!-- 关键改动 2:宽度输入框默认值改为 422 -->
<input type="number" class="property-input" id="canvas-width" value="422" min="100" max="2000">
</div>
<div class="property-item">
<div class="property-label">高度</div>
<!-- 关键改动 2:高度输入框默认值改为 750 -->
<input type="number" class="property-input" id="canvas-height" value="750" min="100" max="2000">
</div>
<div class="property-item">
<div class="property-label">背景颜色</div>
<div class="color-picker">
<div class="color-preview" id="canvas-bg-color" style="background-color: #ffffff;"></div>
<input type="color" class="property-input" id="canvas-bg-color-input" value="#ffffff">
</div>
</div>
</div>
<div class="property-group" id="element-properties" style="display: none;">
<div class="property-title">元素属性</div>
<div class="property-item">
<div class="property-label">X坐标</div>
<input type="number" class="property-input" id="element-x" value="0">
</div>
<div class="property-item">
<div class="property-label">Y坐标</div>
<input type="number" class="property-input" id="element-y" value="0">
</div>
<div class="property-item">
<div class="property-label">宽度</div>
<div class="property-slider">
<input type="range" id="element-width-slider" min="10" max="500" value="100">
<input type="number" id="element-width-value" class="slider-value" min="10" max="500"
value="100">
</div>
</div>
<div class="property-item">
<div class="property-label">高度</div>
<div class="property-slider">
<input type="range" id="element-height-slider" min="10" max="500" value="100">
<input type="number" id="element-height-value" class="slider-value" min="10" max="500"
value="100">
</div>
</div>
<div class="layer-control" id="layer-control" style="display: none;">
<div class="property-label">层级控制</div>
<div class="layer-info">
当前: <span id="current-layer">1</span> / 总: <span id="total-layers">1</span>
</div>
<div class="btn-group">
<button id="btn-bring-to-front" class="btn btn-default">置顶</button>
<button id="btn-move-up" class="btn btn-default">上移</button>
<button id="btn-move-down" class="btn btn-default">下移</button>
<button id="btn-send-to-back" class="btn btn-default">置底</button>
</div>
</div>
<div class="property-item" id="text-content-item" style="display: none;">
<div class="property-label">文本内容</div>
<input type="text" class="property-input" id="element-text" value="">
</div>
<div class="property-item" id="text-color-item" style="display: none;">
<div class="property-label">文本颜色</div>
<div class="color-picker">
<div class="color-preview" id="text-color" style="background-color: #000000;"></div>
<input type="color" class="property-input" id="text-color-input" value="#000000">
</div>
</div>
<div class="property-item" id="text-font-size-item" style="display: none;">
<div class="property-label">字体大小</div>
<div class="property-slider">
<input type="range" id="text-font-size-slider" min="1" max="100" value="16">
<input type="number" id="text-font-size-value" class="slider-value" min="1" max="100"
value="16">
</div>
</div>
<div class="property-item" id="text-align-item" style="display: none;">
<div class="property-label">对齐方式</div>
<select class="property-select" id="text-align">
<option value="left">靠左</option>
<option value="center">居中</option>
<option value="right">靠右</option>
</select>
</div>
<div class="property-item" id="text-overflow-item" style="display: none;">
<div class="property-label">超出处理</div>
<select class="property-select" id="text-overflow">
<option value="wrap">自动换行</option>
<option value="ellipsis">隐藏超出字符</option>
</select>
</div>
<div class="property-item" id="image-url-item" style="display: none;">
<div class="property-label">图片URL</div>
<input type="text" class="property-input" id="image-url" value="">
</div>
<div class="property-item" id="border-color-item" style="display: none;">
<div class="property-label">边框颜色</div>
<div class="color-picker">
<div class="color-preview" id="border-color"
style="background-color: rgba(255,255,255,0);"></div>
<input type="color" class="property-input" id="border-color-input" value="#ffffff">
</div>
</div>
<div class="property-item" id="container-bg-color-item" style="display: none;">
<div class="property-label">容器背景</div>
<div class="color-picker">
<div class="color-preview" id="container-bg-color"
style="background-color: rgba(255,255,255,0);"></div>
<input type="color" class="property-input" id="container-bg-color-input" value="#ffffff">
</div>
</div>
<div class="property-item" id="image-border-radius-item" style="display: none;">
<div class="property-label">圆角半径</div>
<div class="property-slider">
<input type="range" id="image-border-radius-slider" min="0" max="50" value="0">
<input type="number" id="image-border-radius-value" class="slider-value" min="0" max="50"
value="0">
</div>
</div>
<div class="property-item" id="qrcode-url-item" style="display: none;">
<div class="property-label">二维码内容</div>
<input type="text" class="property-input" id="qrcode-url" value="">
</div>
<div class="property-item" id="delete-element-item" style="display: none; margin-top: 15px;">
<button id="delete-element-btn">删除元素</button>
</div>
</div>
</div>
<div class="property-group">
<div class="property-title">JSON数据预览</div>
<div class="json-preview" id="json-preview"></div>
</div>
</div>
</div>
<div class="element-list-panel" id="element-list-panel">
<div class="panel-resize-handle" id="panel-resize-handle"></div>
<div class="panel-title">元素层级</div>
<div class="element-list" id="element-list"></div>
</div>
</body>
</html>
<!-- 先引入 jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 引入 layui JS -->
<script src="https://cdn.staticfile.org/layui/2.9.10/layui.js"></script>
<script>
/**
* 海报编辑器核心类
* 负责画布初始化、元素管理(添加/删除/修改)、拖拽缩放、属性面板同步、JSON 保存等功能
*/
class PosterEditor {
/**
* 构造函数:初始化编辑器实例
* @param {string} canvasId - 画布 DOM 元素的 ID
*/
constructor(canvasId) {
// 1. 获取画布和上下文
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d'); // 2D 绘图上下文
// 2. 核心数据存储
this.elements = []; // 存储所有海报元素(文本、图片、二维码)
this.selectedElement = null; // 当前选中的元素
// 3. 拖拽/缩放状态标识
this.isDragging = false; // 元素是否正在拖拽
this.dragOffset = {x: 0, y: 0}; // 拖拽时的鼠标偏移量(避免点击元素时跳动)
this.isResizing = false; // 元素是否正在缩放
this.resizeDirection = null; // 缩放方向(nw/ne/sw/se)
this.isCanvasResizing = false; // 画布是否正在缩放
// 4. 画布基础配置
this.canvasMinWidth = 100; // 画布最小宽度
this.canvasMinHeight = 100; // 画布最小高度
// 5. 元素计数器(用于生成默认名称,如 text_1、image_2)
this.globalCounter = 1;
// 6. DOM 元素引用(方便后续操作)
this.elementList = document.getElementById('element-list'); // 元素层级列表
this.draggingIndex = -1; // 拖拽元素在列表中的索引
this.sidebar = document.getElementById('sidebar'); // 左侧组件库侧边栏
this.sidebarToggle = document.getElementById('sidebar-toggle'); // 侧边栏折叠按钮
this.elementListPanel = document.getElementById('element-list-panel'); // 元素层级面板
this.panelTitle = this.elementListPanel.querySelector('.panel-title'); // 层级面板标题(拖拽用)
this.saveBtn = document.getElementById('save-json-btn'); // 保存 JSON 按钮
this.propertiesPanel = document.getElementById('properties-panel'); // 右侧属性面板
this.canvasContainer = document.getElementById('canvas-container'); // 画布容器
this.canvasDragHandle = document.getElementById('canvas-drag-handle'); // 画布拖拽手柄
// 7. 面板/画布拖拽配置
this.isDraggingPanel = false; // 元素层级面板是否正在拖拽
this.panelOffset = {x: 0, y: 0}; // 面板拖拽偏移量
this.isDraggingCanvas = false; // 画布是否正在拖拽
this.canvasOffset = {x: 0, y: 0}; // 画布拖拽偏移量
// 8. 不同类型元素的默认容器颜色(区分元素类型,提升视觉体验)
this.defaultContainerColors = {
text: '#e8f4f8', // 文本元素:浅蓝色
image: '#faf6ed', // 图片元素:浅橙色
qrcode: '#fdf2f8' // 二维码元素:浅粉色
};
// 9. 初始化编辑器(顺序不可乱)
this.initCanvas(); // 初始化画布尺寸
this.initEventListeners(); // 绑定所有事件(点击、拖拽、输入等)
this.initSidebarToggle(); // 初始化侧边栏折叠功能
this.initPanelDrag(); // 初始化元素层级面板拖拽
this.initCanvasDrag(); // 初始化画布拖拽
this.updateUI(); // 首次更新 UI(渲染画布、层级列表、属性面板)
}
/**
* 初始化画布尺寸(从输入框读取默认值,无值则用画布自身尺寸)
*/
initCanvas() {
const widthInput = document.getElementById('canvas-width');
const heightInput = document.getElementById('canvas-height');
// 优先使用输入框的值,无值则用画布默认尺寸
this.canvas.width = parseInt(widthInput.value) || this.canvas.width;
this.canvas.height = parseInt(heightInput.value) || this.canvas.height;
this.render(); // 初始化后渲染画布
}
/**
* 创建滑块和输入框的双向绑定(同步数值,如元素宽度、字体大小)
* @param {string} sliderId - 滑块 DOM 元素 ID
* @param {string} inputId - 输入框 DOM 元素 ID
* @param {function} callback - 数值变化后的回调函数(更新元素属性)
*/
createTwoWayBinding(sliderId, inputId, callback) {
const slider = document.getElementById(sliderId);
const input = document.getElementById(inputId);
// 同步滑块和输入框的值
const updateValue = (source, value) => {
if (source === 'slider') input.value = value; // 滑块动 → 输入框同步
if (source === 'input') slider.value = value; // 输入框动 → 滑块同步
callback && callback(parseInt(value)); // 数值变化后执行回调(更新元素)
};
// 绑定滑块输入事件
slider.addEventListener('input', (e) => updateValue('slider', e.target.value));
// 绑定输入框输入事件(处理边界值,如不小于最小值、不大于最大值)
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
if (slider.min) value = Math.max(parseInt(slider.min), value); // 不小于最小值
if (slider.max) value = Math.min(parseInt(slider.max), value); // 不大于最大值
e.target.value = value; // 修正输入框值
updateValue('input', value);
});
}
/**
* 初始化所有事件监听器(核心方法,覆盖所有交互逻辑)
*/
initEventListeners() {
// -------------------------- 画布属性事件 --------------------------
// 画布宽度输入框变化 → 更新画布宽度
document.getElementById('canvas-width').addEventListener('change', (e) => {
const newWidth = Math.max(this.canvasMinWidth, Math.min(parseInt(e.target.value) || this.canvasMinWidth, 2000));
this.canvas.width = newWidth;
e.target.value = newWidth; // 修正输入框值(避免超出范围)
this.render(); // 重新渲染画布
});
// 画布高度输入框变化 → 更新画布高度
document.getElementById('canvas-height').addEventListener('change', (e) => {
const newHeight = Math.max(this.canvasMinHeight, Math.min(parseInt(e.target.value) || this.canvasMinHeight, 2000));
this.canvas.height = newHeight;
e.target.value = newHeight; // 修正输入框值
this.render(); // 重新渲染画布
});
// 画布背景颜色选择器变化 → 更新画布背景
document.getElementById('canvas-bg-color-input').addEventListener('input', (e) => {
document.getElementById('canvas-bg-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
});
// -------------------------- 元素通用属性事件 --------------------------
// 元素宽度(滑块+输入框)双向绑定 → 更新元素宽度
this.createTwoWayBinding('element-width-slider', 'element-width-value', (value) => {
if (this.selectedElement) {
this.selectedElement.width = value;
this.updateElementOverlay(); // 更新元素选中框(覆盖层)
this.render(); // 重新渲染画布
}
});
// 元素高度(滑块+输入框)双向绑定 → 更新元素高度
this.createTwoWayBinding('element-height-slider', 'element-height-value', (value) => {
if (this.selectedElement) {
this.selectedElement.height = value;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// 元素X坐标输入框变化 → 更新元素X坐标
document.getElementById('element-x').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.x = parseInt(e.target.value) || 0;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// 元素Y坐标输入框变化 → 更新元素Y坐标
document.getElementById('element-y').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.y = parseInt(e.target.value) || 0;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// -------------------------- 文本元素专属事件 --------------------------
// 文本内容输入框变化 → 更新文本内容
document.getElementById('element-text').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.text = e.target.value;
this.updateUI(); // 更新 UI(渲染画布+层级列表)
}
});
// 文本颜色选择器变化 → 更新文本颜色
document.getElementById('text-color-input').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.color = e.target.value;
document.getElementById('text-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// 字体大小(滑块+输入框)双向绑定 → 更新字体大小
this.createTwoWayBinding('text-font-size-slider', 'text-font-size-value', (value) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.fontSize = value;
this.render(); // 重新渲染画布
}
});
// 文本对齐方式下拉框变化 → 更新文本对齐
document.getElementById('text-align').addEventListener('change', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.align = e.target.value;
this.render(); // 重新渲染画布
}
});
// 文本超出处理下拉框变化 → 更新超出逻辑(换行/省略)
document.getElementById('text-overflow').addEventListener('change', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.overflow = e.target.value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 图片元素专属事件 --------------------------
// 图片URL输入框变化 → 更新图片地址
document.getElementById('image-url').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.src = e.target.value;
this.updateUI(); // 更新 UI
}
});
// 图片边框颜色选择器变化 → 更新边框颜色
document.getElementById('border-color-input').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.borderColor = e.target.value;
document.getElementById('border-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// 图片圆角(滑块+输入框)双向绑定 → 更新圆角半径
this.createTwoWayBinding('image-border-radius-slider', 'image-border-radius-value', (value) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.borderRadius = value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 二维码元素专属事件 --------------------------
// 二维码内容输入框变化 → 更新二维码内容
document.getElementById('qrcode-url').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'qrcode') { // 仅二维码元素生效
this.selectedElement.text = e.target.value;
this.updateUI(); // 更新 UI
}
});
// -------------------------- 容器背景色(通用)事件 --------------------------
// 容器背景色选择器变化 → 更新元素容器背景
document.getElementById('container-bg-color-input').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.containerBackgroundColor = e.target.value;
document.getElementById('container-bg-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 画布/元素拖拽/缩放事件 --------------------------
// 画布点击事件 → 选中元素/取消选中
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
// 鼠标移动事件 → 拖拽元素/缩放元素/缩放画布
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
// 鼠标抬起事件 → 结束拖拽/缩放
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
// 鼠标离开页面事件 → 结束拖拽/缩放(避免异常状态)
document.addEventListener('mouseleave', this.handleMouseUp.bind(this));
// 画布缩放手柄点击事件 → 开始缩放画布
document.getElementById('canvas-resize-handle').addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡(避免触发画布点击)
this.isCanvasResizing = true;
document.getElementById('element-overlay').classList.add('hidden'); // 隐藏元素选中框
});
// -------------------------- 左侧组件库事件 --------------------------
// 组件库元素点击事件 → 添加对应类型的元素(文本/图片/二维码)
document.querySelectorAll('.component-item').forEach(item => {
item.addEventListener('click', () => this.addElement(item.getAttribute('data-type')));
});
// -------------------------- 保存/删除/层级事件 --------------------------
// 保存 JSON 按钮点击事件 → 调用保存方法
this.saveBtn.addEventListener('click', () => this.saveJSON());
// 元素置顶按钮 → 提升元素层级到最高
document.getElementById('btn-bring-to-front').addEventListener('click', () => this.bringToFront());
// 元素上移按钮 → 提升元素层级
document.getElementById('btn-move-up').addEventListener('click', () => this.moveUp());
// 元素下移按钮 → 降低元素层级
document.getElementById('btn-move-down').addEventListener('click', () => this.moveDown());
// 元素置底按钮 → 降低元素层级到最低
document.getElementById('btn-send-to-back').addEventListener('click', () => this.sendToBack());
// 删除元素按钮 → 删除当前选中元素
document.getElementById('delete-element-btn').addEventListener('click', () => {
if (this.selectedElement) {
this.deleteElement(this.selectedElement.id);
}
});
// 键盘 Delete/Backspace 键 → 删除当前选中元素(快捷键)
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedElement) {
this.deleteElement(this.selectedElement.id);
}
}
});
// -------------------------- 元素层级面板缩放事件 --------------------------
// 层级面板缩放手柄 → 调整面板宽度
const panelResizeHandle = document.getElementById('panel-resize-handle');
panelResizeHandle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
const startX = e.clientX;
const startWidth = this.elementListPanel.offsetWidth;
// 鼠标移动 → 计算新宽度并更新
const handleMouseMove = (e) => {
const diffX = e.clientX - startX;
let newWidth = startWidth + diffX;
const minWidth = 150; // 面板最小宽度
if (newWidth < minWidth) newWidth = minWidth;
this.elementListPanel.style.width = `${newWidth}px`;
};
// 鼠标抬起 → 移除事件监听
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
// 窗口 resize 事件 → 调整层级面板位置(避免超出窗口)
window.addEventListener('resize', () => {
this.adjustElementListPanelPosition();
});
}
/**
* 初始化侧边栏折叠功能
*/
initSidebarToggle() {
this.sidebarToggle.addEventListener('click', () => {
this.sidebar.classList.toggle('collapsed'); // 切换折叠类
// 切换按钮文本(折叠→箭头,展开→三条杠)
this.sidebarToggle.textContent = this.sidebar.classList.contains('collapsed') ? '→' : '≡';
});
}
/**
* 初始化元素层级面板拖拽功能
*/
initPanelDrag() {
// 面板标题点击 → 开始拖拽
this.panelTitle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isDraggingPanel = true;
const panelRect = this.elementListPanel.getBoundingClientRect(); // 获取面板位置信息
// 计算鼠标相对于面板左上角的偏移量
this.panelOffset.x = e.clientX - panelRect.left;
this.panelOffset.y = e.clientY - panelRect.top;
this.elementListPanel.classList.add('dragging'); // 添加拖拽样式(半透明+阴影)
document.body.style.cursor = 'move'; // 鼠标样式改为移动指针
});
// 鼠标移动 → 更新面板位置
document.addEventListener('mousemove', (e) => {
if (!this.isDraggingPanel) return; // 未拖拽则返回
let newLeft = e.clientX - this.panelOffset.x;
let newTop = e.clientY - this.panelOffset.y;
const panelWidth = this.elementListPanel.offsetWidth;
const panelHeight = this.elementListPanel.offsetHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// 限制面板不超出窗口边界
newLeft = Math.max(0, Math.min(newLeft, screenWidth - panelWidth));
newTop = Math.max(0, Math.min(newTop, screenHeight - panelHeight));
// 更新面板位置(取消 right 定位,改用 left+top)
this.elementListPanel.style.left = `${newLeft}px`;
this.elementListPanel.style.top = `${newTop}px`;
this.elementListPanel.style.right = 'auto';
});
// 鼠标抬起 → 结束拖拽
document.addEventListener('mouseup', () => {
if (this.isDraggingPanel) {
this.isDraggingPanel = false;
this.elementListPanel.classList.remove('dragging'); // 移除拖拽样式
document.body.style.cursor = ''; // 恢复默认鼠标样式
}
});
// 面板内部点击(非标题区域)→ 取消拖拽状态
this.elementListPanel.addEventListener('mousedown', (e) => {
if (e.target !== this.panelTitle && !this.panelTitle.contains(e.target)) {
this.isDraggingPanel = false;
}
});
}
/**
* 初始化画布拖拽功能(通过左上角手柄拖拽)
*/
initCanvasDrag() {
// 画布拖拽手柄点击 → 开始拖拽
this.canvasDragHandle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isDraggingCanvas = true;
const containerRect = this.canvasContainer.getBoundingClientRect(); // 获取容器位置
const editorRect = document.querySelector('.editor-area').getBoundingClientRect(); // 获取编辑区位置
// 计算鼠标相对于容器左上角的偏移量
this.canvasOffset.x = e.clientX - containerRect.left;
this.canvasOffset.y = e.clientY - containerRect.top;
this.canvasContainer.style.cursor = 'grabbing'; // 鼠标样式改为抓取
this.canvasDragHandle.style.cursor = 'grabbing';
this.canvasContainer.style.zIndex = '100'; // 提升层级,避免被覆盖
});
// 鼠标移动 → 更新画布位置
document.addEventListener('mousemove', (e) => {
if (!this.isDraggingCanvas) return; // 未拖拽则返回
const editorRect = document.querySelector('.editor-area').getBoundingClientRect();
let newLeft = e.clientX - this.canvasOffset.x - editorRect.left;
let newTop = e.clientY - this.canvasOffset.y - editorRect.top;
const containerWidth = this.canvasContainer.offsetWidth;
const containerHeight = this.canvasContainer.offsetHeight;
const editorWidth = editorRect.width;
const editorHeight = editorRect.height;
// 限制画布不超出编辑区边界(底部留 50px 空白,避免遮挡保存按钮)
newLeft = Math.max(0, Math.min(newLeft, editorWidth - containerWidth));
newTop = Math.max(0, Math.min(newTop, editorHeight - containerHeight - 50));
// 更新画布容器位置(改为绝对定位)
this.canvasContainer.style.position = 'absolute';
this.canvasContainer.style.left = `${newLeft}px`;
this.canvasContainer.style.top = `${newTop}px`;
});
// 鼠标抬起 → 结束拖拽
document.addEventListener('mouseup', () => {
if (this.isDraggingCanvas) {
this.isDraggingCanvas = false;
this.canvasContainer.style.cursor = 'move'; // 恢复移动鼠标样式
this.canvasDragHandle.style.cursor = 'move';
this.canvasContainer.style.zIndex = '1'; // 恢复默认层级
}
});
// 画布内部点击(非手柄区域)→ 取消拖拽状态
this.canvas.addEventListener('mousedown', (e) => {
if (!this.canvasDragHandle.contains(e.target)) {
this.isDraggingCanvas = false;
}
});
}
/**
* 调整元素层级面板位置(窗口 resize 时避免超出窗口)
*/
adjustElementListPanelPosition() {
if (this.isDraggingPanel) return; // 拖拽中不调整
const propertiesPanelRect = this.propertiesPanel.getBoundingClientRect(); // 右侧属性面板位置
const panelWidth = this.elementListPanel.offsetWidth;
// 面板左边界 = 属性面板左边界 - 面板宽度 - 20px(间距)
const newLeft = propertiesPanelRect.left - panelWidth - 20;
// 确保面板不超出左边界
this.elementListPanel.style.left = `${Math.max(0, newLeft)}px`;
this.elementListPanel.style.right = 'auto';
}
/**
* 渲染元素层级列表(同步 elements 数组和 DOM 列表)
*/
renderElementList() {
this.elementList.innerHTML = ''; // 清空现有列表
// 遍历所有元素,生成列表项
this.elements.forEach((element, index) => {
const item = document.createElement('div');
// 列表项样式(选中状态添加 selected 类)
item.className = `element-list-item ${this.selectedElement?.id === element.id ? 'selected' : ''}`;
item.draggable = true; // 支持拖拽排序
item.dataset.id = element.id; // 存储元素 ID(用于后续操作)
item.dataset.index = index; // 存储元素索引
// 1. 元素类型图标(T=文本,I=图片,Q=二维码)
const icon = document.createElement('div');
icon.className = `element-icon ${element.type}-icon`;
icon.textContent = element.type === 'text' ? 'T' : element.type === 'image' ? 'I' : 'Q';
// 2. 元素名称(显示类型+内容/URL,超出隐藏)
const name = document.createElement('div');
name.className = 'element-name';
const displayText = `${element.type === 'text' ? '文本' : element.type === 'image' ? '图片' : '二维码'}: ${element.text || element.src || '未命名'}`;
name.textContent = displayText;
// 3. 元素层级标识(数字越小层级越高)
const layerBadge = document.createElement('div');
layerBadge.className = 'layer-badge';
layerBadge.textContent = element.layer;
// 4. 删除按钮
const deleteBtn = document.createElement('div');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '×';
deleteBtn.title = '删除元素';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡(避免触发列表项点击)
this.deleteElement(element.id);
});
// 5. 拖拽手柄
const handle = document.createElement('div');
handle.className = 'drag-handle';
handle.innerHTML = '⋮⋮'; // 拖拽图标
// 组装列表项
item.append(icon, name, layerBadge, deleteBtn, handle);
// 列表项拖拽事件(排序用)
item.addEventListener('dragstart', (e) => this.handleDragStart(e, index));
item.addEventListener('dragover', (e) => e.preventDefault()); // 允许拖拽放置
item.addEventListener('drop', (e) => this.handleDrop(e, index)); // 放置时触发排序
// 列表项点击 → 选中对应元素
item.addEventListener('click', () => this.handleElementItemClick(element.id));
this.elementList.appendChild(item);
});
}
/**
* 元素层级列表拖拽开始 → 记录拖拽元素索引
* @param {Event} e - 拖拽事件对象
* @param {number} startIndex - 拖拽元素的初始索引
*/
handleDragStart(e, startIndex) {
this.draggingIndex = startIndex;
e.target.classList.add('dragging'); // 添加拖拽样式
}
/**
* 元素层级列表拖拽放置 → 调整元素顺序(更新层级)
* @param {Event} e - 放置事件对象
* @param {number} targetIndex - 放置目标的索引
*/
handleDrop(e, targetIndex) {
e.preventDefault(); // 阻止默认行为
const draggingItem = this.elementList.querySelector('.dragging');
// 校验:无拖拽项/拖拽索引无效/拖拽到自身位置 → 直接返回
if (!draggingItem || this.draggingIndex === -1 || this.draggingIndex === targetIndex) {
if (draggingItem) draggingItem.classList.remove('dragging');
return;
}
// 调整元素数组顺序(从拖拽索引移除,插入到目标索引)
const [movedElement] = this.elements.splice(this.draggingIndex, 1);
this.elements.splice(targetIndex, 0, movedElement);
this.reassignLayers(); // 重新分配层级编号
this.reassignNames(); // 重新分配元素默认名称(如 text_1 → text_2)
draggingItem.classList.remove('dragging'); // 移除拖拽样式
this.updateUI(); // 更新 UI
this.draggingIndex = -1; // 重置拖拽索引
}
/**
* 重新分配元素层级编号(数组顺序决定层级,索引 0 层级最高为 1)
*/
reassignLayers() {
this.elements.forEach((elem, index) => {
elem.layer = index + 1; // 索引 0 → 层级 1(最高),索引 1 → 层级 2,以此类推
});
}
/**
* 重新分配元素默认名称(根据类型和数组索引,如 text_1、image_2)
*/
reassignNames() {
this.elements.forEach((element, index) => {
const newNumber = index + 1;
switch (element.type) {
case 'text':
element.text = `文字{text_${newNumber}}`;
break;
case 'image':
element.src = `{image_${newNumber}}`;
break;
case 'qrcode':
element.text = `{qr_${newNumber}}`;
break;
}
});
this.globalCounter = this.elements.length + 1; // 更新计数器(下一个元素用)
}
/**
* 元素层级列表项点击 → 选中对应元素
* @param {string} elementId - 元素 ID
*/
handleElementItemClick(elementId) {
const element = this.elements.find(el => el.id === elementId);
if (element) this.selectElement(element); // 选中元素
}
/**
* 更新层级控制 UI(禁用/启用上下移按钮,显示当前层级/总层级)
*/
updateLayerUI() {
if (!this.selectedElement) {
document.getElementById('layer-control').style.display = 'none'; // 无选中元素则隐藏
return;
}
document.getElementById('layer-control').style.display = 'block'; // 显示层级控制
// 显示当前层级和总层级
document.getElementById('current-layer').textContent = this.selectedElement.layer;
document.getElementById('total-layers').textContent = this.elements.length;
// 禁用/启用按钮(层级 1 则无法置顶/上移,层级等于总长度则无法置底/下移)
document.getElementById('btn-bring-to-front').disabled = this.selectedElement.layer === 1;
document.getElementById('btn-move-up').disabled = this.selectedElement.layer === 1;
document.getElementById('btn-move-down').disabled = this.selectedElement.layer === this.elements.length;
document.getElementById('btn-send-to-back').disabled = this.selectedElement.layer === this.elements.length;
}
/**
* 元素置顶 → 层级设为 1(最高)
*/
bringToFront() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === 1) return;
this.selectedElement.layer = 1;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素置底 → 层级设为总元素数(最低)
*/
sendToBack() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === this.elements.length) return;
this.selectedElement.layer = this.elements.length;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素上移 → 层级减 1
*/
moveUp() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer <= 1) return;
this.selectedElement.layer--;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素下移 → 层级加 1
*/
moveDown() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer >= this.elements.length) return;
this.selectedElement.layer++;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 按层级排序元素数组(层级 1 在前,层级越高越靠后)
*/
sortElementsByLayer() {
this.elements.sort((a, b) => a.layer - b.layer);
}
/**
* 添加新元素(文本/图片/二维码)
* @param {string} type - 元素类型(text/image/qrcode)
*/
addElement(type) {
// 计算新元素的层级(现有最高层级 + 1)
const newLayer = this.elements.length > 0 ? Math.max(...this.elements.map(e => e.layer)) + 1 : 1;
// 基础元素配置(所有元素通用属性)
const element = {
id: Date.now().toString(), // 用时间戳作为唯一 ID
type,
x: 50, // 默认 X 坐标
y: 50, // 默认 Y 坐标
width: 100, // 默认宽度
height: 100, // 默认高度
layer: newLayer,
containerBackgroundColor: this.defaultContainerColors[type] // 默认容器颜色
};
// 根据类型添加专属属性
switch (type) {
case 'text':
element.text = `文字{text_${this.globalCounter}}`; // 默认文本内容
element.color = '#000000'; // 默认文本颜色(黑色)
element.fontSize = 16; // 默认字体大小
element.align = 'left'; // 默认对齐方式(左对齐)
element.overflow = 'wrap'; // 默认超出处理(自动换行)
break;
case 'image':
element.src = `{image_${this.globalCounter}}`; // 默认图片 URL 占位符
element.borderColor = 'rgba(255,255,255,0)'; // 默认边框颜色(透明)
element.borderRadius = 0; // 默认圆角(0px)
break;
case 'qrcode':
element.text = `{qr_${this.globalCounter}}`; // 默认二维码内容
break;
}
this.elements.push(element); // 添加到元素数组
this.globalCounter++; // 计数器自增(下一个元素用)
this.sortElementsByLayer(); // 按层级排序
this.selectElement(element); // 自动选中新添加的元素
this.updateUI(); // 更新 UI
}
/**
* 选中元素(更新 selectedElement 并同步 UI)
* @param {object} element - 要选中的元素
*/
selectElement(element) {
this.selectedElement = element;
this.updateUI(); // 更新 UI(属性面板、元素选中框等)
}
/**
* 更新右侧属性面板(根据选中元素类型显示/隐藏专属属性)
*/
updatePropertiesPanel() {
const panel = document.getElementById('element-properties');
if (!this.selectedElement) {
panel.style.display = 'none'; // 无选中元素则隐藏面板
return;
}
panel.style.display = 'block'; // 显示面板
// 1. 更新通用属性(坐标、宽高)
document.getElementById('element-x').value = this.selectedElement.x || 0;
document.getElementById('element-y').value = this.selectedElement.y || 0;
document.getElementById('element-width-slider').value = this.selectedElement.width || 100;
document.getElementById('element-width-value').value = this.selectedElement.width || 100;
document.getElementById('element-height-slider').value = this.selectedElement.height || 100;
document.getElementById('element-height-value').value = this.selectedElement.height || 100;
// 2. 隐藏所有属性项(后续按需显示)
document.querySelectorAll('[id$="-item"]').forEach(item => item.style.display = 'none');
document.getElementById('delete-element-item').style.display = 'block'; // 显示删除按钮
// 3. 根据元素类型显示专属属性项
if (this.selectedElement.type === 'text') {
// 文本元素专属属性:内容、颜色、字体大小、对齐方式、超出处理、容器背景
['text-content-item', 'text-color-item', 'text-font-size-item', 'text-align-item', 'text-overflow-item', 'container-bg-color-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步文本属性值到 UI
document.getElementById('element-text').value = this.selectedElement.text || '';
document.getElementById('text-color-input').value = this.selectedElement.color || '#000000';
document.getElementById('text-color').style.backgroundColor = this.selectedElement.color || '#000000';
document.getElementById('text-font-size-slider').value = this.selectedElement.fontSize || 16;
document.getElementById('text-font-size-value').value = this.selectedElement.fontSize || 16;
document.getElementById('text-align').value = this.selectedElement.align || 'left';
document.getElementById('text-overflow').value = this.selectedElement.overflow || 'wrap';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
} else if (this.selectedElement.type === 'image') {
// 图片元素专属属性:URL、边框颜色、容器背景、圆角
['image-url-item', 'border-color-item', 'container-bg-color-item', 'image-border-radius-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步图片属性值到 UI
document.getElementById('image-url').value = this.selectedElement.src || '';
document.getElementById('border-color-input').value = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
document.getElementById('border-color').style.backgroundColor = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
// 同步圆角
document.getElementById('image-border-radius-slider').value = this.selectedElement.borderRadius || 0;
document.getElementById('image-border-radius-value').value = this.selectedElement.borderRadius || 0;
} else if (this.selectedElement.type === 'qrcode') {
// 二维码元素专属属性:内容、容器背景
['qrcode-url-item', 'container-bg-color-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步二维码属性值到 UI
document.getElementById('qrcode-url').value = this.selectedElement.text || '';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
}
this.updateLayerUI(); // 更新层级控制 UI
}
/**
* 更新元素选中框(覆盖层,显示缩放手柄)
*/
updateElementOverlay() {
const overlay = document.getElementById('element-overlay');
if (!this.selectedElement || this.isCanvasResizing) {
overlay.classList.add('hidden'); // 无选中元素/画布缩放时隐藏
return;
}
// 1. 设置选中框位置和大小(与元素一致)
overlay.style.cssText = `left: ${this.selectedElement.x}px; top: ${this.selectedElement.y}px; width: ${this.selectedElement.width}px; height: ${this.selectedElement.height}px;`;
overlay.classList.remove('hidden'); // 显示选中框
// 2. 移除现有缩放手柄(避免重复)
overlay.querySelectorAll('.resize-handle').forEach(handle => handle.remove());
// 3. 添加四个方向的缩放手柄(nw/ne/sw/se)
['nw', 'ne', 'sw', 'se'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `resize-handle resize-${dir}`;
// 手柄点击 → 开始缩放元素
handle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isResizing = true;
this.resizeDirection = dir; // 记录缩放方向
});
overlay.appendChild(handle);
});
}
/**
* 画布点击事件处理 → 选中元素/取消选中
* @param {Event} e - 鼠标点击事件
*/
handleMouseDown(e) {
// 过滤:画布缩放/面板拖拽/画布拖拽时不处理
if (this.isCanvasResizing || this.isDraggingPanel || this.isDraggingCanvas) return;
const rect = this.canvas.getBoundingClientRect();
// 计算鼠标在画布坐标系中的位置(相对于画布左上角)
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 点击缩放手柄则不处理(交给缩放逻辑)
if (this.selectedElement && Array.from(document.querySelectorAll('.resize-handle')).some(h => h.contains(e.target))) {
return;
}
// 查找点击位置的元素(倒序遍历,优先选中上层元素)
const clickedElement = [...this.elements].reverse().find(el => this.isPointInElement(x, y, el));
if (clickedElement) {
this.selectElement(clickedElement); // 选中点击的元素
this.isDragging = true; // 标记为拖拽状态
// 计算拖拽偏移量(鼠标相对于元素左上角的位置)
this.dragOffset.x = x - clickedElement.x;
this.dragOffset.y = y - clickedElement.y;
} else {
this.selectedElement = null; // 未点击到元素 → 取消选中
this.updateUI(); // 更新 UI
}
}
/**
* 鼠标移动事件处理 → 拖拽元素/缩放元素/缩放画布
* @param {Event} e - 鼠标移动事件
*/
handleMouseMove(e) {
// 1. 画布缩放逻辑
if (this.isCanvasResizing) {
const rect = this.canvas.getBoundingClientRect();
const newWidth = e.clientX - rect.left; // 新宽度 = 鼠标X - 画布左边界
const newHeight = e.clientY - rect.top; // 新高度 = 鼠标Y - 画布上边界
// 限制画布大小不小于最小值
if (newWidth > this.canvasMinWidth && newHeight > this.canvasMinHeight) {
this.canvas.width = newWidth;
this.canvas.height = newHeight;
// 同步更新输入框值
document.getElementById('canvas-width').value = newWidth;
document.getElementById('canvas-height').value = newHeight;
this.render(); // 重新渲染画布
}
return;
}
// 2. 未拖拽/缩放则返回
if (!this.isDragging && !this.isResizing) return;
const rect = this.canvas.getBoundingClientRect();
// 计算鼠标在画布坐标系中的位置
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 3. 元素拖拽逻辑
if (this.isDragging && this.selectedElement) {
// 元素新坐标 = 鼠标位置 - 拖拽偏移量(避免点击时跳动)
this.selectedElement.x = x - this.dragOffset.x;
this.selectedElement.y = y - this.dragOffset.y;
// 同步更新输入框值
document.getElementById('element-x').value = this.selectedElement.x;
document.getElementById('element-y').value = this.selectedElement.y;
this.updateElementOverlay(); // 更新选中框
this.render(); // 重新渲染画布
}
// 4. 元素缩放逻辑
else if (this.isResizing && this.selectedElement) {
const minSize = 10; // 元素最小宽高
let newX = this.selectedElement.x, newY = this.selectedElement.y;
let newWidth = this.selectedElement.width, newHeight = this.selectedElement.height;
// 根据缩放方向计算新坐标和宽高
switch (this.resizeDirection) {
case 'nw': // 左上 → 同时调整X、Y、宽度、高度
newX = x;
newY = y;
newWidth = this.selectedElement.x + this.selectedElement.width - x;
newHeight = this.selectedElement.y + this.selectedElement.height - y;
break;
case 'ne': // 右上 → 调整Y、宽度、高度
newY = y;
newWidth = x - this.selectedElement.x;
newHeight = this.selectedElement.y + this.selectedElement.height - y;
break;
case 'sw': // 左下 → 调整X、宽度、高度
newX = x;
newWidth = this.selectedElement.x + this.selectedElement.width - x;
newHeight = y - this.selectedElement.y;
break;
case 'se': // 右下 → 仅调整宽度、高度
newWidth = x - this.selectedElement.x;
newHeight = y - this.selectedElement.y;
break;
}
// 限制元素大小不小于最小值
if (newWidth >= minSize && newHeight >= minSize) {
// 更新元素属性
Object.assign(this.selectedElement, {x: newX, y: newY, width: newWidth, height: newHeight});
// 同步更新输入框/滑块值
document.getElementById('element-x').value = newX;
document.getElementById('element-y').value = newY;
document.getElementById('element-width-slider').value = newWidth;
document.getElementById('element-width-value').value = newWidth;
document.getElementById('element-height-slider').value = newHeight;
document.getElementById('element-height-value').value = newHeight;
this.updateElementOverlay(); // 更新选中框
this.render(); // 重新渲染画布
}
}
}
/**
* 鼠标抬起/离开事件处理 → 结束拖拽/缩放
*/
handleMouseUp() {
// 重置所有拖拽/缩放状态
this.isDragging = this.isResizing = this.isCanvasResizing = false;
this.resizeDirection = this.draggingIndex = -1; // 重置方向和拖拽索引
this.updateElementOverlay(); // 更新选中框(恢复正常状态)
this.updateJSONPreview(); // 更新 JSON 预览(同步最新数据)
}
/**
* 判断点是否在元素内(用于点击选中元素)
* @param {number} x - 点的 X 坐标(画布坐标系)
* @param {number} y - 点的 Y 坐标(画布坐标系)
* @param {object} element - 要判断的元素
* @returns {boolean} 是否在元素内
*/
isPointInElement(x, y, element) {
// 元素矩形区域:x ≤ 点X ≤ x+宽度,y ≤ 点Y ≤ y+高度
return x >= element.x && x <= element.x + element.width && y >= element.y && y <= element.y + element.height;
}
/**
* 核心渲染方法 → 绘制画布背景和所有元素
*/
render() {
// 1. 清空画布(避免残影)
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 2. 绘制画布背景(从颜色选择器获取)
const bgColor = document.getElementById('canvas-bg-color-input').value;
this.ctx.fillStyle = bgColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// 3. 绘制所有元素(倒序遍历,上层元素后绘制,覆盖下层)
[...this.elements].reverse().forEach(element => {
// 绘制元素容器背景(区分元素类型的默认颜色)
const containerBg = element.containerBackgroundColor || this.defaultContainerColors[element.type];
this.ctx.fillStyle = containerBg;
this.ctx.beginPath();
// 图片元素支持圆角容器
if (element.type === 'image' && element.borderRadius > 0) {
this.ctx.roundRect(element.x, element.y, element.width, element.height, element.borderRadius);
} else {
this.ctx.rect(element.x, element.y, element.width, element.height); // 矩形容器
}
this.ctx.fill(); // 填充容器背景
// 图片元素绘制边框
if (element.type === 'image') {
const borderColor = element.borderColor || 'rgba(255,255,255,0)';
this.ctx.strokeStyle = borderColor;
this.ctx.lineWidth = 1;
this.ctx.stroke(); // 绘制边框
}
// 绘制元素内容(文本/图片/二维码)
this.renderElement(element);
});
}
/**
* 绘制单个元素内容(根据类型调用不同绘制方法)
* @param {object} element - 要绘制的元素
*/
renderElement(element) {
switch (element.type) {
case 'text':
this.renderTextElement(element); // 绘制文本
break;
case 'image':
this.renderImageElement(element); // 绘制图片
break;
case 'qrcode':
this.renderQrCodeElement(element); // 绘制二维码
break;
}
}
/**
* 绘制文本元素(支持换行、对齐、超出省略)
* @param {object} element - 文本元素
*/
renderTextElement(element) {
this.ctx.fillStyle = element.color || '#000000'; // 文本颜色
this.ctx.font = `${element.fontSize || 16}px Arial`; // 字体大小和字体族
const text = element.text || '';
const maxWidth = element.width; // 文本最大宽度(元素宽度)
const lineHeight = (element.fontSize || 16) * 1.2; // 行高(字体大小的 1.2 倍)
const x = element.x; // 文本起始 X 坐标
const y = element.y + (element.fontSize || 16); // 文本起始 Y 坐标(向下偏移一个字体大小)
// 处理文本超出:换行/省略
let lines = element.overflow === 'wrap' ? this.wrapText(text, maxWidth) : [this.getEllipsisText(text, maxWidth)];
const totalHeight = lines.length * lineHeight; // 文本总高度
// 绘制每一行文本
lines.forEach((line, index) => {
let drawX = x; // 每行文本的 X 坐标
// 根据对齐方式调整 X 坐标
if (element.align === 'center') {
// 居中:X = 元素X + (元素宽度 - 该行文本宽度)/2
drawX = x + (maxWidth - this.ctx.measureText(line).width) / 2;
} else if (element.align === 'right') {
// 右对齐:X = 元素X + 元素宽度 - 该行文本宽度
drawX = x + maxWidth - this.ctx.measureText(line).width;
}
// 垂直居中:Y = 起始Y - (总高度 - 行高)/2 + 行索引*行高
const drawY = y - (totalHeight - lineHeight) / 2 + index * lineHeight;
this.ctx.fillText(line, drawX, drawY); // 绘制文本
});
}
/**
* 文本换行处理(按空格分割,避免单词截断)
* @param {string} text - 要处理的文本
* @param {number} maxWidth - 最大宽度(元素宽度)
* @returns {array} 换行后的文本行数组
*/
wrapText(text, maxWidth) {
// 按空格分割单词
return text.split(' ').reduce((acc, word) => {
const lastLine = acc[acc.length - 1] || ''; // 最后一行文本
const testLine = lastLine ? `${lastLine} ${word}` : word; // 测试添加当前单词后的行
// 如果添加后宽度不超过最大宽度 → 更新最后一行
if (this.ctx.measureText(testLine).width <= maxWidth && lastLine) {
acc[acc.length - 1] = testLine;
} else {
acc.push(word); // 超过则新增一行
}
return acc;
}, []);
}
/**
* 文本超出省略处理(末尾添加 ...)
* @param {string} text - 要处理的文本
* @param {number} maxWidth - 最大宽度(元素宽度)
* @returns {string} 省略后的文本
*/
getEllipsisText(text, maxWidth) {
// 文本宽度未超出则直接返回
if (this.ctx.measureText(text).width <= maxWidth) return text;
let truncated = text;
// 逐字删除,直到添加 ... 后宽度不超出
while (this.ctx.measureText(truncated + '...').width > maxWidth && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
return truncated + '...'; // 返回省略后的文本
}
/**
* 绘制图片元素(支持网络图片、占位符、圆角裁剪)
* @param {object} element - 图片元素
*/
renderImageElement(element) {
// 网络图片(以 http/https 开头)
if (element.src && element.src.startsWith('http')) {
const img = new Image();
img.crossOrigin = 'anonymous'; // 解决跨域图片绘制问题
const renderProps = {...element}; // 保存当前元素属性(避免异步绘制时属性变化)
// 图片加载成功 → 绘制图片(支持圆角裁剪)
img.onload = () => {
this.ctx.save(); // 保存当前绘图状态
// 圆角裁剪(圆角半径 > 0 时)
if (renderProps.borderRadius > 0) {
this.ctx.beginPath();
this.ctx.roundRect(renderProps.x, renderProps.y, renderProps.width, renderProps.height, renderProps.borderRadius);
this.ctx.clip(); // 裁剪成圆角
}
// 绘制图片(铺满元素区域)
this.ctx.drawImage(img, renderProps.x, renderProps.y, renderProps.width, renderProps.height);
this.ctx.restore(); // 恢复绘图状态
};
// 图片加载失败 → 绘制占位符
img.onerror = () => this.renderImagePlaceholder(renderProps);
img.src = element.src; // 设置图片 URL
} else {
// 非网络图片 → 绘制占位符
this.renderImagePlaceholder(element);
}
}
/**
* 绘制图片占位符(显示文本提示)
* @param {object} props - 图片元素属性
*/
renderImagePlaceholder(props) {
this.ctx.fillStyle = '#999'; // 占位符文本颜色
this.ctx.font = '12px Arial'; // 字体大小
this.ctx.textAlign = 'center'; // 水平居中
this.ctx.textBaseline = 'middle'; // 垂直居中
// 显示提示文本(优先显示 text,无则显示 src,再无则显示默认提示)
const placeholderText = props.text || props.src || '图片占位符';
this.ctx.fillText(placeholderText, props.x + props.width / 2, props.y + props.height / 2);
// 恢复默认文本对齐方式(避免影响其他绘制)
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'alphabetic';
}
/**
* 绘制二维码占位符(简化版,实际项目可集成 qrcode.js 生成真实二维码)
* @param {object} element - 二维码元素
*/
renderQrCodeElement(element) {
// 绘制二维码外框(黑色)
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height - 10);
// 绘制二维码定位点(三个角的白色方块)
const posSize = (element.width - 10) / 7; // 定位点大小
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(element.x + 8, element.y + 8, posSize, posSize); // 左上定位点
this.ctx.fillRect(element.x + element.width - posSize - 8, element.y + 8, posSize, posSize); // 右上定位点
this.ctx.fillRect(element.x + 8, element.y + element.height - posSize - 8, posSize, posSize); // 左下定位点
// 绘制二维码内容提示(居中)
this.ctx.font = '10px Arial';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
const qrText = element.text || '二维码内容';
this.ctx.fillText(qrText, element.x + element.width / 2, element.y + element.height / 2);
// 恢复默认文本对齐方式
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'alphabetic';
}
/**
* 更新 JSON 预览区域(显示当前海报的完整数据)
*/
updateJSONPreview() {
const jsonData = {
width: this.canvas.width, // 画布宽度
height: this.canvas.height, // 画布高度
backgroundColor: document.getElementById('canvas-bg-color-input').value, // 画布背景色
elements: this.elements.map(element => {
const elemCopy = {...element};
delete elemCopy.id; // 移除前端临时 ID(后端不需要)
return elemCopy;
})
};
// 格式化 JSON 并显示(缩进 2 空格,便于阅读)
document.getElementById('json-preview').textContent = JSON.stringify(jsonData, null, 2);
}
/**
* 保存 JSON 到后端(使用 jQuery AJAX)
* 数据格式:{ name: '海报名称', materials: { 画布+元素数据 } }
*/
saveJSON() {
if (typeof $ === 'undefined') {
alert('错误:未检测到 jQuery 库。请先引入 jQuery 才能使用保存功能。');
return;
}
// 1. 获取海报名称
const posterNameInput = document.getElementById('json-name');
const posterName = posterNameInput.value.trim() || '未命名海报';
// 2. 构造请求体
const requestData = {
name: posterName,
materials: {
width: this.canvas.width,
height: this.canvas.height,
backgroundColor: document.getElementById('canvas-bg-color-input').value,
elements: this.elements.map(element => {
const elemCopy = {...element};
delete elemCopy.id; // 移除前端临时 ID
return elemCopy;
})
}
};
// 3. 使用 jQuery AJAX 发送请求
// !!! 替换为你的后端接口地址 !!!
const apiUrl = "{:url('add_post')}";
$.ajax({
url: apiUrl,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
// headers: {
// 'Authorization': 'Bearer ' + yourAuthToken // 如果需要认证
// },
success: function (response) {
console.log('保存成功:', response);
// 使用 layer.msg 显示成功提示
// 假设后端返回 {code: 0, msg: '保存成功'} 这样的格式
// 如果 response.msg 不存在,则使用默认文本
const successMsg = response.msg || `海报 "${posterName}" 已成功保存!`;
parent.layer.msg(successMsg, {icon: 1}); // icon: 1 表示成功的笑脸图标
// 延迟 500 毫秒后关闭弹框并刷新父页面
// **** 根据需求,我现在有弹框关闭弹框了,或者就是根据后台返回页面跳转一下 ****
setTimeout(function () {
// 关闭父级页面上所有的 layer 弹框
parent.layer.closeAll();
// 刷新父级页面
parent.location.reload();
}, 500);
},
error: function (jqXHR, textStatus, errorThrown) {
console.error('保存失败:');
console.error('状态:', textStatus);
console.error('错误:', errorThrown);
console.error('服务器响应:', jqXHR.responseText);
parent.layer.msg('错误:' + errorThrown, {icon: 2}); // icon: 1 表示成功的笑脸图标
}
});
}
/**
* 删除元素(根据 ID 从 elements 数组中移除)
* @param {string} elementId - 要删除的元素 ID
*/
deleteElement(elementId) {
const index = this.elements.findIndex(el => el.id === elementId); // 查找元素索引
if (index !== -1) {
this.elements.splice(index, 1); // 从数组中移除
this.reassignLayers(); // 重新分配层级
this.reassignNames(); // 重新分配元素名称
this.selectedElement = null; // 取消选中(删除的元素已不存在)
this.updateUI(); // 更新 UI
}
}
/**
* 统一更新 UI(调用所有需要刷新的方法,避免重复代码)
*/
updateUI() {
this.render(); // 渲染画布
this.renderElementList(); // 渲染元素层级列表
this.updatePropertiesPanel(); // 更新属性面板
this.updateElementOverlay(); // 更新元素选中框
this.updateJSONPreview(); // 更新 JSON 预览
}
}
// 页面 DOM 加载完成后初始化编辑器
document.addEventListener('DOMContentLoaded', () => {
new PosterEditor('poster-canvas'); // 传入画布 ID 初始化
});
</script>
前端编辑代码
ps:注意css路径问题
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>海报(编辑)</title>
<link href="poster.css" rel="stylesheet"
type="text/css">
</head>
<body>
<div class="header">
<div class="toolbar">
<button class="btn btn-default" id="备用按钮" style="display:none;">备用</button>
</div>
</div>
<div class="container">
<div class="sidebar" id="sidebar">
<div class="sidebar-toggle" id="sidebar-toggle">≡</div>
<div class="component-list" id="component-list">
<div class="component-item" data-type="text">文本</div>
<div class="component-item" data-type="image">图片</div>
<div class="component-item" data-type="qrcode">二维码</div>
</div>
</div>
<div class="editor-area">
<div class="canvas-container" id="canvas-container">
<div class="canvas-drag-handle" id="canvas-drag-handle">☰</div>
<canvas id="poster-canvas" width="422" height="750"></canvas>
<div class="element-overlay hidden" id="element-overlay"></div>
<div class="resize-handle resize-se canvas-resize-handle" id="canvas-resize-handle"></div>
</div>
<div class="json-name-input">
<label for="json-name">名称:</label>
<input type="text" id="json-name" autocomplete="off" placeholder="请输入海报名称">
</div>
<!-- 新增:隐藏的ID输入框 -->
<input type="hidden" id="poster-id" name="id" value="{$id}">
<button class="btn btn-primary" id="save-json-btn">保存JSON</button>
</div>
<div class="properties-panel" id="properties-panel">
<div class="canvas-properties-wrapper">
<div class="property-group">
<div class="property-title">画布属性</div>
<div class="property-item">
<div class="property-label">宽度</div>
<input type="number" class="property-input" id="canvas-width" value="422" min="100" max="2000">
</div>
<div class="property-item">
<div class="property-label">高度</div>
<input type="number" class="property-input" id="canvas-height" value="750" min="100" max="2000">
</div>
<div class="property-item">
<div class="property-label">背景颜色</div>
<div class="color-picker">
<div class="color-preview" id="canvas-bg-color" style="background-color: #ffffff;"></div>
<input type="color" class="property-input" id="canvas-bg-color-input" value="#ffffff">
</div>
</div>
</div>
<div class="property-group" id="element-properties" style="display: none;">
<div class="property-title">元素属性</div>
<div class="property-item">
<div class="property-label">X坐标</div>
<input type="number" class="property-input" id="element-x" value="0">
</div>
<div class="property-item">
<div class="property-label">Y坐标</div>
<input type="number" class="property-input" id="element-y" value="0">
</div>
<div class="property-item">
<div class="property-label">宽度</div>
<div class="property-slider">
<input type="range" id="element-width-slider" min="10" max="500" value="100">
<input type="number" id="element-width-value" class="slider-value" min="10" max="500"
value="100">
</div>
</div>
<div class="property-item">
<div class="property-label">高度</div>
<div class="property-slider">
<input type="range" id="element-height-slider" min="10" max="500" value="100">
<input type="number" id="element-height-value" class="slider-value" min="10" max="500"
value="100">
</div>
</div>
<div class="layer-control" id="layer-control" style="display: none;">
<div class="property-label">层级控制</div>
<div class="layer-info">
当前: <span id="current-layer">1</span> / 总: <span id="total-layers">1</span>
</div>
<div class="btn-group">
<button id="btn-bring-to-front" class="btn btn-default">置顶</button>
<button id="btn-move-up" class="btn btn-default">上移</button>
<button id="btn-move-down" class="btn btn-default">下移</button>
<button id="btn-send-to-back" class="btn btn-default">置底</button>
</div>
</div>
<div class="property-item" id="text-content-item" style="display: none;">
<div class="property-label">文本内容</div>
<input type="text" class="property-input" id="element-text" value="">
</div>
<div class="property-item" id="text-color-item" style="display: none;">
<div class="property-label">文本颜色</div>
<div class="color-picker">
<div class="color-preview" id="text-color" style="background-color: #000000;"></div>
<input type="color" class="property-input" id="text-color-input" value="#000000">
</div>
</div>
<div class="property-item" id="text-font-size-item" style="display: none;">
<div class="property-label">字体大小</div>
<div class="property-slider">
<input type="range" id="text-font-size-slider" min="1" max="100" value="16">
<input type="number" id="text-font-size-value" class="slider-value" min="1" max="100"
value="16">
</div>
</div>
<div class="property-item" id="text-align-item" style="display: none;">
<div class="property-label">对齐方式</div>
<select class="property-select" id="text-align">
<option value="left">靠左</option>
<option value="center">居中</option>
<option value="right">靠右</option>
</select>
</div>
<div class="property-item" id="text-overflow-item" style="display: none;">
<div class="property-label">超出处理</div>
<select class="property-select" id="text-overflow">
<option value="wrap">自动换行</option>
<option value="ellipsis">隐藏超出字符</option>
</select>
</div>
<div class="property-item" id="image-url-item" style="display: none;">
<div class="property-label">图片URL</div>
<input type="text" class="property-input" id="image-url" value="">
</div>
<div class="property-item" id="border-color-item" style="display: none;">
<div class="property-label">边框颜色</div>
<div class="color-picker">
<div class="color-preview" id="border-color"
style="background-color: rgba(255,255,255,0);"></div>
<input type="color" class="property-input" id="border-color-input" value="#ffffff">
</div>
</div>
<div class="property-item" id="container-bg-color-item" style="display: none;">
<div class="property-label">容器背景</div>
<div class="color-picker">
<div class="color-preview" id="container-bg-color"
style="background-color: rgba(255,255,255,0);"></div>
<input type="color" class="property-input" id="container-bg-color-input" value="#ffffff">
</div>
</div>
<div class="property-item" id="image-border-radius-item" style="display: none;">
<div class="property-label">圆角半径</div>
<div class="property-slider">
<input type="range" id="image-border-radius-slider" min="0" max="50" value="0">
<input type="number" id="image-border-radius-value" class="slider-value" min="0" max="50"
value="0">
</div>
</div>
<div class="property-item" id="qrcode-url-item" style="display: none;">
<div class="property-label">二维码内容</div>
<input type="text" class="property-input" id="qrcode-url" value="">
</div>
<div class="property-item" id="delete-element-item" style="display: none; margin-top: 15px;">
<button id="delete-element-btn">删除元素</button>
</div>
</div>
</div>
<div class="property-group">
<div class="property-title">JSON数据预览</div>
<div class="json-preview" id="json-preview"></div>
</div>
</div>
</div>
<div class="element-list-panel" id="element-list-panel">
<div class="panel-resize-handle" id="panel-resize-handle"></div>
<div class="panel-title">元素层级</div>
<div class="element-list" id="element-list"></div>
</div>
</body>
</html>
<!-- 先引入 jQuery -->
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- 引入 layui JS -->
<script src="https://cdn.staticfile.org/layui/2.9.10/layui.js"></script>
<script>
/**
* 海报编辑器核心类
* 负责画布初始化、元素管理(添加/删除/修改)、拖拽缩放、属性面板同步、JSON 保存等功能
*/
class PosterEditor {
/**
* 构造函数:初始化编辑器实例
* @param {string} canvasId - 画布 DOM 元素的 ID
*/
constructor(canvasId) {
// 1. 获取画布和上下文
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d'); // 2D 绘图上下文
// 2. 核心数据存储
this.elements = []; // 存储所有海报元素(文本、图片、二维码)
this.selectedElement = null; // 当前选中的元素
// 3. 拖拽/缩放状态标识
this.isDragging = false; // 元素是否正在拖拽
this.dragOffset = {x: 0, y: 0}; // 拖拽时的鼠标偏移量(避免点击元素时跳动)
this.isResizing = false; // 元素是否正在缩放
this.resizeDirection = null; // 缩放方向(nw/ne/sw/se)
this.isCanvasResizing = false; // 画布是否正在缩放
// 4. 画布基础配置
this.canvasMinWidth = 100; // 画布最小宽度
this.canvasMinHeight = 100; // 画布最小高度
// 5. 元素计数器(用于生成默认名称,如 text_1、image_2)
this.globalCounter = 1;
// 6. DOM 元素引用(方便后续操作)
this.elementList = document.getElementById('element-list'); // 元素层级列表
this.draggingIndex = -1; // 拖拽元素在列表中的索引
this.sidebar = document.getElementById('sidebar'); // 左侧组件库侧边栏
this.sidebarToggle = document.getElementById('sidebar-toggle'); // 侧边栏折叠按钮
this.elementListPanel = document.getElementById('element-list-panel'); // 元素层级面板
this.panelTitle = this.elementListPanel.querySelector('.panel-title'); // 层级面板标题(拖拽用)
this.saveBtn = document.getElementById('save-json-btn'); // 保存 JSON 按钮
this.propertiesPanel = document.getElementById('properties-panel'); // 右侧属性面板
this.canvasContainer = document.getElementById('canvas-container'); // 画布容器
this.canvasDragHandle = document.getElementById('canvas-drag-handle'); // 画布拖拽手柄
// 7. 面板/画布拖拽配置
this.isDraggingPanel = false; // 元素层级面板是否正在拖拽
this.panelOffset = {x: 0, y: 0}; // 面板拖拽偏移量
this.isDraggingCanvas = false; // 画布是否正在拖拽
this.canvasOffset = {x: 0, y: 0}; // 画布拖拽偏移量
// 8. 不同类型元素的默认容器颜色(区分元素类型,提升视觉体验)
this.defaultContainerColors = {
text: '#e8f4f8', // 文本元素:浅蓝色
image: '#faf6ed', // 图片元素:浅橙色
qrcode: '#fdf2f8' // 二维码元素:浅粉色
};
// 9. 初始化编辑器(顺序不可乱)
this.initCanvas(); // 初始化画布尺寸
this.initEventListeners(); // 绑定所有事件(点击、拖拽、输入等)
this.initSidebarToggle(); // 初始化侧边栏折叠功能
this.initPanelDrag(); // 初始化元素层级面板拖拽
this.initCanvasDrag(); // 初始化画布拖拽
// 新增:尝试加载海报数据
this.fetchAndLoadPosterData();
this.updateUI(); // 首次更新 UI(渲染画布、层级列表、属性面板)
}
/**
* 新增:从API获取并加载海报数据(使用 $.ajax)
*/
fetchAndLoadPosterData() {
const apiUrl = "{:url('get_poster')}"; // 你的后端接口地址
console.log(apiUrl);
$.ajax({
url: apiUrl,
type: 'GET', // 请求类型
data: {id: document.getElementById('poster-id').value}, // 请求参数(可选)
dataType: 'json', // 预期返回的数据类型
success: (res) => {
var response = res.data; // 方便后续操作
console.log('加载的原始数据:', response);
if (response && response.materials) {
console.log('成功加载海报数据:', response);
// 1. 设置隐藏域的 ID
document.getElementById('poster-id').value = response.id || '';
// 2. 设置海报名称
document.getElementById('json-name').value = response.name || '';
// 3. 加载画布属性
const materials = response.materials;
this.canvas.width = parseInt(materials.width, 10) || 422;
this.canvas.height = parseInt(materials.height, 10) || 750;
document.getElementById('canvas-width').value = this.canvas.width;
document.getElementById('canvas-height').value = this.canvas.height;
document.getElementById('canvas-bg-color-input').value = materials.backgroundColor || '#ffffff';
document.getElementById('canvas-bg-color').style.backgroundColor = materials.backgroundColor || '#ffffff';
// 4. 清空现有元素并加载新元素
this.elements = []; // 清空
if (materials.elements && materials.elements.length > 0) {
materials.elements.forEach(elementData => {
// 校验并修正宽高,防止出现过大或非数字值
const width = parseInt(elementData.width, 10);
const height = parseInt(elementData.height, 10);
// 根据元素类型,确保关键属性存在
let contentProps = {};
switch (elementData.type) {
case 'text':
contentProps = {
text: elementData.text || '未命名文本',
color: elementData.color || '#000000',
fontSize: parseInt(elementData.fontSize, 10) || 16,
align: elementData.align || 'left',
overflow: elementData.overflow || 'wrap',
};
break;
case 'image':
contentProps = {
src: elementData.src || '{image_placeholder}',
borderColor: elementData.borderColor || 'rgba(255,255,255,0)',
borderRadius: parseInt(elementData.borderRadius, 10) || 0,
};
break;
case 'qrcode':
contentProps = {
text: elementData.text || '未设置二维码内容',
};
break;
}
// 为每个加载的元素生成一个新的唯一ID
const newElement = {
...elementData,
...contentProps, // 合并关键属性
id: Date.now().toString() + Math.floor(Math.random() * 1000).toString(),
width: isNaN(width) ? 100 : Math.max(10, width),
height: isNaN(height) ? 100 : Math.max(10, height),
x: parseInt(elementData.x, 10) || 0,
y: parseInt(elementData.y, 10) || 0,
// 确保容器背景色存在
containerBackgroundColor: elementData.containerBackgroundColor || this.defaultContainerColors[elementData.type] || '#f0f0f0'
};
this.elements.push(newElement);
});
// 根据 layer 排序
this.sortElementsByLayer();
// 更新计数器
this.globalCounter = this.elements.length > 0 ? this.elements.length + 1 : 1;
}
// 5. 强制更新UI
this.updateUI();
} else {
console.warn('未获取到有效的海报数据,将创建新海报。');
this.updateUI(); // 即使数据无效也初始化UI
}
},
error: (xhr, status, error) => {
console.error('加载海报数据失败:', status, error);
console.error('响应内容:', xhr.responseText); // 打印完整的错误响应
// 显示用户友好的错误提示
parent.layer.msg('加载海报数据失败,请检查网络或联系管理员。', {icon: 2});
// 即使失败也继续初始化UI,以便用户可以创建新海报
this.updateUI();
},
timeout: 10000 // 设置超时时间(10秒)
});
}
/**
* 初始化画布尺寸(从输入框读取默认值,无值则用画布自身尺寸)
*/
initCanvas() {
const widthInput = document.getElementById('canvas-width');
const heightInput = document.getElementById('canvas-height');
// 优先使用输入框的值,无值则用画布默认尺寸
this.canvas.width = parseInt(widthInput.value) || this.canvas.width;
this.canvas.height = parseInt(heightInput.value) || this.canvas.height;
this.render(); // 初始化后渲染画布
}
/**
* 创建滑块和输入框的双向绑定(同步数值,如元素宽度、字体大小)
* @param {string} sliderId - 滑块 DOM 元素 ID
* @param {string} inputId - 输入框 DOM 元素 ID
* @param {function} callback - 数值变化后的回调函数(更新元素属性)
*/
createTwoWayBinding(sliderId, inputId, callback) {
const slider = document.getElementById(sliderId);
const input = document.getElementById(inputId);
// 同步滑块和输入框的值
const updateValue = (source, value) => {
if (source === 'slider') input.value = value; // 滑块动 → 输入框同步
if (source === 'input') slider.value = value; // 输入框动 → 滑块同步
callback && callback(parseInt(value)); // 数值变化后执行回调(更新元素)
};
// 绑定滑块输入事件
slider.addEventListener('input', (e) => updateValue('slider', e.target.value));
// 绑定输入框输入事件(处理边界值,如不小于最小值、不大于最大值)
input.addEventListener('input', (e) => {
let value = parseInt(e.target.value) || 0;
if (slider.min) value = Math.max(parseInt(slider.min), value); // 不小于最小值
if (slider.max) value = Math.min(parseInt(slider.max), value); // 不大于最大值
e.target.value = value; // 修正输入框值
updateValue('input', value);
});
}
/**
* 初始化所有事件监听器(核心方法,覆盖所有交互逻辑)
*/
initEventListeners() {
// -------------------------- 画布属性事件 --------------------------
// 画布宽度输入框变化 → 更新画布宽度
document.getElementById('canvas-width').addEventListener('change', (e) => {
const newWidth = Math.max(this.canvasMinWidth, Math.min(parseInt(e.target.value) || this.canvasMinWidth, 2000));
this.canvas.width = newWidth;
e.target.value = newWidth; // 修正输入框值(避免超出范围)
this.render(); // 重新渲染画布
});
// 画布高度输入框变化 → 更新画布高度
document.getElementById('canvas-height').addEventListener('change', (e) => {
const newHeight = Math.max(this.canvasMinHeight, Math.min(parseInt(e.target.value) || this.canvasMinHeight, 2000));
this.canvas.height = newHeight;
e.target.value = newHeight; // 修正输入框值
this.render(); // 重新渲染画布
});
// 画布背景颜色选择器变化 → 更新画布背景
document.getElementById('canvas-bg-color-input').addEventListener('input', (e) => {
document.getElementById('canvas-bg-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
});
// -------------------------- 元素通用属性事件 --------------------------
// 元素宽度(滑块+输入框)双向绑定 → 更新元素宽度
this.createTwoWayBinding('element-width-slider', 'element-width-value', (value) => {
if (this.selectedElement) {
this.selectedElement.width = value;
this.updateElementOverlay(); // 更新元素选中框(覆盖层)
this.render(); // 重新渲染画布
}
});
// 元素高度(滑块+输入框)双向绑定 → 更新元素高度
this.createTwoWayBinding('element-height-slider', 'element-height-value', (value) => {
if (this.selectedElement) {
this.selectedElement.height = value;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// 元素X坐标输入框变化 → 更新元素X坐标
document.getElementById('element-x').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.x = parseInt(e.target.value) || 0;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// 元素Y坐标输入框变化 → 更新元素Y坐标
document.getElementById('element-y').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.y = parseInt(e.target.value) || 0;
this.updateElementOverlay(); // 更新元素选中框
this.render(); // 重新渲染画布
}
});
// -------------------------- 文本元素专属事件 --------------------------
// 文本内容输入框变化 → 更新文本内容
document.getElementById('element-text').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.text = e.target.value;
this.updateUI(); // 更新 UI(渲染画布+层级列表)
}
});
// 文本颜色选择器变化 → 更新文本颜色
document.getElementById('text-color-input').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.color = e.target.value;
document.getElementById('text-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// 字体大小(滑块+输入框)双向绑定 → 更新字体大小
this.createTwoWayBinding('text-font-size-slider', 'text-font-size-value', (value) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.fontSize = value;
this.render(); // 重新渲染画布
}
});
// 文本对齐方式下拉框变化 → 更新文本对齐
document.getElementById('text-align').addEventListener('change', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.align = e.target.value;
this.render(); // 重新渲染画布
}
});
// 文本超出处理下拉框变化 → 更新超出逻辑(换行/省略)
document.getElementById('text-overflow').addEventListener('change', (e) => {
if (this.selectedElement?.type === 'text') { // 仅文本元素生效
this.selectedElement.overflow = e.target.value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 图片元素专属事件 --------------------------
// 图片URL输入框变化 → 更新图片地址
document.getElementById('image-url').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.src = e.target.value;
this.updateUI(); // 更新 UI
}
});
// 图片边框颜色选择器变化 → 更新边框颜色
document.getElementById('border-color-input').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.borderColor = e.target.value;
document.getElementById('border-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// 图片圆角(滑块+输入框)双向绑定 → 更新圆角半径
this.createTwoWayBinding('image-border-radius-slider', 'image-border-radius-value', (value) => {
if (this.selectedElement?.type === 'image') { // 仅图片元素生效
this.selectedElement.borderRadius = value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 二维码元素专属事件 --------------------------
// 二维码内容输入框变化 → 更新二维码内容
document.getElementById('qrcode-url').addEventListener('input', (e) => {
if (this.selectedElement?.type === 'qrcode') { // 仅二维码元素生效
this.selectedElement.text = e.target.value;
this.updateUI(); // 更新 UI
}
});
// -------------------------- 容器背景色(通用)事件 --------------------------
// 容器背景色选择器变化 → 更新元素容器背景
document.getElementById('container-bg-color-input').addEventListener('input', (e) => {
if (this.selectedElement) {
this.selectedElement.containerBackgroundColor = e.target.value;
document.getElementById('container-bg-color').style.backgroundColor = e.target.value;
this.render(); // 重新渲染画布
}
});
// -------------------------- 画布/元素拖拽/缩放事件 --------------------------
// 画布点击事件 → 选中元素/取消选中
this.canvas.addEventListener('mousedown', this.handleMouseDown.bind(this));
// 鼠标移动事件 → 拖拽元素/缩放元素/缩放画布
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
// 鼠标抬起事件 → 结束拖拽/缩放
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
// 鼠标离开页面事件 → 结束拖拽/缩放(避免异常状态)
document.addEventListener('mouseleave', this.handleMouseUp.bind(this));
// 画布缩放手柄点击事件 → 开始缩放画布
document.getElementById('canvas-resize-handle').addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡(避免触发画布点击)
this.isCanvasResizing = true;
document.getElementById('element-overlay').classList.add('hidden'); // 隐藏元素选中框
});
// -------------------------- 左侧组件库事件 --------------------------
// 组件库元素点击事件 → 添加对应类型的元素(文本/图片/二维码)
document.querySelectorAll('.component-item').forEach(item => {
item.addEventListener('click', () => this.addElement(item.getAttribute('data-type')));
});
// -------------------------- 保存/删除/层级事件 --------------------------
// 保存 JSON 按钮点击事件 → 调用保存方法
this.saveBtn.addEventListener('click', () => this.saveJSON());
// 元素置顶按钮 → 提升元素层级到最高
document.getElementById('btn-bring-to-front').addEventListener('click', () => this.bringToFront());
// 元素上移按钮 → 提升元素层级
document.getElementById('btn-move-up').addEventListener('click', () => this.moveUp());
// 元素下移按钮 → 降低元素层级
document.getElementById('btn-move-down').addEventListener('click', () => this.moveDown());
// 元素置底按钮 → 降低元素层级到最低
document.getElementById('btn-send-to-back').addEventListener('click', () => this.sendToBack());
// 删除元素按钮 → 删除当前选中元素
document.getElementById('delete-element-btn').addEventListener('click', () => {
if (this.selectedElement) {
this.deleteElement(this.selectedElement.id);
}
});
// 键盘 Delete/Backspace 键 → 删除当前选中元素(快捷键)
document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (this.selectedElement) {
this.deleteElement(this.selectedElement.id);
}
}
});
// -------------------------- 元素层级面板缩放事件 --------------------------
// 层级面板缩放手柄 → 调整面板宽度
const panelResizeHandle = document.getElementById('panel-resize-handle');
panelResizeHandle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
const startX = e.clientX;
const startWidth = this.elementListPanel.offsetWidth;
// 鼠标移动 → 计算新宽度并更新
const handleMouseMove = (e) => {
const diffX = e.clientX - startX;
let newWidth = startWidth + diffX;
const minWidth = 150; // 面板最小宽度
if (newWidth < minWidth) newWidth = minWidth;
this.elementListPanel.style.width = `${newWidth}px`;
};
// 鼠标抬起 → 移除事件监听
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
});
// 窗口 resize 事件 → 调整层级面板位置(避免超出窗口)
window.addEventListener('resize', () => {
this.adjustElementListPanelPosition();
});
}
/**
* 初始化侧边栏折叠功能
*/
initSidebarToggle() {
this.sidebarToggle.addEventListener('click', () => {
this.sidebar.classList.toggle('collapsed'); // 切换折叠类
// 切换按钮文本(折叠→箭头,展开→三条杠)
this.sidebarToggle.textContent = this.sidebar.classList.contains('collapsed') ? '→' : '≡';
});
}
/**
* 初始化元素层级面板拖拽功能
*/
initPanelDrag() {
// 面板标题点击 → 开始拖拽
this.panelTitle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isDraggingPanel = true;
const panelRect = this.elementListPanel.getBoundingClientRect(); // 获取面板位置信息
// 计算鼠标相对于面板左上角的偏移量
this.panelOffset.x = e.clientX - panelRect.left;
this.panelOffset.y = e.clientY - panelRect.top;
this.elementListPanel.classList.add('dragging'); // 添加拖拽样式(半透明+阴影)
document.body.style.cursor = 'move'; // 鼠标样式改为移动指针
});
// 鼠标移动 → 更新面板位置
document.addEventListener('mousemove', (e) => {
if (!this.isDraggingPanel) return; // 未拖拽则返回
let newLeft = e.clientX - this.panelOffset.x;
let newTop = e.clientY - this.panelOffset.y;
const panelWidth = this.elementListPanel.offsetWidth;
const panelHeight = this.elementListPanel.offsetHeight;
const screenWidth = window.innerWidth;
const screenHeight = window.innerHeight;
// 限制面板不超出窗口边界
newLeft = Math.max(0, Math.min(newLeft, screenWidth - panelWidth));
newTop = Math.max(0, Math.min(newTop, screenHeight - panelHeight));
// 更新面板位置(取消 right 定位,改用 left+top)
this.elementListPanel.style.left = `${newLeft}px`;
this.elementListPanel.style.top = `${newTop}px`;
this.elementListPanel.style.right = 'auto';
});
// 鼠标抬起 → 结束拖拽
document.addEventListener('mouseup', () => {
if (this.isDraggingPanel) {
this.isDraggingPanel = false;
this.elementListPanel.classList.remove('dragging'); // 移除拖拽样式
document.body.style.cursor = ''; // 恢复默认鼠标样式
}
});
// 面板内部点击(非标题区域)→ 取消拖拽状态
this.elementListPanel.addEventListener('mousedown', (e) => {
if (e.target !== this.panelTitle && !this.panelTitle.contains(e.target)) {
this.isDraggingPanel = false;
}
});
}
/**
* 初始化画布拖拽功能(通过左上角手柄拖拽)
*/
initCanvasDrag() {
// 画布拖拽手柄点击 → 开始拖拽
this.canvasDragHandle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isDraggingCanvas = true;
const containerRect = this.canvasContainer.getBoundingClientRect(); // 获取容器位置
const editorRect = document.querySelector('.editor-area').getBoundingClientRect(); // 获取编辑区位置
// 计算鼠标相对于容器左上角的偏移量
this.canvasOffset.x = e.clientX - containerRect.left;
this.canvasOffset.y = e.clientY - containerRect.top;
this.canvasContainer.style.cursor = 'grabbing'; // 鼠标样式改为抓取
this.canvasDragHandle.style.cursor = 'grabbing';
this.canvasContainer.style.zIndex = '100'; // 提升层级,避免被覆盖
});
// 鼠标移动 → 更新画布位置
document.addEventListener('mousemove', (e) => {
if (!this.isDraggingCanvas) return; // 未拖拽则返回
const editorRect = document.querySelector('.editor-area').getBoundingClientRect();
let newLeft = e.clientX - this.canvasOffset.x - editorRect.left;
let newTop = e.clientY - this.canvasOffset.y - editorRect.top;
const containerWidth = this.canvasContainer.offsetWidth;
const containerHeight = this.canvasContainer.offsetHeight;
const editorWidth = editorRect.width;
const editorHeight = editorRect.height;
// 限制画布不超出编辑区边界(底部留 50px 空白,避免遮挡保存按钮)
newLeft = Math.max(0, Math.min(newLeft, editorWidth - containerWidth));
newTop = Math.max(0, Math.min(newTop, editorHeight - containerHeight - 50));
// 更新画布容器位置(改为绝对定位)
this.canvasContainer.style.position = 'absolute';
this.canvasContainer.style.left = `${newLeft}px`;
this.canvasContainer.style.top = `${newTop}px`;
});
// 鼠标抬起 → 结束拖拽
document.addEventListener('mouseup', () => {
if (this.isDraggingCanvas) {
this.isDraggingCanvas = false;
this.canvasContainer.style.cursor = 'move'; // 恢复移动鼠标样式
this.canvasDragHandle.style.cursor = 'move';
this.canvasContainer.style.zIndex = '1'; // 恢复默认层级
}
});
// 画布内部点击(非手柄区域)→ 取消拖拽状态
this.canvas.addEventListener('mousedown', (e) => {
if (!this.canvasDragHandle.contains(e.target)) {
this.isDraggingCanvas = false;
}
});
}
/**
* 调整元素层级面板位置(窗口 resize 时避免超出窗口)
*/
adjustElementListPanelPosition() {
if (this.isDraggingPanel) return; // 拖拽中不调整
const propertiesPanelRect = this.propertiesPanel.getBoundingClientRect(); // 右侧属性面板位置
const panelWidth = this.elementListPanel.offsetWidth;
// 面板左边界 = 属性面板左边界 - 面板宽度 - 20px(间距)
const newLeft = propertiesPanelRect.left - panelWidth - 20;
// 确保面板不超出左边界
this.elementListPanel.style.left = `${Math.max(0, newLeft)}px`;
this.elementListPanel.style.right = 'auto';
}
/**
* 渲染元素层级列表(同步 elements 数组和 DOM 列表)
*/
renderElementList() {
this.elementList.innerHTML = ''; // 清空现有列表
// 遍历所有元素,生成列表项
this.elements.forEach((element, index) => {
const item = document.createElement('div');
// 列表项样式(选中状态添加 selected 类)
item.className = `element-list-item ${this.selectedElement?.id === element.id ? 'selected' : ''}`;
item.draggable = true; // 支持拖拽排序
item.dataset.id = element.id; // 存储元素 ID(用于后续操作)
item.dataset.index = index; // 存储元素索引
// 1. 元素类型图标(T=文本,I=图片,Q=二维码)
const icon = document.createElement('div');
icon.className = `element-icon ${element.type}-icon`;
icon.textContent = element.type === 'text' ? 'T' : element.type === 'image' ? 'I' : 'Q';
// 2. 元素名称(显示类型+内容/URL,超出隐藏)
const name = document.createElement('div');
name.className = 'element-name';
const displayText = `${element.type === 'text' ? '文本' : element.type === 'image' ? '图片' : '二维码'}: ${element.text || element.src || '未命名'}`;
name.textContent = displayText;
// 3. 元素层级标识(数字越小层级越高)
const layerBadge = document.createElement('div');
layerBadge.className = 'layer-badge';
layerBadge.textContent = element.layer;
// 4. 删除按钮
const deleteBtn = document.createElement('div');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '×';
deleteBtn.title = '删除元素';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止事件冒泡(避免触发列表项点击)
this.deleteElement(element.id);
});
// 5. 拖拽手柄
const handle = document.createElement('div');
handle.className = 'drag-handle';
handle.innerHTML = '⋮⋮'; // 拖拽图标
// 组装列表项
item.append(icon, name, layerBadge, deleteBtn, handle);
// 列表项拖拽事件(排序用)
item.addEventListener('dragstart', (e) => this.handleDragStart(e, index));
item.addEventListener('dragover', (e) => e.preventDefault()); // 允许拖拽放置
item.addEventListener('drop', (e) => this.handleDrop(e, index)); // 放置时触发排序
// 列表项点击 → 选中对应元素
item.addEventListener('click', () => this.handleElementItemClick(element.id));
this.elementList.appendChild(item);
});
}
/**
* 元素层级列表拖拽开始 → 记录拖拽元素索引
* @param {Event} e - 拖拽事件对象
* @param {number} startIndex - 拖拽元素的初始索引
*/
handleDragStart(e, startIndex) {
this.draggingIndex = startIndex;
e.target.classList.add('dragging'); // 添加拖拽样式
}
/**
* 元素层级列表拖拽放置 → 调整元素顺序(更新层级)
* @param {Event} e - 放置事件对象
* @param {number} targetIndex - 放置目标的索引
*/
handleDrop(e, targetIndex) {
e.preventDefault(); // 阻止默认行为
const draggingItem = this.elementList.querySelector('.dragging');
// 校验:无拖拽项/拖拽索引无效/拖拽到自身位置 → 直接返回
if (!draggingItem || this.draggingIndex === -1 || this.draggingIndex === targetIndex) {
if (draggingItem) draggingItem.classList.remove('dragging');
return;
}
// 调整元素数组顺序(从拖拽索引移除,插入到目标索引)
const [movedElement] = this.elements.splice(this.draggingIndex, 1);
this.elements.splice(targetIndex, 0, movedElement);
this.reassignLayers(); // 重新分配层级编号
this.reassignNames(); // 重新分配元素默认名称(如 text_1 → text_2)
draggingItem.classList.remove('dragging'); // 移除拖拽样式
this.updateUI(); // 更新 UI
this.draggingIndex = -1; // 重置拖拽索引
}
/**
* 重新分配元素层级编号(数组顺序决定层级,索引 0 层级最高为 1)
*/
reassignLayers() {
this.elements.forEach((elem, index) => {
elem.layer = index + 1; // 索引 0 → 层级 1(最高),索引 1 → 层级 2,以此类推
});
}
/**
* 重新分配元素默认名称(根据类型和数组索引,如 text_1、image_2)
*/
reassignNames() {
this.elements.forEach((element, index) => {
const newNumber = index + 1;
switch (element.type) {
case 'text':
if (element.text && element.text.match(/{text_\d+}/)) {
element.text = element.text.replace(/{text_\d+}/, `{text_${newNumber}}`);
}
break;
case 'image':
if (element.src && element.src.match(/{image_\d+}/)) {
element.src = element.src.replace(/{image_\d+}/, `{image_${newNumber}}`);
}
break;
case 'qrcode':
if (element.text && element.text.match(/{qr_\d+}/)) {
element.text = element.text.replace(/{qr_\d+}/, `{qr_${newNumber}}`);
}
break;
}
});
this.globalCounter = this.elements.length + 1; // 更新计数器(下一个元素用)
}
/**
* 元素层级列表项点击 → 选中对应元素
* @param {string} elementId - 元素 ID
*/
handleElementItemClick(elementId) {
const element = this.elements.find(el => el.id === elementId);
if (element) this.selectElement(element); // 选中元素
}
/**
* 更新层级控制 UI(禁用/启用上下移按钮,显示当前层级/总层级)
*/
updateLayerUI() {
if (!this.selectedElement) {
document.getElementById('layer-control').style.display = 'none'; // 无选中元素则隐藏
return;
}
document.getElementById('layer-control').style.display = 'block'; // 显示层级控制
// 显示当前层级和总层级
document.getElementById('current-layer').textContent = this.selectedElement.layer;
document.getElementById('total-layers').textContent = this.elements.length;
// 禁用/启用按钮(层级 1 则无法置顶/上移,层级等于总长度则无法置底/下移)
document.getElementById('btn-bring-to-front').disabled = this.selectedElement.layer === 1;
document.getElementById('btn-move-up').disabled = this.selectedElement.layer === 1;
document.getElementById('btn-move-down').disabled = this.selectedElement.layer === this.elements.length;
document.getElementById('btn-send-to-back').disabled = this.selectedElement.layer === this.elements.length;
}
/**
* 元素置顶 → 层级设为 1(最高)
*/
bringToFront() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === 1) return;
this.selectedElement.layer = 1;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素置底 → 层级设为总元素数(最低)
*/
sendToBack() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer === this.elements.length) return;
this.selectedElement.layer = this.elements.length;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素上移 → 层级减 1
*/
moveUp() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer <= 1) return;
this.selectedElement.layer--;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 元素下移 → 层级加 1
*/
moveDown() {
if (!this.selectedElement || this.elements.length <= 1 || this.selectedElement.layer >= this.elements.length) return;
this.selectedElement.layer++;
this.sortElementsByLayer(); // 按层级排序
this.updateUI(); // 更新 UI
}
/**
* 按层级排序元素数组(层级 1 在前,层级越高越靠后)
*/
sortElementsByLayer() {
this.elements.sort((a, b) => a.layer - b.layer);
}
/**
* 添加新元素(文本/图片/二维码)
* @param {string} type - 元素类型(text/image/qrcode)
*/
addElement(type) {
// 计算新元素的层级(现有最高层级 + 1)
const newLayer = this.elements.length > 0 ? Math.max(...this.elements.map(e => e.layer)) + 1 : 1;
// 基础元素配置(所有元素通用属性)
const element = {
id: Date.now().toString(), // 用时间戳作为唯一 ID
type,
x: 50, // 默认 X 坐标
y: 50, // 默认 Y 坐标
width: 100, // 默认宽度
height: 100, // 默认高度
layer: newLayer,
containerBackgroundColor: this.defaultContainerColors[type] // 默认容器颜色
};
// 根据类型添加专属属性
switch (type) {
case 'text':
element.text = `文字{text_${this.globalCounter}}`; // 默认文本内容
element.color = '#000000'; // 默认文本颜色(黑色)
element.fontSize = 16; // 默认字体大小
element.align = 'left'; // 默认对齐方式(左对齐)
element.overflow = 'wrap'; // 默认超出处理(自动换行)
break;
case 'image':
element.src = `{image_${this.globalCounter}}`; // 默认图片 URL 占位符
element.borderColor = 'rgba(255,255,255,0)'; // 默认边框颜色(透明)
element.borderRadius = 0; // 默认圆角(0px)
break;
case 'qrcode':
element.text = `{qr_${this.globalCounter}}`; // 默认二维码内容
break;
}
this.elements.push(element); // 添加到元素数组
this.globalCounter++; // 计数器自增(下一个元素用)
this.sortElementsByLayer(); // 按层级排序
this.selectElement(element); // 自动选中新添加的元素
this.updateUI(); // 更新 UI
}
/**
* 选中元素(更新 selectedElement 并同步 UI)
* @param {object} element - 要选中的元素
*/
selectElement(element) {
this.selectedElement = element;
this.updateUI(); // 更新 UI(属性面板、元素选中框等)
}
/**
* 更新右侧属性面板(根据选中元素类型显示/隐藏专属属性)
*/
updatePropertiesPanel() {
const panel = document.getElementById('element-properties');
if (!this.selectedElement) {
panel.style.display = 'none'; // 无选中元素则隐藏面板
return;
}
panel.style.display = 'block'; // 显示面板
// 1. 更新通用属性(坐标、宽高)
document.getElementById('element-x').value = this.selectedElement.x || 0;
document.getElementById('element-y').value = this.selectedElement.y || 0;
document.getElementById('element-width-slider').value = this.selectedElement.width || 100;
document.getElementById('element-width-value').value = this.selectedElement.width || 100;
document.getElementById('element-height-slider').value = this.selectedElement.height || 100;
document.getElementById('element-height-value').value = this.selectedElement.height || 100;
// 2. 隐藏所有属性项(后续按需显示)
document.querySelectorAll('[id$="-item"]').forEach(item => item.style.display = 'none');
document.getElementById('delete-element-item').style.display = 'block'; // 显示删除按钮
// 3. 根据元素类型显示专属属性项
if (this.selectedElement.type === 'text') {
// 文本元素专属属性:内容、颜色、字体大小、对齐方式、超出处理、容器背景
['text-content-item', 'text-color-item', 'text-font-size-item', 'text-align-item', 'text-overflow-item', 'container-bg-color-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步文本属性值到 UI
document.getElementById('element-text').value = this.selectedElement.text || '';
document.getElementById('text-color-input').value = this.selectedElement.color || '#000000';
document.getElementById('text-color').style.backgroundColor = this.selectedElement.color || '#000000';
document.getElementById('text-font-size-slider').value = this.selectedElement.fontSize || 16;
document.getElementById('text-font-size-value').value = this.selectedElement.fontSize || 16;
document.getElementById('text-align').value = this.selectedElement.align || 'left';
document.getElementById('text-overflow').value = this.selectedElement.overflow || 'wrap';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.text;
} else if (this.selectedElement.type === 'image') {
// 图片元素专属属性:URL、边框颜色、容器背景、圆角
['image-url-item', 'border-color-item', 'container-bg-color-item', 'image-border-radius-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步图片属性值到 UI
document.getElementById('image-url').value = this.selectedElement.src || '';
document.getElementById('border-color-input').value = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
document.getElementById('border-color').style.backgroundColor = this.selectedElement.borderColor || 'rgba(255,255,255,0)';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.image;
// 同步圆角
document.getElementById('image-border-radius-slider').value = this.selectedElement.borderRadius || 0;
document.getElementById('image-border-radius-value').value = this.selectedElement.borderRadius || 0;
} else if (this.selectedElement.type === 'qrcode') {
// 二维码元素专属属性:内容、容器背景
['qrcode-url-item', 'container-bg-color-item'].forEach(id => {
document.getElementById(id).style.display = 'block';
});
// 同步二维码属性值到 UI
document.getElementById('qrcode-url').value = this.selectedElement.text || '';
// 同步容器背景色
document.getElementById('container-bg-color-input').value = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
document.getElementById('container-bg-color').style.backgroundColor = this.selectedElement.containerBackgroundColor || this.defaultContainerColors.qrcode;
}
this.updateLayerUI(); // 更新层级控制 UI
}
/**
* 更新元素选中框(覆盖层,显示缩放手柄)
*/
updateElementOverlay() {
const overlay = document.getElementById('element-overlay');
if (!this.selectedElement || this.isCanvasResizing) {
overlay.classList.add('hidden'); // 无选中元素/画布缩放时隐藏
return;
}
// 1. 设置选中框位置和大小(与元素一致)
overlay.style.cssText = `left: ${this.selectedElement.x}px; top: ${this.selectedElement.y}px; width: ${this.selectedElement.width}px; height: ${this.selectedElement.height}px;`;
overlay.classList.remove('hidden'); // 显示选中框
// 2. 移除现有缩放手柄(避免重复)
overlay.querySelectorAll('.resize-handle').forEach(handle => handle.remove());
// 3. 添加四个方向的缩放手柄(nw/ne/sw/se)
['nw', 'ne', 'sw', 'se'].forEach(dir => {
const handle = document.createElement('div');
handle.className = `resize-handle resize-${dir}`;
// 手柄点击 → 开始缩放元素
handle.addEventListener('mousedown', (e) => {
e.stopPropagation(); // 阻止事件冒泡
this.isResizing = true;
this.resizeDirection = dir; // 记录缩放方向
});
overlay.appendChild(handle);
});
}
/**
* 画布点击事件处理 → 选中元素/取消选中
* @param {Event} e - 鼠标点击事件
*/
handleMouseDown(e) {
// 过滤:画布缩放/面板拖拽/画布拖拽时不处理
if (this.isCanvasResizing || this.isDraggingPanel || this.isDraggingCanvas) return;
const rect = this.canvas.getBoundingClientRect();
// 计算鼠标在画布坐标系中的位置(相对于画布左上角)
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 点击缩放手柄则不处理(交给缩放逻辑)
if (this.selectedElement && Array.from(document.querySelectorAll('.resize-handle')).some(h => h.contains(e.target))) {
return;
}
// 查找点击位置的元素(倒序遍历,优先选中上层元素)
const clickedElement = [...this.elements].reverse().find(el => this.isPointInElement(x, y, el));
if (clickedElement) {
this.selectElement(clickedElement); // 选中点击的元素
this.isDragging = true; // 标记为拖拽状态
// 计算拖拽偏移量(鼠标相对于元素左上角的位置)
this.dragOffset.x = x - clickedElement.x;
this.dragOffset.y = y - clickedElement.y;
} else {
this.selectedElement = null; // 未点击到元素 → 取消选中
this.updateUI(); // 更新 UI
}
}
/**
* 鼠标移动事件处理 → 拖拽元素/缩放元素/缩放画布
* @param {Event} e - 鼠标移动事件
*/
handleMouseMove(e) {
// 1. 画布缩放逻辑
if (this.isCanvasResizing) {
const rect = this.canvas.getBoundingClientRect();
const newWidth = e.clientX - rect.left; // 新宽度 = 鼠标X - 画布左边界
const newHeight = e.clientY - rect.top; // 新高度 = 鼠标Y - 画布上边界
// 限制画布大小不小于最小值
if (newWidth > this.canvasMinWidth && newHeight > this.canvasMinHeight) {
this.canvas.width = newWidth;
this.canvas.height = newHeight;
// 同步更新输入框值
document.getElementById('canvas-width').value = newWidth;
document.getElementById('canvas-height').value = newHeight;
this.render(); // 重新渲染画布
}
return;
}
// 2. 未拖拽/缩放则返回
if (!this.isDragging && !this.isResizing) return;
const rect = this.canvas.getBoundingClientRect();
// 计算鼠标在画布坐标系中的位置
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
// 3. 元素拖拽逻辑
if (this.isDragging && this.selectedElement) {
// 元素新坐标 = 鼠标位置 - 拖拽偏移量(避免点击时跳动)
this.selectedElement.x = x - this.dragOffset.x;
this.selectedElement.y = y - this.dragOffset.y;
// 同步更新输入框值
document.getElementById('element-x').value = this.selectedElement.x;
document.getElementById('element-y').value = this.selectedElement.y;
this.updateElementOverlay(); // 更新选中框
this.render(); // 重新渲染画布
}
// 4. 元素缩放逻辑
else if (this.isResizing && this.selectedElement) {
const minSize = 10; // 元素最小宽高
let newX = this.selectedElement.x, newY = this.selectedElement.y;
let newWidth = this.selectedElement.width, newHeight = this.selectedElement.height;
// 根据缩放方向计算新坐标和宽高
switch (this.resizeDirection) {
case 'nw': // 左上 → 同时调整X、Y、宽度、高度
newX = x;
newY = y;
newWidth = this.selectedElement.x + this.selectedElement.width - x;
newHeight = this.selectedElement.y + this.selectedElement.height - y;
break;
case 'ne': // 右上 → 调整Y、宽度、高度
newY = y;
newWidth = x - this.selectedElement.x;
newHeight = this.selectedElement.y + this.selectedElement.height - y;
break;
case 'sw': // 左下 → 调整X、宽度、高度
newX = x;
newWidth = this.selectedElement.x + this.selectedElement.width - x;
newHeight = y - this.selectedElement.y;
break;
case 'se': // 右下 → 仅调整宽度、高度
newWidth = x - this.selectedElement.x;
newHeight = y - this.selectedElement.y;
break;
}
// 限制元素大小不小于最小值
if (newWidth >= minSize && newHeight >= minSize) {
// 更新元素属性
Object.assign(this.selectedElement, {x: newX, y: newY, width: newWidth, height: newHeight});
// 同步更新输入框/滑块值
document.getElementById('element-x').value = newX;
document.getElementById('element-y').value = newY;
document.getElementById('element-width-slider').value = newWidth;
document.getElementById('element-width-value').value = newWidth;
document.getElementById('element-height-slider').value = newHeight;
document.getElementById('element-height-value').value = newHeight;
this.updateElementOverlay(); // 更新选中框
this.render(); // 重新渲染画布
}
}
}
/**
* 鼠标抬起/离开事件处理 → 结束拖拽/缩放
*/
handleMouseUp() {
// 重置所有拖拽/缩放状态
this.isDragging = this.isResizing = this.isCanvasResizing = false;
this.resizeDirection = this.draggingIndex = -1; // 重置方向和拖拽索引
this.updateElementOverlay(); // 更新选中框(恢复正常状态)
this.updateJSONPreview(); // 更新 JSON 预览(同步最新数据)
}
/**
* 判断点是否在元素内(用于点击选中元素)
* @param {number} x - 点的 X 坐标(画布坐标系)
* @param {number} y - 点的 Y 坐标(画布坐标系)
* @param {object} element - 要判断的元素
* @returns {boolean} 是否在元素内
*/
isPointInElement(x, y, element) {
// 元素矩形区域:x ≤ 点X ≤ x+宽度,y ≤ 点Y ≤ y+高度
return x >= element.x && x <= element.x + element.width && y >= element.y && y <= element.y + element.height;
}
/**
* 核心渲染方法 → 绘制画布背景和所有元素
*/
render() {
// 1. 清空画布(避免残影)
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
// 2. 绘制画布背景(从颜色选择器获取)
const bgColor = document.getElementById('canvas-bg-color-input').value;
this.ctx.fillStyle = bgColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
// 3. 绘制所有元素(倒序遍历,上层元素后绘制,覆盖下层)
[...this.elements].reverse().forEach(element => {
// 绘制元素容器背景(区分元素类型的默认颜色)
const containerBg = element.containerBackgroundColor || this.defaultContainerColors[element.type];
this.ctx.fillStyle = containerBg;
this.ctx.beginPath();
// 图片元素支持圆角容器
if (element.type === 'image' && element.borderRadius > 0) {
this.ctx.roundRect(element.x, element.y, element.width, element.height, element.borderRadius);
} else {
this.ctx.rect(element.x, element.y, element.width, element.height); // 矩形容器
}
this.ctx.fill(); // 填充容器背景
// 图片元素绘制边框
if (element.type === 'image') {
const borderColor = element.borderColor || 'rgba(255,255,255,0)';
this.ctx.strokeStyle = borderColor;
this.ctx.lineWidth = 1;
this.ctx.stroke(); // 绘制边框
}
// 绘制元素内容(文本/图片/二维码)
this.renderElement(element);
});
}
/**
* 绘制单个元素内容(根据类型调用不同绘制方法)
* @param {object} element - 要绘制的元素
*/
renderElement(element) {
switch (element.type) {
case 'text':
this.renderTextElement(element); // 绘制文本
break;
case 'image':
this.renderImageElement(element); // 绘制图片
break;
case 'qrcode':
this.renderQrCodeElement(element); // 绘制二维码
break;
}
}
/**
* 绘制文本元素(支持换行、对齐、超出省略)
* @param {object} element - 文本元素
*/
renderTextElement(element) {
this.ctx.fillStyle = element.color || '#000000'; // 文本颜色
this.ctx.font = `${element.fontSize || 16}px Arial`; // 字体大小和字体族
const text = element.text || '';
const maxWidth = element.width; // 文本最大宽度(元素宽度)
const lineHeight = (element.fontSize || 16) * 1.2; // 行高(字体大小的 1.2 倍)
const x = element.x; // 文本起始 X 坐标
const y = element.y + (element.fontSize || 16); // 文本起始 Y 坐标(向下偏移一个字体大小)
// 处理文本超出:换行/省略
let lines = element.overflow === 'wrap' ? this.wrapText(text, maxWidth) : [this.getEllipsisText(text, maxWidth)];
const totalHeight = lines.length * lineHeight; // 文本总高度
// 绘制每一行文本
lines.forEach((line, index) => {
let drawX = x; // 每行文本的 X 坐标
// 根据对齐方式调整 X 坐标
if (element.align === 'center') {
// 居中:X = 元素X + (元素宽度 - 该行文本宽度)/2
drawX = x + (maxWidth - this.ctx.measureText(line).width) / 2;
} else if (element.align === 'right') {
// 右对齐:X = 元素X + 元素宽度 - 该行文本宽度
drawX = x + maxWidth - this.ctx.measureText(line).width;
}
// 垂直居中:Y = 起始Y - (总高度 - 行高)/2 + 行索引*行高
const drawY = y - (totalHeight - lineHeight) / 2 + index * lineHeight;
this.ctx.fillText(line, drawX, drawY); // 绘制文本
});
}
/**
* 文本换行处理(按空格分割,避免单词截断)
* @param {string} text - 要处理的文本
* @param {number} maxWidth - 最大宽度(元素宽度)
* @returns {array} 换行后的文本行数组
*/
wrapText(text, maxWidth) {
// 按空格分割单词
return text.split(' ').reduce((acc, word) => {
const lastLine = acc[acc.length - 1] || ''; // 最后一行文本
const testLine = lastLine ? `${lastLine} ${word}` : word; // 测试添加当前单词后的行
// 如果添加后宽度不超过最大宽度 → 更新最后一行
if (this.ctx.measureText(testLine).width <= maxWidth && lastLine) {
acc[acc.length - 1] = testLine;
} else {
acc.push(word); // 超过则新增一行
}
return acc;
}, []);
}
/**
* 文本超出省略处理(末尾添加 ...)
* @param {string} text - 要处理的文本
* @param {number} maxWidth - 最大宽度(元素宽度)
* @returns {string} 省略后的文本
*/
getEllipsisText(text, maxWidth) {
// 文本宽度未超出则直接返回
if (this.ctx.measureText(text).width <= maxWidth) return text;
let truncated = text;
// 逐字删除,直到添加 ... 后宽度不超出
while (this.ctx.measureText(truncated + '...').width > maxWidth && truncated.length > 0) {
truncated = truncated.slice(0, -1);
}
return truncated + '...'; // 返回省略后的文本
}
/**
* 绘制图片元素(支持网络图片、占位符、圆角裁剪)
* @param {object} element - 图片元素
*/
renderImageElement(element) {
// 网络图片(以 http/https 开头)
if (element.src && element.src.startsWith('http')) {
const img = new Image();
img.crossOrigin = 'anonymous'; // 解决跨域图片绘制问题
const renderProps = {...element}; // 保存当前元素属性(避免异步绘制时属性变化)
// 图片加载成功 → 绘制图片(支持圆角裁剪)
img.onload = () => {
this.ctx.save(); // 保存当前绘图状态
// 圆角裁剪(圆角半径 > 0 时)
if (renderProps.borderRadius > 0) {
this.ctx.beginPath();
this.ctx.roundRect(renderProps.x, renderProps.y, renderProps.width, renderProps.height, renderProps.borderRadius);
this.ctx.clip(); // 裁剪成圆角
}
// 绘制图片(铺满元素区域)
this.ctx.drawImage(img, renderProps.x, renderProps.y, renderProps.width, renderProps.height);
this.ctx.restore(); // 恢复绘图状态
};
// 图片加载失败 → 绘制占位符
img.onerror = () => this.renderImagePlaceholder(renderProps);
img.src = element.src; // 设置图片 URL
} else {
// 非网络图片 → 绘制占位符
this.renderImagePlaceholder(element);
}
}
/**
* 绘制图片占位符(显示文本提示)
* @param {object} props - 图片元素属性
*/
renderImagePlaceholder(props) {
this.ctx.fillStyle = '#999'; // 占位符文本颜色
this.ctx.font = '12px Arial'; // 字体大小
this.ctx.textAlign = 'center'; // 水平居中
this.ctx.textBaseline = 'middle'; // 垂直居中
// 显示提示文本(优先显示 text,无则显示 src,再无则显示默认提示)
const placeholderText = props.text || props.src || '图片占位符';
this.ctx.fillText(placeholderText, props.x + props.width / 2, props.y + props.height / 2);
// 恢复默认文本对齐方式(避免影响其他绘制)
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'alphabetic';
}
/**
* 绘制二维码占位符(简化版,实际项目可集成 qrcode.js 生成真实二维码)
* @param {object} element - 二维码元素
*/
renderQrCodeElement(element) {
// 绘制二维码外框(黑色)
this.ctx.fillStyle = '#000000';
this.ctx.fillRect(element.x + 5, element.y + 5, element.width - 10, element.height - 10);
// 绘制二维码定位点(三个角的白色方块)
const posSize = (element.width - 10) / 7; // 定位点大小
this.ctx.fillStyle = '#ffffff';
this.ctx.fillRect(element.x + 8, element.y + 8, posSize, posSize); // 左上定位点
this.ctx.fillRect(element.x + element.width - posSize - 8, element.y + 8, posSize, posSize); // 右上定位点
this.ctx.fillRect(element.x + 8, element.y + element.height - posSize - 8, posSize, posSize); // 左下定位点
// 绘制二维码内容提示(居中)
this.ctx.font = '10px Arial';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
const qrText = element.text || '二维码内容';
this.ctx.fillText(qrText, element.x + element.width / 2, element.y + element.height / 2);
// 恢复默认文本对齐方式
this.ctx.textAlign = 'left';
this.ctx.textBaseline = 'alphabetic';
}
/**
* 更新 JSON 预览区域(显示当前海报的完整数据)
*/
updateJSONPreview() {
const jsonData = {
id: document.getElementById('poster-id').value || '', // 新增:包含海报ID
name: document.getElementById('json-name').value.trim() || '未命名海报', // 包含名称
materials: {
width: this.canvas.width, // 画布宽度
height: this.canvas.height, // 画布高度
backgroundColor: document.getElementById('canvas-bg-color-input').value, // 画布背景色
elements: this.elements.map(element => {
const elemCopy = {...element};
delete elemCopy.id; // 移除前端临时 ID(后端不需要)
return elemCopy;
})
}
};
// 格式化 JSON 并显示(缩进 2 空格,便于阅读)
document.getElementById('json-preview').textContent = JSON.stringify(jsonData, null, 2);
}
/**
* 保存 JSON 到后端(支持新增/编辑,自动携带 ID)
* 数据格式:{ id: '海报ID', name: '海报名称', materials: { 画布+元素数据 } }
*/
saveJSON() {
if (typeof $ === 'undefined') {
alert('错误:未检测到 jQuery 库。请先引入 jQuery 才能使用保存功能。');
return;
}
// 1. 获取核心数据
const posterId = document.getElementById('poster-id').value.trim(); // 获取隐藏域的 ID
const posterName = document.getElementById('json-name').value.trim() || '未命名海报';
// 2. 构造请求体(包含 ID,新增时为空字符串)
const requestData = {
id: posterId, // 关键:编辑时携带 ID,新增时为空
name: posterName,
materials: {
width: this.canvas.width,
height: this.canvas.height,
backgroundColor: document.getElementById('canvas-bg-color-input').value,
elements: this.elements.map(element => {
const elemCopy = {...element};
delete elemCopy.id; // 移除前端临时 ID
return elemCopy;
})
}
};
// 3. 接口地址(用户可根据后端逻辑调整,新增/编辑可共用一个接口)
const apiUrl = "{:url('add_post')}"; // 后端统一接收接口(支持新增/编辑)
// 4. 发送 AJAX 请求
$.ajax({
url: apiUrl,
type: 'POST',
contentType: 'application/json',
data: JSON.stringify(requestData),
success: function (response) {
console.log('保存成功:', response);
// 显示成功提示
const successMsg = response.msg || (posterId ? `海报 "${posterName}" 已成功更新!` : `海报 "${posterName}" 已成功创建!`);
parent.layer.msg(successMsg, {icon: 1});
// 延迟关闭弹框并刷新父页面
setTimeout(function () {
parent.layer.closeAll();
parent.location.reload();
}, 500);
// 如果是新增,后端返回新 ID 后更新隐藏域(可选)
if (!posterId && response.data?.id) {
document.getElementById('poster-id').value = response.data.id;
}
},
error: function (jqXHR, textStatus, errorThrown) {
console.error('保存失败:', textStatus, errorThrown);
parent.layer.msg('保存失败:' + (errorThrown || '网络异常'), {icon: 2});
}
});
}
/**
* 删除元素(根据 ID 从 elements 数组中移除)
* @param {string} elementId - 要删除的元素 ID
*/
deleteElement(elementId) {
const index = this.elements.findIndex(el => el.id === elementId); // 查找元素索引
if (index !== -1) {
this.elements.splice(index, 1); // 从数组中移除
this.reassignLayers(); // 重新分配层级
this.reassignNames(); // 重新分配元素名称
this.selectedElement = null; // 取消选中(删除的元素已不存在)
this.updateUI(); // 更新 UI
}
}
/**
* 统一更新 UI(调用所有需要刷新的方法,避免重复代码)
*/
updateUI() {
this.render(); // 渲染画布
this.renderElementList(); // 渲染元素层级列表
this.updatePropertiesPanel(); // 更新属性面板
this.updateElementOverlay(); // 更新元素选中框
this.updateJSONPreview(); // 更新 JSON 预览
this.adjustElementListPanelPosition(); // 调整层级面板位置
}
}
// 页面 DOM 加载完成后初始化编辑器
document.addEventListener('DOMContentLoaded', () => {
new PosterEditor('poster-canvas'); // 传入画布 ID 初始化
});
</script>
前端css代码
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: #f5f7fa;
color: #333;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.toolbar {
display: flex;
gap: 10px;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #409EFF;
color: white;
}
.btn-default {
background: #f4f4f5;
color: #606266;
border: 1px solid #dcdfe6;
}
.btn-default:disabled {
background: #ebeef5;
color: #c0c4cc;
cursor: not-allowed;
}
.container {
display: flex;
flex: 1;
overflow: hidden;
}
.sidebar {
width: 240px;
background: white;
padding: 16px;
overflow-y: auto;
border-right: 1px solid #dcdfe6;
transition: width 0.3s ease;
position: relative;
flex-shrink: 0;
}
.sidebar.collapsed {
width: 40px;
}
.sidebar-toggle {
position: absolute;
top: 10px;
right: 10px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
cursor: pointer;
background: #f4f4f5;
border-radius: 4px;
z-index: 10;
transition: transform 0.3s ease;
user-select: none;
}
.sidebar.collapsed .sidebar-toggle {
transform: rotate(90deg);
}
.sidebar.collapsed .component-list {
display: none;
}
.component-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 40px;
}
.component-item {
padding: 12px;
background: #f9f9f9;
border-radius: 4px;
cursor: move;
text-align: center;
border: 1px dashed #dcdfe6;
transition: all 0.2s;
}
.component-item:hover {
border-color: #409EFF;
color: #409EFF;
}
.editor-area {
flex: 1;
padding: 40px 20px;
overflow: auto;
display: flex;
justify-content: center;
align-items: flex-start;
position: relative; /* 作为保存按钮和输入框的定位父容器 */
}
.canvas-container {
position: relative;
background: white;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow: hidden;
cursor: move;
margin-top: 20px;
margin-right: 40%;
}
#poster-canvas {
display: block;
background: white;
}
.canvas-drag-handle {
position: absolute;
top: -12px;
left: -12px;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
background: rgba(64, 158, 255, 0.9);
color: white;
border-radius: 50%;
cursor: move;
z-index: 1000;
user-select: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border: 2px solid white;
}
.canvas-drag-handle:hover {
background: #409EFF;
}
.element-overlay {
position: absolute;
border: 1px dashed #409EFF;
pointer-events: none;
}
.properties-panel {
width: 280px;
background: white;
padding: 16px;
overflow-y: auto;
border-left: 1px solid #dcdfe6;
position: fixed;
top: 0;
bottom: 0;
right: 0;
transition: right 0.1s ease;
z-index: 90;
display: flex;
flex-direction: column;
}
/* --- 新增的 CSS 样式 --- */
/* 包裹名称输入框的容器 */
.json-name-input {
position: absolute;
bottom: 110px; /* 在保存按钮上方 */
right: 310px; /* 与保存按钮对齐 */
display: flex;
align-items: center;
gap: 10px; /* 标签和输入框之间的间距 */
width: 100%;
max-width: 300px; /* 限制最大宽度 */
z-index: 100;
}
/* 名称标签 */
.json-name-input label {
font-size: 14px;
color: #606266;
white-space: nowrap; /* 防止标签文字换行 */
}
/* 名称输入框 */
.json-name-input input[type="text"] {
flex: 1; /* 让输入框占据剩余空间 */
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
transition: border-color 0.3s ease;
}
/* 输入框聚焦时的样式 */
.json-name-input input[type="text"]:focus {
outline: none;
border-color: #409EFF; /* 聚焦时边框变色,与主题一致 */
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
/* --- CSS 样式结束 --- */
/* 关键改动:调整保存按钮的 bottom 值,为输入框留出空间 */
#save-json-btn {
position: absolute;
bottom: 70px; /* 从 30px 调整到 70px */
right: 310px; /* 画布属性面板宽度280px + 间距30px */
width: 120px;
padding: 10px;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
background-color: #409EFF;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
transform: none;
}
#save-json-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: #3689e6;
}
.property-group {
margin-bottom: 15px;
}
.property-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 10px;
color: #606266;
}
.property-item {
margin-bottom: 12px;
}
.property-label {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.property-input, .property-select {
width: 100%;
padding: 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 14px;
}
.property-slider {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
}
.property-slider input[type="range"] {
flex: 1;
height: 6px;
border-radius: 3px;
background: #e6e6e6;
outline: none;
-webkit-appearance: none;
}
.property-slider input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #409EFF;
cursor: pointer;
border: none;
}
.property-slider .slider-value {
width: 40px;
text-align: center;
border: 1px solid #dcdfe6;
border-radius: 4px;
padding: 4px 0;
font-size: 12px;
}
.color-picker {
display: flex;
align-items: center;
}
.color-preview {
width: 24px;
height: 24px;
border-radius: 4px;
margin-right: 8px;
border: 1px solid #dcdfe6;
}
.json-preview {
background: #2d2d2d;
color: #f8f8f2;
padding: 12px;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
margin-top: auto;
}
.hidden {
display: none;
}
.resize-handle {
position: absolute;
width: 8px;
height: 8px;
background: #409EFF;
border: 1px solid white;
border-radius: 50%;
z-index: 10;
pointer-events: all;
cursor: pointer;
}
.resize-nw {
top: -4px;
left: -4px;
cursor: nw-resize;
}
.resize-ne {
top: -4px;
right: -4px;
cursor: ne-resize;
}
.resize-sw {
bottom: -4px;
left: -4px;
cursor: sw-resize;
}
.resize-se {
bottom: -4px;
right: -4px;
cursor: se-resize;
}
.canvas-resize-handle {
width: 12px;
height: 12px;
bottom: -6px;
right: -6px;
box-shadow: 0 0 0 1px #409EFF, 0 2px 4px rgba(0, 0, 0, 0.2);
}
.layer-control {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #e6e6e6;
}
.layer-control .btn-group {
display: flex;
gap: 5px;
margin-top: 8px;
}
.layer-control .btn-group .btn {
flex: 1;
padding: 6px;
font-size: 12px;
}
.layer-control .layer-info {
font-size: 12px;
color: #606266;
margin-bottom: 8px;
}
.element-list-panel {
position: fixed;
right: 300px;
top: 20px;
background-color: white;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
border-radius: 4px;
min-width: 150px;
width: 300px; /* 加宽默认宽度 */
padding: 12px;
z-index: 100;
max-height: calc(100vh - 220px);
display: flex;
flex-direction: column;
border: 1px solid #dcdfe6;
cursor: move;
transition: box-shadow 0.2s ease;
}
.element-list-panel:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.element-list-panel.dragging {
opacity: 0.8;
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2);
}
.panel-title {
font-size: 14px;
font-weight: bold;
margin-bottom: 8px;
color: #606266;
cursor: move;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}
.panel-title::before {
content: '☰';
font-size: 12px;
color: #909399;
}
.element-list {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 100px);
margin-bottom: 10px;
cursor: default;
}
.element-list-item {
padding: 8px 10px;
margin-bottom: 6px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: move;
font-size: 13px;
transition: all 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 8px;
}
.element-list-item:hover {
border-color: #409EFF;
background-color: #f5faff;
}
.element-list-item.selected {
border-color: #409EFF;
background-color: #e8f4ff;
color: #409EFF;
}
.element-list-item.dragging {
opacity: 0.5;
background-color: #f0f0f0;
}
.element-icon {
width: 16px;
height: 16px;
border-radius: 2px;
margin-right: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: white;
}
.text-icon {
background: #67c23a; /* 文本:绿色 */
}
.image-icon {
background: #e6a23c; /* 图片:橙色 */
}
.qrcode-icon {
background: #f56c6c; /* 二维码:红色 */
}
.element-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
}
.layer-badge {
background-color: #409EFF;
color: white;
font-size: 10px;
padding: 1px 4px;
border-radius: 4px;
margin-left: auto;
}
.delete-btn {
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
border-radius: 50%;
background-color: #f56c6c;
color: white;
font-size: 12px;
cursor: pointer;
margin-left: 5px;
transition: background-color 0.2s;
}
.delete-btn:hover {
background-color: #e4393c;
}
.drag-handle {
color: #c0c4cc;
cursor: move;
margin-left: 5px;
}
.panel-resize-handle {
position: absolute;
top: 0;
right: 0;
width: 6px;
height: 100%;
cursor: ew-resize;
z-index: 101;
background-color: transparent;
}
.panel-resize-handle:hover {
background-color: rgba(64, 158, 255, 0.1);
}
#delete-element-btn {
width: 100%;
background-color: #f56c6c;
color: white;
border: none;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.3s;
}
#delete-element-btn:hover {
background-color: #e4393c;
}
后台数据库
ps:我这有写好的例子
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for cmf_poster
-- ----------------------------
DROP TABLE IF EXISTS `cmf_poster`;
CREATE TABLE `cmf_poster` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '名称',
`materials` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL COMMENT '海报图内容',
`create_time` bigint(20) NULL DEFAULT NULL COMMENT '创建时间',
`update_time` bigint(20) NULL DEFAULT NULL COMMENT '更新时间',
`delete_time` bigint(20) NULL DEFAULT 0 COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 7 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '海报图管理' ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of cmf_poster
-- ----------------------------
INSERT INTO `cmf_poster` VALUES (4, '测试', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"50\",\"y\":\"50\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"239\",\"y\":\"200\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"2\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_2}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"qrcode\",\"x\":\"86\",\"y\":\"336\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"3\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_3}\"}]}', 1763798735, NULL, 0);
INSERT INTO `cmf_poster` VALUES (5, '888', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"69\",\"y\":\"32\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"198\",\"y\":\"522\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"2\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_2}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"qrcode\",\"x\":\"38\",\"y\":\"152\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"3\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_3}\"},{\"type\":\"qrcode\",\"x\":\"231\",\"y\":\"39\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"4\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_4}\"},{\"type\":\"qrcode\",\"x\":\"71\",\"y\":\"542\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"5\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_5}\"},{\"type\":\"image\",\"x\":\"284\",\"y\":\"255\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"6\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_6}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"text\",\"x\":\"277\",\"y\":\"397\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"7\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_7}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"74\",\"y\":\"341\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"8\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_8}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"}]}', 1763799146, NULL, 0);
INSERT INTO `cmf_poster` VALUES (6, '测试全屏444888', '{\"width\":\"422\",\"height\":\"750\",\"backgroundColor\":\"#ffffff\",\"elements\":[{\"type\":\"text\",\"x\":\"157\",\"y\":\"8\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"1\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_1}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"29\",\"y\":\"114\",\"width\":\"114\",\"height\":\"39\",\"layer\":\"2\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_2}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"294\",\"y\":\"476\",\"width\":\"126\",\"height\":\"27\",\"layer\":\"3\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_3}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"199\",\"y\":\"129\",\"width\":\"45\",\"height\":\"45\",\"layer\":\"4\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_4}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"48\"},{\"type\":\"image\",\"x\":\"33\",\"y\":\"4\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"5\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_5}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"31\"},{\"type\":\"image\",\"x\":\"287\",\"y\":\"364\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"6\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_6}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"34\"},{\"type\":\"qrcode\",\"x\":\"144\",\"y\":\"325\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"7\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_7}\"},{\"type\":\"text\",\"x\":\"25\",\"y\":\"320\",\"width\":\"109\",\"height\":\"169\",\"layer\":\"8\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_8}\",\"color\":\"#000000\",\"fontSize\":\"12\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"7\",\"y\":\"534\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"9\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_9}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"0\"},{\"type\":\"text\",\"x\":\"116\",\"y\":\"532\",\"width\":\"291\",\"height\":\"70\",\"layer\":\"10\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_10}\",\"color\":\"#3818d8\",\"fontSize\":\"11\",\"align\":\"center\",\"overflow\":\"wrap\"},{\"type\":\"text\",\"x\":\"119\",\"y\":\"611\",\"width\":\"284\",\"height\":\"22\",\"layer\":\"11\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_11}\",\"color\":\"#f93e3e\",\"fontSize\":\"11\",\"align\":\"right\",\"overflow\":\"ellipsis\"},{\"type\":\"qrcode\",\"x\":\"308\",\"y\":\"641\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"12\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_12}\"},{\"type\":\"text\",\"x\":\"262\",\"y\":\"233\",\"width\":\"127\",\"height\":\"20\",\"layer\":\"13\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_13}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"149\",\"y\":\"640\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"14\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_14}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"2\"},{\"type\":\"qrcode\",\"x\":\"146\",\"y\":\"204\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"15\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_15}\"},{\"type\":\"text\",\"x\":\"17\",\"y\":\"497\",\"width\":\"118\",\"height\":\"29\",\"layer\":\"16\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_16}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"qrcode\",\"x\":\"32\",\"y\":\"212\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"17\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_17}\"},{\"type\":\"text\",\"x\":\"283\",\"y\":\"120\",\"width\":\"122\",\"height\":\"28\",\"layer\":\"18\",\"containerBackgroundColor\":\"#e8f4f8\",\"text\":\"文字{text_18}\",\"color\":\"#000000\",\"fontSize\":\"16\",\"align\":\"left\",\"overflow\":\"wrap\"},{\"type\":\"image\",\"x\":\"291\",\"y\":\"16\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"19\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_19}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"26\"},{\"type\":\"qrcode\",\"x\":\"40\",\"y\":\"638\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"20\",\"containerBackgroundColor\":\"#fdf2f8\",\"text\":\"{qr_20}\"},{\"type\":\"image\",\"x\":\"164\",\"y\":\"427\",\"width\":\"100\",\"height\":\"100\",\"layer\":\"21\",\"containerBackgroundColor\":\"#faf6ed\",\"src\":\"{image_21}\",\"borderColor\":\"rgba(255,255,255,0)\",\"borderRadius\":\"33\"}]}', 1763801812, 1763888059, 0);
SET FOREIGN_KEY_CHECKS = 1;
ps:操作数据库增删改查我这里就不写了,项目不一样语法不一样
ps: 注意给 materials 用 json_decode json_encode($materials, JSON_UNESCAPED_UNICODE) 一下
接口调用方法
/**
* 海报图创建
* https://lscs001.aaaa.net/api/wxapp/poster/create_poster?id=6
*/
public function create_poster()
{
$PosterInit = new \init\PosterInit();//海报图管理 (ps:InitController)
$PosterModel = new \initmodel\PosterModel(); //海报图管理 (ps:InitModel)
/** 获取参数 **/
$params = $this->request->param();
$params["user_id"] = $this->user_id;
/** 查询条件 **/
$where = [];
$where[] = ["id", "=", $params["id"]];
/** 查询数据,我这封装的,根据自己需求改这里 **/
$params["InterfaceType"] = "api";//接口类型
$params["DataFormat"] = "find";//数据格式,find详情,list列表
$result = $PosterInit->get_find($where, $params);
if (empty($result)) $this->error("暂无数据");
$generator = new \init\PosterGeneratorInit();
// 替换参数
$poster_data = [
'text_1' => '标题文字',
'text_2' => '副标题恶hi各位合规文化古诶,给我黑鬼文化馆',
'text_3' => '底部文字恶hi各位合规文化古诶,给我黑鬼文化馆',
'image_4' => 'https://oss.ausite.cn/xcxkf220/default/20251015/aa2f16d64e549a70583356ba30c786d9.jpg',
'image_5' => 'https://oss.ausite.cn/kf203/admin/20250912/978a00a76ede33dd6717801db314d815.png',
'image_6' => 'https://oss.ausite.cn/xcxkf220/default/20251023/c70bba27ebc75502f07669ecd3619e36.jpg',
'qr_7' => 'https://example.com/qr1',
'text_8' => '右侧文字恶hi各位合规文化古诶,给我黑鬼文化馆',
'image_9' => 'https://oss.ausite.cn/xcxkf220/default/20251015/aa2f16d64e549a70583356ba30c786d9.jpg',
'text_10' => '居中文字',
'text_11' => '底部左侧文字我吃了个亏,给黑恶hi各位合规文化古诶,给我黑鬼文化馆iu额',
'qr_12' => 'https://example.com/qr2',
'text_13' => '中间文字恶hi各位合规文化古诶,给我黑鬼文化馆',
'image_14' => 'https://oss.ausite.cn/xcxkf220/default/20251022/4b54357b288d4d923bca7a75227c1e63.jpg',
'qr_15' => 'https://example.com/qr3',
'text_16' => '左下角文字恶hi各位合规文化古诶,给我黑鬼文化馆',
'qr_17' => 'https://example.com/qr4',
'text_18' => '右上角文字恶hi各位合规文化古诶,给我黑鬼文化馆',
'image_19' => 'https://oss.ausite.cn/xcxkf220/default/20251113/ba514859d0b19b3b511834de9de95081.jpg',
'qr_20' => 'https://example.com/qr5',
'image_21' => 'https://oss.ausite.cn/xcxkf220/default/20251026/9e60b85da853c9a5b9a0b2040866221c.jpg'
];//完整版
//测试数据 这要转成数组格式,我在 get_find() 转过了
//$result['materials'] = '{"width":"422","height":"750","backgroundColor":"#ffffff","elements":[{"type":"text","x":"134","y":"12","width":"100","height":"100","layer":"1","containerBackgroundColor":"#e8f4f8","text":"文字{text_1}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"29","y":"114","width":"100","height":"100","layer":"2","containerBackgroundColor":"#e8f4f8","text":"文字{text_2}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"294","y":"476","width":"126","height":"27","layer":"3","containerBackgroundColor":"#e8f4f8","text":"文字{text_3}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"169","y":"123","width":"100","height":"100","layer":"4","containerBackgroundColor":"#faf6ed","src":"{image_4}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"image","x":"15","y":"10","width":"100","height":"100","layer":"5","containerBackgroundColor":"#faf6ed","src":"{image_5}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"image","x":"277","y":"291","width":"100","height":"100","layer":"6","containerBackgroundColor":"#faf6ed","src":"{image_6}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"144","y":"325","width":"100","height":"100","layer":"7","containerBackgroundColor":"#fdf2f8","text":"{qr_7}"},{"type":"text","x":"25","y":"320","width":"118","height":"34","layer":"8","containerBackgroundColor":"#e8f4f8","text":"文字{text_8}","color":"#000000","fontSize":"16","align":"right","overflow":"ellipsis"},{"type":"image","x":"7","y":"534","width":"100","height":"100","layer":"9","containerBackgroundColor":"#faf6ed","src":"{image_9}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"text","x":"145","y":"534","width":"117","height":"34","layer":"10","containerBackgroundColor":"#e8f4f8","text":"文字{text_10}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"text","x":"159","y":"595","width":"115","height":"25","layer":"11","containerBackgroundColor":"#e8f4f8","text":"文字{text_11}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"qrcode","x":"308","y":"641","width":"100","height":"100","layer":"12","containerBackgroundColor":"#fdf2f8","text":"{qr_12}"},{"type":"text","x":"262","y":"233","width":"105","height":"33","layer":"13","containerBackgroundColor":"#e8f4f8","text":"文字{text_13}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"188","y":"640","width":"100","height":"100","layer":"14","containerBackgroundColor":"#faf6ed","src":"{image_14}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"145","y":"223","width":"100","height":"100","layer":"15","containerBackgroundColor":"#fdf2f8","text":"{qr_15}"},{"type":"text","x":"16","y":"432","width":"118","height":"29","layer":"16","containerBackgroundColor":"#e8f4f8","text":"文字{text_16}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"qrcode","x":"32","y":"212","width":"100","height":"100","layer":"17","containerBackgroundColor":"#fdf2f8","text":"{qr_17}"},{"type":"text","x":"283","y":"120","width":"122","height":"28","layer":"18","containerBackgroundColor":"#e8f4f8","text":"文字{text_18}","color":"#000000","fontSize":"16","align":"left","overflow":"wrap"},{"type":"image","x":"291","y":"16","width":"100","height":"100","layer":"19","containerBackgroundColor":"#faf6ed","src":"{image_19}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"},{"type":"qrcode","x":"40","y":"638","width":"100","height":"100","layer":"20","containerBackgroundColor":"#fdf2f8","text":"{qr_20}"},{"type":"image","x":"164","y":"427","width":"100","height":"100","layer":"21","containerBackgroundColor":"#faf6ed","src":"{image_21}","borderColor":"rgba(255,255,255,0)","borderRadius":"0"}]}';
$posterResult = $generator->generatePoster($result['materials'], $poster_data);
//有错误提示一下
if ($posterResult['code'] == 0) $this->error("海报生成失败!", $posterResult['msg']);
$this->success("生成成功", cmf_get_asset_url($posterResult['data']));
}
处理图片,二维码,文字方法
ps: 引入了插架 QRcode,Image 注意下载一下
ps: 我给图上传到oss上了 最后一步需要改一下你们,没时间整理了, 这里可能需要改动一些地方有不懂的私信
<?php
namespace init;
use cmf\lib\Storage;
use cmf\phpqrcode\QRcode;
use think\Image;
/**
* 海报生成器
*/
class PosterGeneratorInit
{
// 字体文件路径
private $fontPath;
private $storage;
private $plugin;
private $config;
private $dir;
private $tempDir;
/**
* 构造函数
*/
public function __construct()
{
$this->storage = cmf_get_option('storage');
$pluginClass = cmf_get_plugin_class($this->storage['type']);
$this->plugin = new $pluginClass();
$this->config = $this->plugin->getConfig();
$this->dir = $this->config['dir'] ?? 'default';
// 初始化字体路径(确保字体文件存在,否则文字无法显示)
$this->fontPath = app()->getRootPath() . 'public/font/song.otf';
if (!file_exists($this->fontPath)) {
throw new \Exception("字体文件不存在:{$this->fontPath}");
}
// 初始化临时目录
$this->initTempDir();
}
/**
* 初始化临时目录
*/
private function initTempDir()
{
$date = date('Ymd');
$this->tempDir = app()->getRootPath() . "public/upload/{$this->dir}/poster/{$date}/";
if (!file_exists($this->tempDir)) {
mkdir($this->tempDir, 0777, true);
}
}
/**
* 核心生成方法
*/
public function generatePoster($materials, $replaceData)
{
// 创建背景图
$bgPath = $this->createBackground($materials);
if (!$bgPath) {
return ['code' => 0, 'msg' => '背景图创建失败'];
}
// 处理所有元素
$finalPath = $this->processElements($materials, $replaceData, $bgPath);
if (!$finalPath) {
return ['code' => 0, 'msg' => '海报元素处理失败'];
}
// 上传到云存储
$urlPath = $this->uploadToCloud($finalPath);
if (!$urlPath) {
return ['code' => 0, 'msg' => '上传到云存储失败'];
}
return ['code' => 1, 'msg' => '生成成功', 'data' => $urlPath];
}
/**
* 创建背景图
*/
private function createBackground($template)
{
$width = $template['width'] ?? 422;
$height = $template['height'] ?? 750;
$backgroundColor = $template['backgroundColor'] ?? '#ec8383';
$bgPath = $this->tempDir . 'bg_' . uniqid(uniqid(rand(11111, 99999))) . '.png';
$this->createBg($bgPath, $width, $height, $backgroundColor);
return file_exists($bgPath) ? $bgPath : '';
}
/**
* 处理所有元素
*/
private function processElements($materials, $replaceData, $bgPath)
{
$elements = $materials['elements'];
// 从模板配置获取尺寸,如果没有则从第一个元素获取
$width = $materials['width'] ?? 422;
$height = $materials['height'] ?? 750;
// 尝试从元素中获取画布尺寸
foreach ($elements as $element) {
if (isset($element['canvasWidth']) && isset($element['canvasHeight'])) {
$width = $element['canvasWidth'];
$height = $element['canvasHeight'];
break;
}
}
// 创建真彩色画布
$canvas = imagecreatetruecolor($width, $height);
// 设置背景 - 修复:不使用透明背景,直接使用背景图
if (file_exists($bgPath)) {
$bgImage = imagecreatefrompng($bgPath);
imagecopy($canvas, $bgImage, 0, 0, 0, 0, $width, $height);
imagedestroy($bgImage);
} else {
// 如果没有背景图,使用白色背景
$white = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $white);
}
$sortedElements = $this->sortElements($elements);
// 循环处理所有元素
foreach ($sortedElements as $element) {
$this->processElement($canvas, $element, $replaceData, $width, $height);
}
// 保存最终图片
$finalPath = $this->tempDir . 'final_' . uniqid(uniqid(rand(11111, 99999))) . '.png';
imagepng($canvas, $finalPath, 9); // 最高质量
imagedestroy($canvas);
return file_exists($finalPath) ? $finalPath : '';
}
/**
* 处理单个元素
*/
private function processElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight)
{
$type = $element['type'] ?? '';
switch ($type) {
case 'text':
$this->handleTextElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight);
break;
case 'image':
$this->handleImageElement($canvas, $element, $replaceData);
break;
case 'qrcode':
$this->handleQrcodeElement($canvas, $element, $replaceData);
break;
}
}
/**
* 处理文字元素 - 修复溢出处理
*/
private function handleTextElement($canvas, $element, $replaceData, $canvasWidth, $canvasHeight)
{
$text = $this->replacePlaceholders($element['text'], $replaceData);
if (empty($text)) return;
$fontSize = $element['fontSize'] ?? 16;
$color = $element['color'] ?? '#000000';
$x = $element['x'] ?? 0;
$y = $element['y'] ?? 0;
$width = $element['width'] ?? $canvasWidth;
$height = $element['height'] ?? 0; // 文字区域高度
$align = $element['align'] ?? 'left';
$overflow = $element['overflow'] ?? 'wrap'; // 溢出处理方式
// 处理文字溢出(包括宽度和高度限制)
$processedText = $this->handleTextOverflow($text, $element);
// 计算文字位置
$position = $this->calculateTextPosition($element, $processedText, $canvasWidth, $canvasHeight);
$x = $position['x'];
$y = $position['y'];
// 将十六进制颜色转换为GD颜色
$rgb = $this->hexToRgb($color);
$textColor = imagecolorallocate($canvas, $rgb['r'], $rgb['g'], $rgb['b']);
// 处理多行文本
$lines = explode("\n", $processedText);
$lineHeight = $fontSize * 1.5; // 行高
// 如果有高度限制,计算最大显示行数
$maxLines = 0;
if ($height > 0) {
$maxLines = floor($height / $lineHeight);
if ($maxLines > 0 && count($lines) > $maxLines) {
$lines = array_slice($lines, 0, $maxLines);
// 如果最后一行被截断且设置了省略号,在最后一行添加省略号
if ($overflow === 'ellipsis') {
$lastLine = $lines[count($lines) - 1];
$lines[count($lines) - 1] = $this->truncateLineWithEllipsis($lastLine, $width, $fontSize);
}
}
}
foreach ($lines as $index => $line) {
$currentY = $y + ($index * $lineHeight);
// 使用imagettftext绘制文字
imagettftext(
$canvas,
$fontSize,
0, // 角度
$x,
$currentY,
$textColor,
$this->fontPath,
$line
);
}
}
/**
* 处理文字超出 - 增强版本(支持宽度和高度限制)
*/
private function handleTextOverflow($text, $element)
{
$overflow = $element['overflow'] ?? 'wrap';
$width = $element['width'] ?? 100;
$height = $element['height'] ?? 0; // 高度限制
$fontSize = $element['fontSize'] ?? 16;
$lineHeight = $fontSize * 1.5;
// 首先处理宽度溢出
$processedText = $this->handleWidthOverflow($text, $width, $fontSize, $overflow);
// 如果有高度限制,再处理高度溢出
if ($height > 0) {
$processedText = $this->handleHeightOverflow($processedText, $height, $lineHeight, $overflow);
}
return $processedText;
}
/**
* 处理宽度溢出
*/
private function handleWidthOverflow($text, $maxWidth, $fontSize, $overflow)
{
// 如果是单行省略模式
if ($overflow === 'ellipsis') {
return $this->truncateLineWithEllipsis($text, $maxWidth, $fontSize);
}
// 如果是换行模式
if ($overflow === 'wrap') {
return $this->autoWrapText($text, $maxWidth, $fontSize);
}
// 默认不处理
return $text;
}
/**
* 处理高度溢出
*/
private function handleHeightOverflow($text, $maxHeight, $lineHeight, $overflow)
{
$lines = explode("\n", $text);
$maxLines = floor($maxHeight / $lineHeight);
if (count($lines) <= $maxLines) {
return $text;
}
// 截断到最大行数
$truncatedLines = array_slice($lines, 0, $maxLines);
// 如果设置了省略号模式,在最后一行添加省略号
if ($overflow === 'ellipsis' && !empty($truncatedLines)) {
$lastLine = $truncatedLines[count($truncatedLines) - 1];
// 如果最后一行已经有省略号,不再重复添加
if (mb_substr($lastLine, -3) !== '...') {
$truncatedLines[count($truncatedLines) - 1] = rtrim($lastLine) . '...';
}
}
return implode("\n", $truncatedLines);
}
/**
* 截断单行文本并添加省略号
*/
private function truncateLineWithEllipsis($text, $maxWidth, $fontSize)
{
$ellipsis = '...';
$ellipsisWidth = $this->calculateTextWidth($ellipsis, $fontSize);
// 如果文本本身宽度就小于最大宽度,直接返回
$textWidth = $this->calculateTextWidth($text, $fontSize);
if ($textWidth <= $maxWidth) {
return $text;
}
// 如果连省略号都放不下,直接返回空字符串
if ($ellipsisWidth > $maxWidth) {
return '';
}
$maxWidth -= $ellipsisWidth;
$result = '';
for ($i = 0; $i < mb_strlen($text); $i++) {
$char = mb_substr($text, $i, 1);
$testString = $result . $char;
if ($this->calculateTextWidth($testString, $fontSize) > $maxWidth) {
break;
}
$result = $testString;
}
return $result . $ellipsis;
}
/**
* 计算文本宽度
*/
private function calculateTextWidth($text, $fontSize)
{
$box = imagettfbbox($fontSize, 0, $this->fontPath, $text);
return abs($box[2] - $box[0]);
}
/**
* 自动换行文本
*/
private function autoWrapText($text, $maxWidth, $fontSize)
{
$words = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY);
$lines = [];
$currentLine = '';
foreach ($words as $word) {
$testLine = $currentLine . $word;
if ($this->calculateTextWidth($testLine, $fontSize) <= $maxWidth) {
$currentLine = $testLine;
} else {
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
$currentLine = $word;
}
}
if (!empty($currentLine)) {
$lines[] = $currentLine;
}
return implode("\n", $lines);
}
/**
* 计算文字位置
*/
private function calculateTextPosition($element, $text, $canvasWidth, $canvasHeight)
{
$x = $element['x'] ?? 0;
$y = $element['y'] ?? 0;
$width = $element['width'] ?? $canvasWidth;
$height = $element['height'] ?? 0;
$align = $element['align'] ?? 'left';
$fontSize = $element['fontSize'] ?? 16;
// 处理多行文本的高度计算
$lines = explode("\n", $text);
$lineHeight = $fontSize * 1.5;
$textHeight = count($lines) * $lineHeight;
// 垂直居中调整(如果需要)
if (isset($element['verticalAlign']) && $element['verticalAlign'] === 'middle') {
$containerHeight = $height > 0 ? $height : $textHeight;
$y = $y + ($containerHeight - $textHeight) / 2;
}
// 计算最长行的宽度
$maxLineWidth = 0;
foreach ($lines as $line) {
$lineWidth = $this->calculateTextWidth($line, $fontSize);
if ($lineWidth > $maxLineWidth) {
$maxLineWidth = $lineWidth;
}
}
// 水平对齐
switch ($align) {
case 'center':
$x = $x + ($width - $maxLineWidth) / 2;
break;
case 'right':
$x = $x + $width - $maxLineWidth;
break;
default: // left
// $x 保持不变
break;
}
// 调整Y坐标(GD库的Y坐标是从基线开始的,需要调整)
$y = $y + $fontSize; // 基础调整
return ['x' => (int)$x, 'y' => (int)$y];
}
/**
* 处理图片元素 - 优化圆角处理
*/
private function handleImageElement($canvas, $element, $replaceData)
{
$src = $this->replacePlaceholders($element['src'], $replaceData);
$localPath = $this->downloadImage($src);
if ($localPath && file_exists($localPath)) {
$x = $element['x'] ?? 0;
$y = $element['y'] ?? 0;
$width = $element['width'] ?? 100;
$height = $element['height'] ?? 100;
$borderRadius = $element['borderRadius'] ?? 0;
// 根据图片类型加载图片
$imageInfo = getimagesize($localPath);
if (!$imageInfo) return;
$sourceImage = null;
switch ($imageInfo[2]) {
case IMAGETYPE_JPEG:
$sourceImage = imagecreatefromjpeg($localPath);
break;
case IMAGETYPE_PNG:
$sourceImage = imagecreatefrompng($localPath);
break;
case IMAGETYPE_GIF:
$sourceImage = imagecreatefromgif($localPath);
break;
default:
return;
}
if (!$sourceImage) return;
// 调整图片尺寸
$resizedImage = imagecreatetruecolor($width, $height);
// 统一设置透明度
imagealphablending($resizedImage, false);
imagesavealpha($resizedImage, true);
$transparent = imagecolorallocatealpha($resizedImage, 0, 0, 0, 127);
imagefill($resizedImage, 0, 0, $transparent);
// 高质量缩放
imagecopyresampled($resizedImage, $sourceImage, 0, 0, 0, 0, $width, $height, imagesx($sourceImage), imagesy($sourceImage));
// 应用圆角效果 - 优化:限制圆角半径不超过宽度或高度的一半
if ($borderRadius > 0) {
// 限制圆角半径不超过宽度或高度的一半
$maxRadius = min($width, $height) / 2;
$borderRadius = min($borderRadius, $maxRadius);
$roundedImage = $this->applyRoundedCornersOptimized($resizedImage, $borderRadius, $width, $height);
if ($roundedImage) {
imagedestroy($resizedImage);
$resizedImage = $roundedImage;
}
}
// 合并到画布
imagecopy($canvas, $resizedImage, $x, $y, 0, 0, $width, $height);
// 释放内存
imagedestroy($sourceImage);
imagedestroy($resizedImage);
}
}
/**
* 应用圆角效果 - 优化版本(修复小尺寸图片圆角问题)
*/
private function applyRoundedCornersOptimized($image, $radius, $width, $height)
{
// 如果圆角为0,直接返回原图
if ($radius <= 0) {
return $image;
}
// 创建带透明背景的画布
$roundedImage = imagecreatetruecolor($width, $height);
imagealphablending($roundedImage, false);
imagesavealpha($roundedImage, true);
// 填充透明背景
$transparent = imagecolorallocatealpha($roundedImage, 0, 0, 0, 127);
imagefill($roundedImage, 0, 0, $transparent);
// 创建圆形遮罩
$mask = imagecreatetruecolor($width, $height);
imagealphablending($mask, false);
imagesavealpha($mask, true);
imagefill($mask, 0, 0, imagecolorallocatealpha($mask, 0, 0, 0, 127));
// 绘制圆角矩形遮罩
$white = imagecolorallocate($mask, 255, 255, 255);
$this->drawRoundedRectangleOptimized($mask, 0, 0, $width - 1, $height - 1, $radius, $white);
// 应用遮罩
for ($x = 0; $x < $width; $x++) {
for ($y = 0; $y < $height; $y++) {
$maskColor = imagecolorat($mask, $x, $y);
$alpha = ($maskColor >> 24) & 0x7F;
// 如果遮罩像素是白色(不透明),复制原图像素
if ($alpha < 127) {
$sourceColor = imagecolorat($image, $x, $y);
imagesetpixel($roundedImage, $x, $y, $sourceColor);
}
}
}
imagedestroy($mask);
return $roundedImage;
}
/**
* 绘制圆角矩形 - 优化版本(修复小尺寸圆角问题)
*/
private function drawRoundedRectangleOptimized($image, $x1, $y1, $x2, $y2, $radius, $color)
{
if ($radius <= 0) {
imagefilledrectangle($image, $x1, $y1, $x2, $y2, $color);
return;
}
// 绘制四个圆角
$this->drawCircleQuarterOptimized($image, $x1 + $radius, $y1 + $radius, $radius, 1, $color);
$this->drawCircleQuarterOptimized($image, $x2 - $radius, $y1 + $radius, $radius, 2, $color);
$this->drawCircleQuarterOptimized($image, $x2 - $radius, $y2 - $radius, $radius, 3, $color);
$this->drawCircleQuarterOptimized($image, $x1 + $radius, $y2 - $radius, $radius, 4, $color);
// 绘制矩形区域
imagefilledrectangle($image, $x1 + $radius, $y1, $x2 - $radius, $y2, $color);
imagefilledrectangle($image, $x1, $y1 + $radius, $x2, $y2 - $radius, $color);
}
/**
* 绘制圆的四分之一 - 优化版本(使用更精确的算法)
*/
private function drawCircleQuarterOptimized($image, $centerX, $centerY, $radius, $quarter, $color)
{
// 使用更精确的圆角绘制算法
for ($x = 0; $x <= $radius; $x++) {
$y = (int)round(sqrt($radius * $radius - $x * $x));
switch ($quarter) {
case 1: // 左上角
for ($drawY = $centerY - $y; $drawY <= $centerY; $drawY++) {
imagesetpixel($image, $centerX - $x, $drawY, $color);
}
break;
case 2: // 右上角
for ($drawY = $centerY - $y; $drawY <= $centerY; $drawY++) {
imagesetpixel($image, $centerX + $x, $drawY, $color);
}
break;
case 3: // 右下角
for ($drawY = $centerY; $drawY <= $centerY + $y; $drawY++) {
imagesetpixel($image, $centerX + $x, $drawY, $color);
}
break;
case 4: // 左下角
for ($drawY = $centerY; $drawY <= $centerY + $y; $drawY++) {
imagesetpixel($image, $centerX - $x, $drawY, $color);
}
break;
}
}
}
/**
* 处理二维码元素 - 同样优化圆角处理
*/
private function handleQrcodeElement($canvas, $element, $replaceData)
{
$text = $this->replacePlaceholders($element['text'], $replaceData);
$qrPath = $this->tempDir . 'qr_' . uniqid(uniqid(rand(11111, 99999))) . '.png';
$width = $element['width'] ?? 100;
$height = $element['height'] ?? 100;
$x = $element['x'] ?? 0;
$y = $element['y'] ?? 0;
$borderRadius = $element['borderRadius'] ?? 0;
// 生成二维码
QRcode::png($text, $qrPath, 1, 10, 1, true, 0xFFFFFF, 0x000000);
if (file_exists($qrPath)) {
$qrcodeImage = imagecreatefrompng($qrPath);
if ($qrcodeImage) {
// 调整二维码尺寸
$resizedQr = imagecreatetruecolor($width, $height);
// 统一设置透明度
imagealphablending($resizedQr, false);
imagesavealpha($resizedQr, true);
$transparent = imagecolorallocatealpha($resizedQr, 0, 0, 0, 127);
imagefill($resizedQr, 0, 0, $transparent);
imagecopyresampled($resizedQr, $qrcodeImage, 0, 0, 0, 0, $width, $height, imagesx($qrcodeImage), imagesy($qrcodeImage));
// 应用圆角效果 - 同样优化
if ($borderRadius > 0) {
// 限制圆角半径不超过宽度或高度的一半
$maxRadius = min($width, $height) / 2;
$borderRadius = min($borderRadius, $maxRadius);
$roundedQr = $this->applyRoundedCornersOptimized($resizedQr, $borderRadius, $width, $height);
if ($roundedQr) {
imagedestroy($resizedQr);
$resizedQr = $roundedQr;
}
}
// 合并到画布
imagecopy($canvas, $resizedQr, $x, $y, 0, 0, $width, $height);
imagedestroy($qrcodeImage);
imagedestroy($resizedQr);
// 清理临时文件
@unlink($qrPath);
}
}
}
/**
* 替换占位符
*/
private function replacePlaceholders($text, $replaceData)
{
return preg_replace_callback('/\{([^}]+)\}/', function ($matches) use ($replaceData) {
$key = $matches[1];
return $replaceData[$key] ?? $matches[0];
}, $text);
}
/**
* 下载网络图片
*/
private function downloadImage($url)
{
$localPath = $this->tempDir . 'download_' . uniqid(uniqid(rand(11111, 99999)));
// 根据URL确定文件扩展名
$extension = pathinfo(parse_url($url, PHP_URL_PATH), PATHINFO_EXTENSION);
if (empty($extension)) {
$extension = 'png'; // 默认使用png
}
$localPath .= '.' . $extension;
$content = @file_get_contents($url);
if ($content === false) {
return '';
}
if (file_put_contents($localPath, $content) === false) {
return '';
}
return file_exists($localPath) ? $localPath : '';
}
/**
* 上传到云存储
*/
private function uploadToCloud($localPath)
{
$date = date('Ymd');
$cloudPath = "{$this->dir}/poster/{$date}/" . uniqid(uniqid(rand(11111, 99999))) . ".png";
$storage = new Storage($this->storage['type'], $this->storage['storages'][$this->storage['type']]);
$result = $storage->upload($cloudPath, $localPath);
return $result['url'] ? $cloudPath : '';
}
/**
* 对元素进行排序(按图层)
*/
private function sortElements($elements)
{
usort($elements, function ($a, $b) {
return ($b['layer'] ?? 0) - ($a['layer'] ?? 0);
});
return $elements;
}
/**
* 创建背景图 - 修复背景颜色问题
*/
private function createBg($bgPath, $width, $height, $backgroundColor)
{
// 使用真彩色
$image = imagecreatetruecolor($width, $height);
// 分配背景颜色
$rgb = $this->hexToRgb($backgroundColor);
$bgColor = imagecolorallocate($image, $rgb['r'], $rgb['g'], $rgb['b']);
// 填充背景
imagefill($image, 0, 0, $bgColor);
// 保存为PNG,不使用透明色
imagepng($image, $bgPath, 9);
imagedestroy($image);
}
/**
* 十六进制转RGB
*/
private function hexToRgb($hex)
{
$hex = str_replace('#', '', $hex);
if (strlen($hex) == 3) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
return [
'r' => hexdec(substr($hex, 0, 2)),
'g' => hexdec(substr($hex, 2, 2)),
'b' => hexdec(substr($hex, 4, 2))
];
}
/**
* 清理临时目录
*/
private function clearTempDir()
{
if (file_exists($this->tempDir)) {
$files = glob($this->tempDir . '*');
foreach ($files as $file) {
if (is_file($file)) {
@unlink($file);
}
}
}
}
/**
* 析构函数 - 自动清理
*/
public function __destruct()
{
$this->clearTempDir();
}
}
987

被折叠的 条评论
为什么被折叠?



