4004 lines
145 KiB
JavaScript
4004 lines
145 KiB
JavaScript
// 极简邀请函制作工具 - 性能优化版本
|
||
(function() {
|
||
const fileInput = document.getElementById('fileInput');
|
||
const addImageBtn = document.getElementById('addImageBtn');
|
||
const addOverlayBtn = document.getElementById('addOverlayBtn');
|
||
const addTextBtn = document.getElementById('addTextBtn');
|
||
const saveBtn = document.getElementById('saveBtn');
|
||
const imageContainer = document.getElementById('imageContainer');
|
||
const bgLayer = document.getElementById('bgLayer');
|
||
const bgImage = document.getElementById('bgImage');
|
||
const emptyTip = document.getElementById('emptyTip');
|
||
const input1 = document.getElementById('input1');
|
||
const input2 = document.getElementById('input2');
|
||
const input3 = document.getElementById('input3');
|
||
const exportCanvas = document.getElementById('exportCanvas');
|
||
const longPressTip = document.getElementById('longPressTip');
|
||
const canvasArea = document.getElementById('canvasArea');
|
||
const editorToolbar = document.getElementById('editorToolbar');
|
||
const textTools = document.getElementById('textTools');
|
||
const imageTools = document.getElementById('imageTools');
|
||
const bgTools = document.getElementById('bgTools');
|
||
const opacitySlider = document.getElementById('opacitySlider');
|
||
const opacityValue = document.getElementById('opacityValue');
|
||
const rotateSlider = document.getElementById('rotateSlider');
|
||
const rotateValue = document.getElementById('rotateValue');
|
||
const mirrorHBtn = document.getElementById('mirrorHBtn');
|
||
const bgScaleSlider = document.getElementById('bgScaleSlider');
|
||
const bgScaleValue = document.getElementById('bgScaleValue');
|
||
const bgOpacitySlider = document.getElementById('bgOpacitySlider');
|
||
const bgOpacityValue = document.getElementById('bgOpacityValue');
|
||
const weightSlider = document.getElementById('weightSlider');
|
||
const weightValue = document.getElementById('weightValue');
|
||
const italicBtn = document.getElementById('italicBtn');
|
||
const lockBtn = document.getElementById('lockBtn');
|
||
const colorPicker = document.getElementById('colorPicker');
|
||
const fontPicker = document.getElementById('fontPicker');
|
||
const fontSizePicker = document.getElementById('fontSizePicker');
|
||
const overlayLayer = document.getElementById('overlayLayer');
|
||
const overlayInput = document.createElement('input');
|
||
overlayInput.type = 'file';
|
||
overlayInput.accept = 'image/*';
|
||
overlayInput.hidden = true;
|
||
document.body.appendChild(overlayInput);
|
||
const guideH = document.getElementById('guideH');
|
||
const guideV = document.getElementById('guideV');
|
||
const gridLines = document.getElementById('gridLines');
|
||
|
||
let inputsConfig = null;
|
||
let currentImage = null;
|
||
let bgImageScale = 1;
|
||
let bgImageOpacity = 1;
|
||
let selectedInput = null;
|
||
let editingInput = null;
|
||
let longPressTimer = null;
|
||
|
||
// 图片滑动调整位置相关变量
|
||
let imageOffsetX = 0;
|
||
let isDraggingImage = false;
|
||
let imageStartX = 0;
|
||
let imageStartOffsetX = 0;
|
||
let minOffsetX = 0;
|
||
let maxOffsetX = 0;
|
||
let isInteractingWithBgImage = false;
|
||
|
||
// 撤销重做相关变量
|
||
const undoStack = [];
|
||
const redoStack = [];
|
||
const MAX_HISTORY = 50;
|
||
let isUndoing = false;
|
||
let saveStateTimer = null;
|
||
let lastSavedState = null;
|
||
const SAVE_DELAY = 300; // 防抖延迟 300ms
|
||
|
||
// ==================== 性能优化:图片缓存系统 ====================
|
||
const imageCache = new Map();
|
||
const MAX_CACHE_SIZE = 20; // 最大缓存图片数量
|
||
|
||
// 获取缓存图片,如果不存在则加载并缓存
|
||
async function getCachedImage(src) {
|
||
if (imageCache.has(src)) {
|
||
return imageCache.get(src);
|
||
}
|
||
|
||
// 清理旧缓存
|
||
if (imageCache.size >= MAX_CACHE_SIZE) {
|
||
const firstKey = imageCache.keys().next().value;
|
||
imageCache.delete(firstKey);
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.crossOrigin = 'anonymous';
|
||
img.onload = () => {
|
||
imageCache.set(src, img);
|
||
resolve(img);
|
||
};
|
||
img.onerror = reject;
|
||
img.src = src;
|
||
});
|
||
}
|
||
|
||
// 清理图片缓存
|
||
function clearImageCache() {
|
||
imageCache.clear();
|
||
}
|
||
|
||
// ==================== 性能优化:状态压缩 ====================
|
||
// 压缩状态数据,减少内存占用
|
||
function compressState(state) {
|
||
return {
|
||
...state,
|
||
// 压缩文本内容,限制长度
|
||
textInputs: state.textInputs.map(input => ({
|
||
...input,
|
||
content: input.content.length > 500 ? input.content.substring(0, 500) : input.content
|
||
}))
|
||
};
|
||
}
|
||
|
||
// ==================== 性能优化:节流与防抖工具 ====================
|
||
function throttle(func, limit) {
|
||
let inThrottle;
|
||
return function(...args) {
|
||
if (!inThrottle) {
|
||
func.apply(this, args);
|
||
inThrottle = true;
|
||
setTimeout(() => inThrottle = false, limit);
|
||
}
|
||
};
|
||
}
|
||
|
||
function debounce(func, wait) {
|
||
let timeout;
|
||
return function(...args) {
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||
};
|
||
}
|
||
|
||
// ==================== 性能优化:Loading 管理器 ====================
|
||
const loadingManager = {
|
||
show: (message = '处理中...') => {
|
||
let loadingEl = document.getElementById('globalLoading');
|
||
if (!loadingEl) {
|
||
loadingEl = document.createElement('div');
|
||
loadingEl.id = 'globalLoading';
|
||
loadingEl.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 99999;
|
||
`;
|
||
loadingEl.innerHTML = `
|
||
<div style="
|
||
background: #fff;
|
||
padding: 20px 30px;
|
||
border-radius: 8px;
|
||
text-align: center;
|
||
">
|
||
<div class="loading-spinner" style="
|
||
width: 40px;
|
||
height: 40px;
|
||
border: 3px solid #f3f3f3;
|
||
border-top: 3px solid #4a90e2;
|
||
border-radius: 50%;
|
||
animation: spin 1s linear infinite;
|
||
margin: 0 auto 10px;
|
||
"></div>
|
||
<div class="loading-text" style="color: #333; font-size: 14px;">${message}</div>
|
||
</div>
|
||
`;
|
||
document.body.appendChild(loadingEl);
|
||
|
||
// 添加动画样式
|
||
if (!document.getElementById('loadingAnimation')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'loadingAnimation';
|
||
style.textContent = `
|
||
@keyframes spin {
|
||
0% { transform: rotate(0deg); }
|
||
100% { transform: rotate(360deg); }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
} else {
|
||
loadingEl.querySelector('.loading-text').textContent = message;
|
||
loadingEl.style.display = 'flex';
|
||
}
|
||
},
|
||
hide: () => {
|
||
const loadingEl = document.getElementById('globalLoading');
|
||
if (loadingEl) {
|
||
loadingEl.style.display = 'none';
|
||
}
|
||
},
|
||
updateMessage: (message) => {
|
||
const loadingEl = document.getElementById('globalLoading');
|
||
if (loadingEl) {
|
||
loadingEl.querySelector('.loading-text').textContent = message;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 初始化
|
||
init();
|
||
|
||
function init() {
|
||
// 添加图片按钮
|
||
addImageBtn.addEventListener('click', () => {
|
||
fileInput.click();
|
||
});
|
||
|
||
// 层叠图按钮
|
||
addOverlayBtn.addEventListener('click', () => {
|
||
overlayInput.click();
|
||
});
|
||
|
||
// 层叠图文件选择
|
||
overlayInput.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
loadOverlayImage(file);
|
||
}
|
||
overlayInput.value = '';
|
||
});
|
||
|
||
// 添加文本按钮
|
||
addTextBtn.addEventListener('click', () => {
|
||
addTextInput();
|
||
});
|
||
|
||
// 文件选择
|
||
fileInput.addEventListener('change', (e) => {
|
||
const file = e.target.files[0];
|
||
if (file) {
|
||
loadImage(file);
|
||
}
|
||
fileInput.value = '';
|
||
});
|
||
|
||
// 保存按钮
|
||
saveBtn.addEventListener('click', saveImage);
|
||
|
||
// 撤销重做按钮
|
||
const undoBtn = document.getElementById('undoBtn');
|
||
const redoBtn = document.getElementById('redoBtn');
|
||
|
||
undoBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
undo();
|
||
});
|
||
redoBtn.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
redo();
|
||
});
|
||
|
||
updateUndoRedoButtons();
|
||
|
||
// 从配置文件应用输入框初始参数
|
||
if (typeof INPUTS_CONFIG !== 'undefined' && INPUTS_CONFIG && INPUTS_CONFIG.inputs) {
|
||
INPUTS_CONFIG.inputs.forEach(function(cfg) {
|
||
var el = document.getElementById(cfg.id);
|
||
if (el) {
|
||
el.style.left = cfg.left + 'px';
|
||
el.style.top = cfg.top + 'px';
|
||
if (cfg.fontWeight) el.style.fontWeight = cfg.fontWeight;
|
||
if (cfg.placeholder) el.dataset.placeholder = cfg.placeholder;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 初始化输入框
|
||
initInput(input1);
|
||
initInput(input2);
|
||
initInput(input3);
|
||
|
||
// 初始化图片滑动
|
||
initImageDrag();
|
||
|
||
// 透明度滑块
|
||
opacitySlider.addEventListener('input', (e) => {
|
||
if (selectedOverlay) {
|
||
selectedOverlay.style.opacity = e.target.value / 100;
|
||
opacityValue.textContent = e.target.value + '%';
|
||
}
|
||
});
|
||
opacitySlider.addEventListener('change', () => {
|
||
saveState();
|
||
});
|
||
|
||
// 旋转滑块
|
||
rotateSlider.addEventListener('input', (e) => {
|
||
if (selectedOverlay) {
|
||
let rotateDeg = parseInt(e.target.value);
|
||
|
||
// 吸附功能:在 0°、-90°、90° 附近吸附
|
||
const snapAngles = [0, -90, 90];
|
||
const snapThreshold = 5; // 吸附阈值 5 度
|
||
|
||
for (const snapAngle of snapAngles) {
|
||
if (Math.abs(rotateDeg - snapAngle) < snapThreshold) {
|
||
rotateDeg = snapAngle;
|
||
// 更新滑动条位置
|
||
rotateSlider.value = rotateDeg;
|
||
break;
|
||
}
|
||
}
|
||
|
||
selectedOverlay.style.transform = `rotate(${rotateDeg}deg)`;
|
||
rotateValue.textContent = rotateDeg + '°';
|
||
}
|
||
});
|
||
rotateSlider.addEventListener('change', () => {
|
||
saveState();
|
||
});
|
||
|
||
// 水平镜像按钮
|
||
mirrorHBtn.addEventListener('click', () => {
|
||
if (selectedOverlay) {
|
||
const img = selectedOverlay.querySelector('img');
|
||
if (img) {
|
||
const currentTransform = img.style.transform || '';
|
||
const isMirroredH = currentTransform.includes('scaleX(-1)');
|
||
|
||
// 移除之前的 scaleX 变换
|
||
let newTransform = currentTransform.replace(/scaleX\(-?1\)/g, '').trim();
|
||
|
||
// 如果之前没有水平镜像,则添加
|
||
if (!isMirroredH) {
|
||
newTransform = newTransform ? newTransform + ' scaleX(-1)' : 'scaleX(-1)';
|
||
mirrorHBtn.classList.add('active');
|
||
} else {
|
||
mirrorHBtn.classList.remove('active');
|
||
}
|
||
|
||
img.style.transform = newTransform;
|
||
|
||
// 保存状态
|
||
saveState();
|
||
}
|
||
}
|
||
});
|
||
|
||
// 背景图缩放滑块
|
||
bgScaleSlider.addEventListener('input', (e) => {
|
||
bgImageScale = e.target.value / 100;
|
||
bgScaleValue.textContent = e.target.value + '%';
|
||
updateBgImage();
|
||
});
|
||
bgScaleSlider.addEventListener('change', () => {
|
||
saveState();
|
||
});
|
||
|
||
// 背景图透明度滑块
|
||
bgOpacitySlider.addEventListener('input', (e) => {
|
||
bgImageOpacity = e.target.value / 100;
|
||
bgOpacityValue.textContent = e.target.value + '%';
|
||
bgImage.style.opacity = bgImageOpacity;
|
||
});
|
||
bgOpacitySlider.addEventListener('change', () => {
|
||
saveState();
|
||
});
|
||
|
||
// 点击空白区域取消选中
|
||
document.addEventListener('click', (e) => {
|
||
// 只有点击真正的空白区域(不是输入框,不是图片容器,不是工具栏)才取消选中
|
||
const isTextInput = e.target.closest('.text-input');
|
||
const isImageContainer = e.target.closest('.image-container');
|
||
const isToolbar = e.target.closest('.toolbar');
|
||
const isEditorToolbar = e.target.closest('.editor-toolbar');
|
||
|
||
if (!isTextInput && !isImageContainer && !isToolbar && !isEditorToolbar) {
|
||
deselectAll();
|
||
}
|
||
});
|
||
|
||
// 点击容器空白处取消选中
|
||
function handleContainerClick(e) {
|
||
if (isInteractingWithBgImage) {
|
||
return;
|
||
}
|
||
|
||
const isBackground = e.target === imageContainer ||
|
||
e.target === bgLayer ||
|
||
e.target === bgImage ||
|
||
e.target.closest('.preview-bg-layer') ||
|
||
e.target === document.getElementById('emptyTip');
|
||
const isOverlayImage = e.target.closest('.overlay-image');
|
||
const isTextInput = e.target.closest('.text-input');
|
||
const isQRCode = e.target.closest('.qr-item');
|
||
|
||
// 点击背景/空白区域(排除二维码)
|
||
if (isBackground && !isOverlayImage && !isQRCode) {
|
||
// 如果正在编辑,先结束编辑
|
||
if (editingInput) {
|
||
endEdit(editingInput);
|
||
}
|
||
// 移除输入框选中状态
|
||
if (selectedInput) {
|
||
selectedInput.classList.remove('selected');
|
||
selectedInput = null;
|
||
hidePositionInfo();
|
||
}
|
||
// 取消层叠图选中状态
|
||
if (selectedOverlay) {
|
||
selectedOverlay.classList.remove('selected');
|
||
selectedOverlay = null;
|
||
}
|
||
// 隐藏二维码工具栏
|
||
if (window.QRCodeAPI) {
|
||
window.QRCodeAPI.hideTools();
|
||
}
|
||
// 隐藏辅助线
|
||
hideGuides();
|
||
// 隐藏编辑器工具栏
|
||
editorToolbar.classList.remove('active');
|
||
}
|
||
|
||
// 点击空白区域(不是层叠图、输入框和二维码)
|
||
if (!isOverlayImage && !isTextInput && !isBackground && !isQRCode) {
|
||
if (selectedOverlay) {
|
||
selectedOverlay.classList.remove('selected');
|
||
selectedOverlay = null;
|
||
}
|
||
// 隐藏二维码工具栏
|
||
if (window.QRCodeAPI) {
|
||
window.QRCodeAPI.hideTools();
|
||
}
|
||
}
|
||
}
|
||
|
||
imageContainer.addEventListener('click', handleContainerClick);
|
||
// 移动端:touchend 时如果正在编辑文本,不处理容器点击
|
||
imageContainer.addEventListener('touchend', (e) => {
|
||
// 如果触摸目标是文本输入框或正在编辑,不触发容器点击
|
||
if (e.target.closest('.text-input')) {
|
||
return;
|
||
}
|
||
// 如果正在编辑文本,不触发容器点击(避免隐藏工具栏)
|
||
if (editingInput) {
|
||
return;
|
||
}
|
||
handleContainerClick(e);
|
||
});
|
||
|
||
// 初始化右键菜单(只在3:5预览区域)
|
||
initContextMenu();
|
||
|
||
// 初始化文本编辑器工具栏
|
||
initTextEditorToolbar();
|
||
|
||
// 初始化键盘微调功能
|
||
initKeyboardNudge();
|
||
|
||
// 初始化图层管理
|
||
initLayerManager();
|
||
|
||
// 加载默认背景图
|
||
loadDefaultBackground();
|
||
|
||
// 保存初始状态
|
||
setTimeout(() => {
|
||
saveState();
|
||
}, 500);
|
||
|
||
}
|
||
|
||
// ==================== 撤销重做功能(性能优化版) ====================
|
||
|
||
// 内存使用监控
|
||
let memoryMonitor = {
|
||
lastCheck: 0,
|
||
checkInterval: 30000, // 30秒检查一次
|
||
check: function() {
|
||
const now = Date.now();
|
||
if (now - this.lastCheck < this.checkInterval) return;
|
||
this.lastCheck = now;
|
||
|
||
// 如果历史记录过多,主动清理
|
||
if (undoStack.length > MAX_HISTORY * 0.8) {
|
||
console.log('内存优化:清理旧历史记录');
|
||
while (undoStack.length > MAX_HISTORY * 0.6) {
|
||
undoStack.shift();
|
||
}
|
||
}
|
||
|
||
// 清理图片缓存
|
||
if (imageCache.size > MAX_CACHE_SIZE * 0.8) {
|
||
console.log('内存优化:清理图片缓存');
|
||
clearImageCache();
|
||
}
|
||
}
|
||
};
|
||
|
||
// 保存当前状态(带防抖和压缩)
|
||
function saveState(immediate = false) {
|
||
if (isUndoing) return;
|
||
|
||
// 清除之前的定时器
|
||
if (saveStateTimer) {
|
||
clearTimeout(saveStateTimer);
|
||
saveStateTimer = null;
|
||
}
|
||
|
||
const doSave = () => {
|
||
// 内存检查
|
||
memoryMonitor.check();
|
||
|
||
const state = captureState();
|
||
|
||
// 如果新状态与上一个状态相同,不保存
|
||
if (lastSavedState && statesEqual(state, lastSavedState)) {
|
||
return;
|
||
}
|
||
|
||
// 压缩状态后保存
|
||
const compressedState = compressState(state);
|
||
undoStack.push(compressedState);
|
||
lastSavedState = JSON.parse(JSON.stringify(compressedState));
|
||
|
||
// 限制历史记录数量
|
||
if (undoStack.length > MAX_HISTORY) {
|
||
undoStack.shift();
|
||
}
|
||
|
||
// 清空重做栈
|
||
redoStack.length = 0;
|
||
|
||
updateUndoRedoButtons();
|
||
};
|
||
|
||
if (immediate) {
|
||
doSave();
|
||
} else {
|
||
saveStateTimer = setTimeout(doSave, SAVE_DELAY);
|
||
}
|
||
}
|
||
|
||
// 捕获当前状态
|
||
function captureState() {
|
||
return {
|
||
// 背景图状态 - 使用图片标识而非完整 dataURL
|
||
bgImageSrc: bgImage.src,
|
||
bgImageScale: bgImageScale,
|
||
bgImageOpacity: bgImageOpacity,
|
||
imageOffsetX: imageOffsetX,
|
||
|
||
// 层叠图状态
|
||
overlays: Array.from(overlayLayer.querySelectorAll('.overlay-image')).map(el => {
|
||
const img = el.querySelector('img');
|
||
return {
|
||
layerId: el.dataset.layerId,
|
||
src: img ? img.src : '',
|
||
left: el.style.left,
|
||
top: el.style.top,
|
||
width: el.style.width,
|
||
height: el.style.height,
|
||
opacity: el.style.opacity,
|
||
transform: el.style.transform,
|
||
imgTransform: img ? img.style.transform : '',
|
||
isHidden: el.classList.contains('layer-hidden'),
|
||
isLocked: el.classList.contains('locked')
|
||
};
|
||
}),
|
||
|
||
// 文本输入框状态
|
||
textInputs: Array.from(document.querySelectorAll('.preview-input-layer .text-input')).map(el => ({
|
||
id: el.id,
|
||
content: el.innerText.replace('×', '').trim(),
|
||
left: el.style.left,
|
||
top: el.style.top,
|
||
color: el.style.color,
|
||
fontFamily: el.style.fontFamily,
|
||
fontSize: el.style.fontSize,
|
||
fontWeight: el.style.fontWeight,
|
||
fontStyle: el.style.fontStyle,
|
||
isLocked: el.dataset.locked === 'true',
|
||
isHidden: el.classList.contains('layer-hidden')
|
||
})),
|
||
|
||
// 背景图隐藏状态
|
||
bgImageHidden: bgImage.classList.contains('layer-hidden'),
|
||
|
||
// 添加时间戳用于状态管理
|
||
timestamp: Date.now()
|
||
};
|
||
}
|
||
|
||
// 比较两个状态是否相同(优化版)
|
||
function statesEqual(state1, state2) {
|
||
// 快速检查:时间戳
|
||
if (state1.timestamp && state2.timestamp && Math.abs(state1.timestamp - state2.timestamp) > 1000) {
|
||
return false;
|
||
}
|
||
|
||
// 比较关键属性
|
||
if (state1.bgImageSrc !== state2.bgImageSrc) return false;
|
||
if (state1.bgImageScale !== state2.bgImageScale) return false;
|
||
if (state1.imageOffsetX !== state2.imageOffsetX) return false;
|
||
|
||
// 使用 JSON 比较完整状态
|
||
const s1 = JSON.stringify({ ...state1, timestamp: undefined });
|
||
const s2 = JSON.stringify({ ...state2, timestamp: undefined });
|
||
return s1 === s2;
|
||
}
|
||
|
||
// 撤销
|
||
function undo() {
|
||
if (undoStack.length <= 1) return;
|
||
|
||
isUndoing = true;
|
||
|
||
// 将当前状态移到重做栈
|
||
const currentState = undoStack.pop();
|
||
redoStack.push(currentState);
|
||
|
||
// 恢复上一个状态
|
||
const prevState = undoStack[undoStack.length - 1];
|
||
restoreState(prevState);
|
||
|
||
updateUndoRedoButtons();
|
||
|
||
isUndoing = false;
|
||
}
|
||
|
||
// 重做
|
||
function redo() {
|
||
if (redoStack.length === 0) return;
|
||
|
||
isUndoing = true;
|
||
|
||
// 从重做栈恢复状态
|
||
const state = redoStack.pop();
|
||
undoStack.push(state);
|
||
|
||
restoreState(state);
|
||
|
||
updateUndoRedoButtons();
|
||
|
||
isUndoing = false;
|
||
}
|
||
|
||
// 恢复状态
|
||
function restoreState(state) {
|
||
// 恢复背景图
|
||
if (state.bgImageSrc) {
|
||
if (state.bgImageSrc !== bgImage.src) {
|
||
// 创建新图片对象确保加载完成
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
currentImage = img;
|
||
bgImage.src = state.bgImageSrc;
|
||
bgImageScale = state.bgImageScale;
|
||
bgImageOpacity = state.bgImageOpacity;
|
||
imageOffsetX = state.imageOffsetX;
|
||
|
||
// 恢复背景图隐藏状态
|
||
if (state.bgImageHidden) {
|
||
bgImage.classList.add('layer-hidden');
|
||
} else {
|
||
bgImage.classList.remove('layer-hidden');
|
||
}
|
||
|
||
bgScaleSlider.value = Math.round(bgImageScale * 100);
|
||
bgScaleValue.textContent = Math.round(bgImageScale * 100) + '%';
|
||
bgOpacitySlider.value = Math.round(bgImageOpacity * 100);
|
||
bgOpacityValue.textContent = Math.round(bgImageOpacity * 100) + '%';
|
||
|
||
updateBgImage();
|
||
|
||
// 更新UI状态
|
||
imageContainer.classList.remove('empty');
|
||
emptyTip.classList.add('hidden');
|
||
};
|
||
img.src = state.bgImageSrc;
|
||
} else {
|
||
// 相同的图片,直接更新参数
|
||
bgImageScale = state.bgImageScale;
|
||
bgImageOpacity = state.bgImageOpacity;
|
||
imageOffsetX = state.imageOffsetX;
|
||
|
||
// 恢复背景图隐藏状态
|
||
if (state.bgImageHidden) {
|
||
bgImage.classList.add('layer-hidden');
|
||
} else {
|
||
bgImage.classList.remove('layer-hidden');
|
||
}
|
||
|
||
bgScaleSlider.value = Math.round(bgImageScale * 100);
|
||
bgScaleValue.textContent = Math.round(bgImageScale * 100) + '%';
|
||
bgOpacitySlider.value = Math.round(bgImageOpacity * 100);
|
||
bgOpacityValue.textContent = Math.round(bgImageOpacity * 100) + '%';
|
||
|
||
updateBgImage();
|
||
}
|
||
} else {
|
||
// 没有背景图,清空
|
||
bgImage.src = '';
|
||
currentImage = null;
|
||
imageContainer.classList.add('empty');
|
||
emptyTip.classList.remove('hidden');
|
||
}
|
||
|
||
// 恢复层叠图
|
||
overlayLayer.innerHTML = '';
|
||
state.overlays.forEach(overlayData => {
|
||
// 创建新图片对象
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
createOverlayElementFromData(img, overlayData);
|
||
};
|
||
img.src = overlayData.src;
|
||
});
|
||
|
||
// 恢复文本输入框
|
||
const inputLayer = document.getElementById('previewInputLayer');
|
||
|
||
// 先隐藏所有预设输入框
|
||
[input1, input2, input3].forEach(inputEl => {
|
||
if (inputEl) {
|
||
inputEl.classList.add('hidden');
|
||
}
|
||
});
|
||
|
||
// 清除动态添加的输入框(保留预设的三个)
|
||
const allInputs = inputLayer.querySelectorAll('.text-input');
|
||
allInputs.forEach(input => {
|
||
if (input.id !== 'input1' && input.id !== 'input2' && input.id !== 'input3') {
|
||
input.remove();
|
||
}
|
||
});
|
||
|
||
state.textInputs.forEach(inputData => {
|
||
let input = document.getElementById(inputData.id);
|
||
if (!input) {
|
||
// 创建新的输入框
|
||
input = document.createElement('div');
|
||
input.id = inputData.id;
|
||
input.className = 'text-input';
|
||
input.contentEditable = false;
|
||
input.dataset.placeholder = '请输入';
|
||
inputLayer.appendChild(input);
|
||
initInput(input);
|
||
}
|
||
|
||
// 显示输入框
|
||
input.classList.remove('hidden');
|
||
|
||
// 设置文本内容
|
||
input.textContent = inputData.content;
|
||
|
||
// 应用样式
|
||
input.style.left = inputData.left;
|
||
input.style.top = inputData.top;
|
||
input.style.color = inputData.color || '#000000';
|
||
input.style.fontFamily = inputData.fontFamily || '';
|
||
input.style.fontSize = inputData.fontSize || '';
|
||
input.style.fontWeight = inputData.fontWeight || 'bold';
|
||
input.style.fontStyle = inputData.fontStyle || 'normal';
|
||
input.dataset.locked = inputData.isLocked ? 'true' : '';
|
||
|
||
// 更新锁定样式
|
||
if (inputData.isLocked) {
|
||
input.classList.add('locked');
|
||
} else {
|
||
input.classList.remove('locked');
|
||
}
|
||
|
||
// 恢复隐藏状态
|
||
if (inputData.isHidden) {
|
||
input.classList.add('layer-hidden');
|
||
} else {
|
||
input.classList.remove('layer-hidden');
|
||
}
|
||
|
||
// 检查是否需要显示 placeholder
|
||
if (!inputData.content.trim()) {
|
||
input.classList.add('show-placeholder');
|
||
} else {
|
||
input.classList.remove('show-placeholder');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 更新撤销重做按钮状态
|
||
function updateUndoRedoButtons() {
|
||
const undoBtn = document.getElementById('undoBtn');
|
||
const redoBtn = document.getElementById('redoBtn');
|
||
|
||
if (undoBtn) {
|
||
undoBtn.disabled = undoStack.length <= 1;
|
||
}
|
||
if (redoBtn) {
|
||
redoBtn.disabled = redoStack.length === 0;
|
||
}
|
||
}
|
||
|
||
// 加载默认背景图(路径从 config.js 读取)
|
||
function loadDefaultBackground() {
|
||
const defaultBgUrl = typeof DEFAULT_BG_IMAGE !== 'undefined' ? DEFAULT_BG_IMAGE : './images/default-bg.jpg';
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
currentImage = img;
|
||
bgImage.src = img.src;
|
||
imageContainer.classList.remove('empty');
|
||
emptyTip.classList.add('hidden');
|
||
// 计算图片尺寸和边界
|
||
calculateImageBounds();
|
||
// 背景图选择后默认居中显示
|
||
imageOffsetX = 0;
|
||
// 重置缩放
|
||
bgImageScale = 1;
|
||
bgScaleSlider.value = 100;
|
||
bgScaleValue.textContent = '100%';
|
||
// 重置透明度
|
||
bgImageOpacity = 1;
|
||
bgOpacitySlider.value = 100;
|
||
bgOpacityValue.textContent = '100%';
|
||
updateBgImage();
|
||
|
||
// 显示预设的文本输入框
|
||
showPresetTextInputs();
|
||
};
|
||
img.onerror = () => {
|
||
console.log('默认背景图加载失败,使用空状态');
|
||
};
|
||
img.src = defaultBgUrl;
|
||
}
|
||
|
||
// 加载图片
|
||
function loadImage(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
currentImage = img;
|
||
bgImage.src = img.src;
|
||
imageContainer.classList.remove('empty');
|
||
emptyTip.classList.add('hidden');
|
||
// 计算图片尺寸和边界
|
||
calculateImageBounds();
|
||
// 背景图选择后默认居中显示
|
||
imageOffsetX = 0;
|
||
// 重置缩放
|
||
bgImageScale = 1;
|
||
bgScaleSlider.value = 100;
|
||
bgScaleValue.textContent = '100%';
|
||
// 重置透明度
|
||
bgImageOpacity = 1;
|
||
bgOpacitySlider.value = 100;
|
||
bgOpacityValue.textContent = '100%';
|
||
updateBgImage();
|
||
|
||
// 显示预设的文本输入框
|
||
showPresetTextInputs();
|
||
|
||
// 保存状态(立即)
|
||
saveState(true);
|
||
};
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// 显示预设的文本输入框
|
||
function showPresetTextInputs() {
|
||
[input1, input2, input3].forEach(inputEl => {
|
||
if (inputEl) {
|
||
inputEl.classList.remove('hidden');
|
||
}
|
||
});
|
||
}
|
||
|
||
// 添加文本输入框
|
||
function addTextInput() {
|
||
const container = imageContainer;
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
|
||
const textEl = document.createElement('div');
|
||
textEl.className = 'text-input';
|
||
textEl.id = 'text_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
textEl.contentEditable = 'false';
|
||
textEl.textContent = '新文本';
|
||
|
||
const left = Math.random() * (containerWidth * 0.6) + containerWidth * 0.1;
|
||
const top = Math.random() * (containerHeight * 0.6) + containerHeight * 0.1;
|
||
textEl.style.left = (left / containerWidth * 100) + '%';
|
||
textEl.style.top = (top / containerHeight * 100) + '%';
|
||
|
||
const inputLayer = container.querySelector('.preview-input-layer');
|
||
inputLayer.appendChild(textEl);
|
||
|
||
initInput(textEl);
|
||
|
||
selectInput(textEl);
|
||
|
||
// 保存状态(立即)
|
||
saveState(true);
|
||
}
|
||
|
||
// 加载层叠图
|
||
function loadOverlayImage(file) {
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
createOverlayElement(img);
|
||
};
|
||
img.src = e.target.result;
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
|
||
// 创建层叠图元素
|
||
function createOverlayElement(img) {
|
||
const container = imageContainer;
|
||
const containerWidth = container.clientWidth;
|
||
const containerHeight = container.clientHeight;
|
||
|
||
// 计算初始尺寸(不超过容器1/3)
|
||
let width = img.naturalWidth;
|
||
let height = img.naturalHeight;
|
||
const maxSize = Math.min(containerWidth, containerHeight) / 2;
|
||
|
||
if (width > maxSize || height > maxSize) {
|
||
if (width > height) {
|
||
height = (height / width) * maxSize;
|
||
width = maxSize;
|
||
} else {
|
||
width = (width / height) * maxSize;
|
||
height = maxSize;
|
||
}
|
||
}
|
||
|
||
// 创建层叠图元素
|
||
const overlayEl = document.createElement('div');
|
||
overlayEl.className = 'overlay-image';
|
||
overlayEl.dataset.layerId = 'overlay_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
overlayEl.dataset.scale = 1;
|
||
|
||
const imgEl = document.createElement('img');
|
||
imgEl.src = img.src;
|
||
imgEl.style.width = '100%';
|
||
imgEl.style.height = '100%';
|
||
imgEl.style.objectFit = 'contain';
|
||
overlayEl.appendChild(imgEl);
|
||
|
||
// 居中显示
|
||
overlayEl.style.left = (containerWidth / 2 - width / 2) + 'px';
|
||
overlayEl.style.top = (containerHeight / 2 - height / 2) + 'px';
|
||
overlayEl.style.width = width + 'px';
|
||
overlayEl.style.height = height + 'px';
|
||
|
||
// 初始化拖拽和缩放
|
||
initOverlayImage(overlayEl);
|
||
|
||
overlayLayer.appendChild(overlayEl);
|
||
|
||
// 选中新添加的层叠图
|
||
selectOverlay(overlayEl);
|
||
|
||
// 保存状态(立即)
|
||
saveState(true);
|
||
}
|
||
|
||
// 从数据创建层叠图元素(用于撤销恢复)
|
||
function createOverlayElementFromData(img, overlayData) {
|
||
// 创建层叠图元素
|
||
const overlayEl = document.createElement('div');
|
||
overlayEl.className = 'overlay-image';
|
||
overlayEl.dataset.layerId = overlayData.layerId || 'overlay_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
||
overlayEl.dataset.scale = 1;
|
||
|
||
const imgEl = document.createElement('img');
|
||
imgEl.src = img.src;
|
||
imgEl.style.width = '100%';
|
||
imgEl.style.height = '100%';
|
||
imgEl.style.objectFit = 'contain';
|
||
imgEl.style.transform = overlayData.imgTransform || '';
|
||
overlayEl.appendChild(imgEl);
|
||
|
||
// 恢复位置和尺寸
|
||
overlayEl.style.left = overlayData.left;
|
||
overlayEl.style.top = overlayData.top;
|
||
overlayEl.style.width = overlayData.width;
|
||
overlayEl.style.height = overlayData.height;
|
||
overlayEl.style.opacity = overlayData.opacity || '1';
|
||
overlayEl.style.transform = overlayData.transform || '';
|
||
|
||
// 恢复隐藏状态
|
||
if (overlayData.isHidden) {
|
||
overlayEl.classList.add('layer-hidden');
|
||
}
|
||
|
||
// 恢复锁定状态
|
||
if (overlayData.isLocked) {
|
||
overlayEl.classList.add('locked');
|
||
}
|
||
|
||
// 初始化拖拽和缩放
|
||
initOverlayImage(overlayEl);
|
||
|
||
overlayLayer.appendChild(overlayEl);
|
||
}
|
||
|
||
// 初始化层叠图交互
|
||
function initOverlayImage(overlayEl) {
|
||
let isDragging = false;
|
||
let isResizing = false;
|
||
let startX, startY, startLeft, startTop, startWidth, startHeight;
|
||
const minSize = 30;
|
||
|
||
// 点击选中(区分点击和拖拽)
|
||
overlayEl.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
selectOverlay(overlayEl);
|
||
});
|
||
|
||
// 拖拽移动(不选中也可拖动)
|
||
overlayEl.addEventListener('mousedown', handleDragStart);
|
||
overlayEl.addEventListener('touchstart', handleDragStart, { passive: false });
|
||
|
||
function handleDragStart(e) {
|
||
if (e.target.classList.contains('delete-btn') || e.target.classList.contains('resize-handle')) return;
|
||
|
||
e.stopPropagation(); // 阻止事件冒泡到 imageContainer
|
||
|
||
const isTouch = e.type === 'touchstart';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
isDragging = true;
|
||
startX = clientX;
|
||
startY = clientY;
|
||
startLeft = overlayEl.offsetLeft;
|
||
startTop = overlayEl.offsetTop;
|
||
|
||
overlayEl.classList.add('dragging');
|
||
|
||
// 拖动时设置为选中状态
|
||
selectOverlay(overlayEl);
|
||
|
||
document.addEventListener('mousemove', handleDragMove);
|
||
document.addEventListener('mouseup', handleDragEnd);
|
||
document.addEventListener('touchmove', handleDragMove, { passive: false });
|
||
document.addEventListener('touchend', handleDragEnd);
|
||
}
|
||
|
||
function handleDragMove(e) {
|
||
if (!isDragging) return;
|
||
|
||
const isTouch = e.type === 'touchmove';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
const dx = clientX - startX;
|
||
const dy = clientY - startY;
|
||
|
||
// 移除移动距离限制,实现 1px 精确移动
|
||
let newLeft = startLeft + dx;
|
||
let newTop = startTop + dy;
|
||
|
||
// 不启用吸附,只限制边界,但显示辅助线
|
||
const snapped = getSnapPosition(overlayEl, newLeft, newTop, false);
|
||
newLeft = snapped.left;
|
||
newTop = snapped.top;
|
||
|
||
// 显示对齐辅助线
|
||
showAlignmentGuides(overlayEl);
|
||
|
||
overlayEl.style.left = newLeft + 'px';
|
||
overlayEl.style.top = newTop + 'px';
|
||
|
||
e.preventDefault();
|
||
}
|
||
|
||
function handleDragEnd() {
|
||
isDragging = false;
|
||
overlayEl.classList.remove('dragging');
|
||
hideGuides();
|
||
|
||
document.removeEventListener('mousemove', handleDragMove);
|
||
document.removeEventListener('mouseup', handleDragEnd);
|
||
document.removeEventListener('touchmove', handleDragMove);
|
||
document.removeEventListener('touchend', handleDragEnd);
|
||
|
||
// 保存状态
|
||
saveState();
|
||
}
|
||
|
||
// 缩放控制(四角)
|
||
const corners = ['tl', 'tr', 'bl', 'br'];
|
||
corners.forEach(corner => {
|
||
const handle = document.createElement('div');
|
||
handle.className = 'resize-handle resize-' + corner;
|
||
handle.style.cssText = `
|
||
position: absolute;
|
||
width: 12px;
|
||
height: 12px;
|
||
background: #4a90e2;
|
||
border: 1px solid #fff;
|
||
`;
|
||
|
||
switch(corner) {
|
||
case 'tl': handle.style.top = '-6px'; handle.style.left = '-6px'; handle.style.cursor = 'nw-resize'; break;
|
||
case 'tr': handle.style.top = '-6px'; handle.style.right = '-6px'; handle.style.cursor = 'ne-resize'; break;
|
||
case 'bl': handle.style.bottom = '-6px'; handle.style.left = '-6px'; handle.style.cursor = 'sw-resize'; break;
|
||
case 'br': handle.style.bottom = '-6px'; handle.style.right = '-6px'; handle.style.cursor = 'se-resize'; break;
|
||
}
|
||
|
||
handle.addEventListener('mousedown', startResize);
|
||
handle.addEventListener('touchstart', startResize, { passive: false });
|
||
|
||
overlayEl.appendChild(handle);
|
||
|
||
function startResize(e) {
|
||
e.stopPropagation();
|
||
isResizing = true;
|
||
const isTouch = e.type === 'touchstart';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
startX = clientX;
|
||
startY = clientY;
|
||
startLeft = overlayEl.offsetLeft;
|
||
startTop = overlayEl.offsetTop;
|
||
startWidth = overlayEl.offsetWidth;
|
||
startHeight = overlayEl.offsetHeight;
|
||
|
||
document.addEventListener('mousemove', handleResize);
|
||
document.addEventListener('mouseup', endResize);
|
||
document.addEventListener('touchmove', handleResize, { passive: false });
|
||
document.addEventListener('touchend', endResize);
|
||
|
||
e.preventDefault();
|
||
}
|
||
|
||
function handleResize(e) {
|
||
if (!isResizing) return;
|
||
|
||
const isTouch = e.type === 'touchmove';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
const dx = clientX - startX;
|
||
const dy = clientY - startY;
|
||
|
||
let newWidth = startWidth;
|
||
let newHeight = startHeight;
|
||
let newLeft = startLeft;
|
||
let newTop = startTop;
|
||
|
||
const aspectRatio = startWidth / startHeight;
|
||
|
||
// 根据角落调整尺寸
|
||
if (corner === 'br') {
|
||
newWidth = Math.max(minSize, startWidth + dx);
|
||
newHeight = newWidth / aspectRatio;
|
||
} else if (corner === 'bl') {
|
||
newWidth = Math.max(minSize, startWidth - dx);
|
||
newHeight = newWidth / aspectRatio;
|
||
newLeft = startLeft - (newWidth - startWidth);
|
||
} else if (corner === 'tr') {
|
||
newWidth = Math.max(minSize, startWidth + dx);
|
||
newHeight = newWidth / aspectRatio;
|
||
newTop = startTop - (newHeight - startHeight);
|
||
} else if (corner === 'tl') {
|
||
newWidth = Math.max(minSize, startWidth - dx);
|
||
newHeight = newWidth / aspectRatio;
|
||
newLeft = startLeft - (newWidth - startWidth);
|
||
newTop = startTop - (newHeight - startHeight);
|
||
}
|
||
|
||
overlayEl.style.width = newWidth + 'px';
|
||
overlayEl.style.height = newHeight + 'px';
|
||
overlayEl.style.left = newLeft + 'px';
|
||
overlayEl.style.top = newTop + 'px';
|
||
|
||
e.preventDefault();
|
||
}
|
||
|
||
function endResize() {
|
||
isResizing = false;
|
||
document.removeEventListener('mousemove', handleResize);
|
||
document.removeEventListener('mouseup', endResize);
|
||
document.removeEventListener('touchmove', handleResize);
|
||
document.removeEventListener('touchend', endResize);
|
||
|
||
// 保存状态
|
||
saveState();
|
||
}
|
||
});
|
||
}
|
||
|
||
// 选中层叠图(同时取消输入框选中)
|
||
let selectedOverlay = null;
|
||
function selectOverlay(overlayEl) {
|
||
if (selectedInput) {
|
||
selectedInput.classList.remove('selected');
|
||
selectedInput = null;
|
||
hidePositionInfo();
|
||
}
|
||
if (editingInput) {
|
||
endEdit(editingInput);
|
||
}
|
||
if (selectedOverlay && selectedOverlay !== overlayEl) {
|
||
selectedOverlay.classList.remove('selected');
|
||
}
|
||
selectedOverlay = overlayEl;
|
||
overlayEl.classList.add('selected');
|
||
|
||
// 显示编辑器工具栏和图片工具
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.add('active');
|
||
bgTools.classList.remove('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 更新透明度滑块为当前层叠图的透明度
|
||
const currentOpacity = overlayEl.style.opacity || '1';
|
||
opacitySlider.value = parseFloat(currentOpacity) * 100;
|
||
opacityValue.textContent = opacitySlider.value + '%';
|
||
|
||
// 更新旋转滑块为当前层叠图的旋转角度
|
||
const currentTransform = overlayEl.style.transform || '';
|
||
const rotateMatch = currentTransform.match(/rotate\(([-\d.]+)deg\)/);
|
||
const currentRotate = rotateMatch ? parseFloat(rotateMatch[1]) : 0;
|
||
rotateSlider.value = currentRotate;
|
||
rotateValue.textContent = currentRotate + '°';
|
||
|
||
// 更新镜像按钮状态
|
||
const img = overlayEl.querySelector('img');
|
||
if (img) {
|
||
const imgTransform = img.style.transform || '';
|
||
mirrorHBtn.classList.toggle('active', imgTransform.includes('scaleX(-1)'));
|
||
}
|
||
}
|
||
|
||
// 计算图片滑动边界
|
||
function calculateImageBounds() {
|
||
if (!currentImage) return;
|
||
|
||
const containerWidth = imageContainer.clientWidth;
|
||
const containerHeight = imageContainer.clientHeight;
|
||
const imgRatio = currentImage.naturalWidth / currentImage.naturalHeight;
|
||
const containerRatio = containerWidth / containerHeight;
|
||
|
||
let imgWidth, imgHeight;
|
||
|
||
if (imgRatio > containerRatio) {
|
||
// 图片更宽,以高度为基准计算宽度
|
||
imgHeight = containerHeight;
|
||
imgWidth = imgHeight * imgRatio;
|
||
} else {
|
||
// 图片更高或等比,以宽度为基准
|
||
imgWidth = containerWidth;
|
||
imgHeight = imgWidth / imgRatio;
|
||
}
|
||
// 计算滑动边界:严格以图片边缘为边界,不能露出白板
|
||
// 滑动公式:imageOffsetX = imageStartOffsetX + deltaX
|
||
// 手指向右滑动(deltaX>0) → imageOffsetX增大 → 图片右移,显示右侧
|
||
// 手指向左滑动(deltaX<0) → imageOffsetX减小 → 图片左移,显示左侧
|
||
// 初始位置居中:imageOffsetX = 0,此时 baseLeft 已使图片居中
|
||
if (imgWidth > containerWidth) {
|
||
// 图片宽度大于容器,可以滑动
|
||
// minOffsetX = -(imgWidth - containerWidth) / 2:向左滑到极限(显示左侧)
|
||
// maxOffsetX = (imgWidth - containerWidth) / 2:向右滑到极限(显示右侧)
|
||
const halfRange = (imgWidth - containerWidth) / 2;
|
||
minOffsetX = -halfRange;
|
||
maxOffsetX = halfRange;
|
||
} else {
|
||
// 图片宽度小于等于容器,居中显示,不能滑动
|
||
minOffsetX = 0;
|
||
maxOffsetX = 0;
|
||
}
|
||
}
|
||
|
||
// 更新背景图(缩放和透明度)
|
||
function updateBgImage() {
|
||
if (!currentImage) return;
|
||
|
||
const containerWidth = imageContainer.clientWidth;
|
||
const containerHeight = imageContainer.clientHeight;
|
||
const imgRatio = currentImage.naturalWidth / currentImage.naturalHeight;
|
||
const containerRatio = containerWidth / containerHeight;
|
||
|
||
let imgWidth, imgHeight;
|
||
|
||
if (imgRatio > containerRatio) {
|
||
imgHeight = containerHeight;
|
||
imgWidth = imgHeight * imgRatio;
|
||
} else {
|
||
imgWidth = containerWidth;
|
||
imgHeight = imgWidth / imgRatio;
|
||
}
|
||
|
||
// 应用缩放
|
||
bgImage.style.width = (imgWidth * bgImageScale) + 'px';
|
||
bgImage.style.height = (imgHeight * bgImageScale) + 'px';
|
||
|
||
// 应用透明度
|
||
bgImage.style.opacity = bgImageOpacity;
|
||
|
||
// 重新计算边界
|
||
const scaledWidth = imgWidth * bgImageScale;
|
||
const scaledHeight = imgHeight * bgImageScale;
|
||
if (scaledWidth > containerWidth) {
|
||
minOffsetX = -(scaledWidth - containerWidth) / 2;
|
||
maxOffsetX = (scaledWidth - containerWidth) / 2;
|
||
} else {
|
||
minOffsetX = 0;
|
||
maxOffsetX = 0;
|
||
}
|
||
// 限制当前位置在新范围内
|
||
imageOffsetX = Math.max(minOffsetX, Math.min(maxOffsetX, imageOffsetX));
|
||
|
||
// 重新应用位置(居中 + 偏移)
|
||
const baseLeft = (containerWidth - scaledWidth) / 2;
|
||
bgImage.style.left = (baseLeft + imageOffsetX) + 'px';
|
||
bgImage.style.top = ((containerHeight - scaledHeight) / 2) + 'px';
|
||
}
|
||
|
||
// 更新图片位置
|
||
function updateImagePosition() {
|
||
if (!currentImage) return;
|
||
|
||
const containerWidth = imageContainer.clientWidth;
|
||
const containerHeight = imageContainer.clientHeight;
|
||
const imgRatio = currentImage.naturalWidth / currentImage.naturalHeight;
|
||
|
||
let imgWidth, imgHeight;
|
||
|
||
if (imgRatio > containerWidth / containerHeight) {
|
||
imgHeight = containerHeight;
|
||
imgWidth = imgHeight * imgRatio;
|
||
} else {
|
||
imgWidth = containerWidth;
|
||
imgHeight = imgWidth / imgRatio;
|
||
}
|
||
|
||
// 应用缩放
|
||
const scaledWidth = imgWidth * bgImageScale;
|
||
const scaledHeight = imgHeight * bgImageScale;
|
||
|
||
// 限制滑动范围
|
||
if (scaledWidth > containerWidth) {
|
||
minOffsetX = -(scaledWidth - containerWidth) / 2;
|
||
maxOffsetX = (scaledWidth - containerWidth) / 2;
|
||
} else {
|
||
minOffsetX = 0;
|
||
maxOffsetX = 0;
|
||
}
|
||
imageOffsetX = Math.max(minOffsetX, Math.min(maxOffsetX, imageOffsetX));
|
||
|
||
// 应用位置
|
||
const baseLeft = (containerWidth - scaledWidth) / 2;
|
||
bgImage.style.width = scaledWidth + 'px';
|
||
bgImage.style.height = scaledHeight + 'px';
|
||
bgImage.style.left = (baseLeft + imageOffsetX) + 'px';
|
||
bgImage.style.top = ((containerHeight - scaledHeight) / 2) + 'px';
|
||
bgImage.style.opacity = bgImageOpacity;
|
||
bgImage.style.transform = 'none';
|
||
}
|
||
|
||
// 初始化图片滑动调整位置
|
||
function initImageDrag() {
|
||
// 鼠标按下
|
||
imageContainer.addEventListener('mousedown', handleImageStart);
|
||
imageContainer.addEventListener('touchstart', handleImageStart, { passive: false });
|
||
|
||
// 鼠标移动
|
||
document.addEventListener('mousemove', handleImageMove);
|
||
document.addEventListener('touchmove', handleImageMove, { passive: false });
|
||
|
||
// 鼠标释放
|
||
document.addEventListener('mouseup', handleImageEnd);
|
||
document.addEventListener('touchend', handleImageEnd);
|
||
|
||
// 点击背景图显示工具栏
|
||
bgImage.addEventListener('click', (e) => {
|
||
if (!currentImage) return;
|
||
|
||
// 取消输入框和层叠图的选中状态
|
||
if (editingInput) {
|
||
endEdit(editingInput);
|
||
}
|
||
if (selectedInput) {
|
||
selectedInput.classList.remove('selected');
|
||
selectedInput = null;
|
||
hidePositionInfo();
|
||
}
|
||
if (selectedOverlay) {
|
||
selectedOverlay.classList.remove('selected');
|
||
selectedOverlay = null;
|
||
}
|
||
|
||
e.stopPropagation();
|
||
|
||
isInteractingWithBgImage = true;
|
||
|
||
// 显示背景图工具栏
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.add('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 同步滑块值
|
||
bgScaleSlider.value = bgImageScale * 100;
|
||
bgScaleValue.textContent = Math.round(bgImageScale * 100) + '%';
|
||
bgOpacitySlider.value = bgImageOpacity * 100;
|
||
bgOpacityValue.textContent = Math.round(bgImageOpacity * 100) + '%';
|
||
|
||
setTimeout(() => {
|
||
isInteractingWithBgImage = false;
|
||
}, 100);
|
||
});
|
||
|
||
bgLayer.addEventListener('click', (e) => {
|
||
if (!currentImage) return;
|
||
|
||
// 取消输入框和层叠图的选中状态
|
||
if (editingInput) {
|
||
endEdit(editingInput);
|
||
}
|
||
if (selectedInput) {
|
||
selectedInput.classList.remove('selected');
|
||
selectedInput = null;
|
||
hidePositionInfo();
|
||
}
|
||
if (selectedOverlay) {
|
||
selectedOverlay.classList.remove('selected');
|
||
selectedOverlay = null;
|
||
}
|
||
|
||
e.stopPropagation();
|
||
|
||
isInteractingWithBgImage = true;
|
||
|
||
// 显示背景图工具栏
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.add('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 同步滑块值
|
||
bgScaleSlider.value = bgImageScale * 100;
|
||
bgScaleValue.textContent = Math.round(bgImageScale * 100) + '%';
|
||
bgOpacitySlider.value = bgImageOpacity * 100;
|
||
bgOpacityValue.textContent = Math.round(bgImageOpacity * 100) + '%';
|
||
|
||
setTimeout(() => {
|
||
isInteractingWithBgImage = false;
|
||
}, 100);
|
||
});
|
||
|
||
function handleImageStart(e) {
|
||
// 如果点击的是输入框,不处理图片滑动
|
||
if (e.target.closest('.text-input')) {
|
||
return;
|
||
}
|
||
|
||
// 如果正在编辑,先退出编辑状态
|
||
if (editingInput) {
|
||
deselectAll();
|
||
}
|
||
|
||
// 如果没有图片,不处理
|
||
if (!currentImage) {
|
||
return;
|
||
}
|
||
|
||
const isTouch = e.type === 'touchstart';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
|
||
// 只有点击图片本身或背景图层时才启动图片滑动
|
||
const isImageOrBgLayer = e.target === bgImage ||
|
||
e.target === bgLayer ||
|
||
e.target === imageContainer;
|
||
|
||
if (!isImageOrBgLayer) {
|
||
return;
|
||
}
|
||
|
||
isDraggingImage = true;
|
||
isInteractingWithBgImage = true;
|
||
imageStartX = clientX;
|
||
imageStartOffsetX = imageOffsetX;
|
||
|
||
// 显示背景图工具栏
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.add('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 同步滑块值
|
||
bgScaleSlider.value = bgImageScale * 100;
|
||
bgScaleValue.textContent = Math.round(bgImageScale * 100) + '%';
|
||
bgOpacitySlider.value = bgImageOpacity * 100;
|
||
bgOpacityValue.textContent = Math.round(bgImageOpacity * 100) + '%';
|
||
|
||
if (isTouch) {
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handleImageMove(e) {
|
||
if (!isDraggingImage) return;
|
||
|
||
const isTouch = e.type === 'touchmove';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
|
||
const deltaX = clientX - imageStartX;
|
||
// 反转拖拽:手指向右滑动(deltaX>0) → imageOffsetX增大 → 图片右移,显示右侧
|
||
// 手指向左滑动(deltaX<0) → imageOffsetX减小 → 图片左移,显示左侧
|
||
imageOffsetX = imageStartOffsetX + deltaX;
|
||
|
||
updateImagePosition();
|
||
|
||
if (isTouch) {
|
||
e.preventDefault();
|
||
}
|
||
}
|
||
|
||
function handleImageEnd() {
|
||
if (isDraggingImage) {
|
||
// 保存状态(拖拽结束只保存一次)
|
||
saveState(true);
|
||
}
|
||
isDraggingImage = false;
|
||
isInteractingWithBgImage = false;
|
||
}
|
||
}
|
||
|
||
// ==================== 输入框位置信息显示 ====================
|
||
var positionInfoEl = null;
|
||
|
||
// 创建位置信息提示元素
|
||
function ensurePositionInfo() {
|
||
if (!positionInfoEl) {
|
||
positionInfoEl = document.createElement('div');
|
||
positionInfoEl.className = 'input-position-info';
|
||
positionInfoEl.style.cssText = 'display:none;position:fixed;padding:2px 6px;background:rgba(0,0,0,0.45);color:#fff;font-size:11px;border-radius:3px;pointer-events:none;z-index:10000;white-space:nowrap;font-family:monospace;';
|
||
document.body.appendChild(positionInfoEl);
|
||
}
|
||
return positionInfoEl;
|
||
}
|
||
|
||
// 显示并更新位置信息
|
||
function showPositionInfo(element) {
|
||
var info = ensurePositionInfo();
|
||
var left = parseFloat(element.style.left) || 0;
|
||
var top = parseFloat(element.style.top) || 0;
|
||
info.textContent = 'left: ' + left.toFixed(1) + 'px top: ' + top.toFixed(1) + 'px';
|
||
|
||
// 将 info 定位在输入框上方
|
||
var rect = element.getBoundingClientRect();
|
||
info.style.left = (rect.left + rect.width / 2 - info.offsetWidth / 2) + 'px';
|
||
info.style.top = (rect.top - 30) + 'px';
|
||
info.style.display = 'block';
|
||
}
|
||
|
||
// 更新位置信息(拖拽中实时调用)
|
||
function updatePositionInfo(element, left, top) {
|
||
var info = ensurePositionInfo();
|
||
info.textContent = 'left: ' + left.toFixed(1) + 'px top: ' + top.toFixed(1) + 'px';
|
||
info.style.left = (parseFloat(info.style.left)) + 'px';
|
||
|
||
// 重新定位到输入框上方
|
||
var rect = element.getBoundingClientRect();
|
||
info.style.left = (rect.left + rect.width / 2 - info.offsetWidth / 2) + 'px';
|
||
info.style.top = (rect.top - 30) + 'px';
|
||
}
|
||
|
||
// 隐藏位置信息
|
||
function hidePositionInfo() {
|
||
if (positionInfoEl) {
|
||
positionInfoEl.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// 初始化输入框 - 每个输入框独立管理自己的拖拽状态
|
||
function initInput(element) {
|
||
let isMouseDown = false;
|
||
let isDragging = false;
|
||
let dragTimer = null;
|
||
let startX, startY, startLeft, startTop;
|
||
let clickStartTime = 0;
|
||
let clickStartX = 0;
|
||
let clickStartY = 0;
|
||
let hasMoved = false;
|
||
|
||
// 检查是否需要显示 placeholder
|
||
function updatePlaceholder() {
|
||
// 获取纯文本内容(排除删除按钮)
|
||
const textContent = Array.from(element.childNodes)
|
||
.filter(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('text-delete-btn')))
|
||
.map(node => node.textContent)
|
||
.join('')
|
||
.trim();
|
||
|
||
if (!textContent) {
|
||
element.classList.add('show-placeholder');
|
||
} else {
|
||
element.classList.remove('show-placeholder');
|
||
}
|
||
}
|
||
|
||
// 初始化时检查
|
||
updatePlaceholder();
|
||
|
||
// 监听输入事件,控制 placeholder 显示
|
||
element.addEventListener('input', updatePlaceholder);
|
||
|
||
// 鼠标/触摸按下
|
||
element.addEventListener('mousedown', handleStart);
|
||
element.addEventListener('touchstart', handleStart, { passive: false });
|
||
|
||
function handleStart(e) {
|
||
const isTouch = e.type === 'touchstart';
|
||
|
||
// 如果正在编辑此输入框,不处理拖拽事件,让浏览器处理文本选择
|
||
if (editingInput === element) {
|
||
return;
|
||
}
|
||
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
isMouseDown = true;
|
||
clickStartTime = Date.now();
|
||
clickStartX = clientX;
|
||
clickStartY = clientY;
|
||
hasMoved = false;
|
||
isDragging = false;
|
||
|
||
// 选中此输入框
|
||
selectInput(element);
|
||
|
||
startX = clientX;
|
||
startY = clientY;
|
||
const containerRect = imageContainer.getBoundingClientRect();
|
||
const elemRect = element.getBoundingClientRect();
|
||
startLeft = elemRect.left - containerRect.left;
|
||
startTop = elemRect.top - containerRect.top;
|
||
|
||
// 延迟判断是否为拖拽
|
||
dragTimer = setTimeout(() => {
|
||
if (isMouseDown) {
|
||
isDragging = true;
|
||
element.classList.add('dragging');
|
||
}
|
||
}, 150);
|
||
|
||
// 阻止事件冒泡,避免触发图片滑动
|
||
e.stopPropagation();
|
||
}
|
||
|
||
// 鼠标/触摸移动
|
||
element.addEventListener('mousemove', handleMove);
|
||
element.addEventListener('touchmove', handleMove, { passive: false });
|
||
|
||
function handleMove(e) {
|
||
// 如果正在编辑,不拦截事件,让浏览器处理文本选择
|
||
if (editingInput === element) {
|
||
return;
|
||
}
|
||
|
||
// 如果已锁定,禁止拖拽
|
||
if (element.classList.contains('locked')) {
|
||
return;
|
||
}
|
||
|
||
if (!isMouseDown) return;
|
||
if (!selectedInput) return;
|
||
|
||
const isTouch = e.type === 'touchmove';
|
||
const clientX = isTouch ? e.touches[0].clientX : e.clientX;
|
||
const clientY = isTouch ? e.touches[0].clientY : e.clientY;
|
||
|
||
const moveDistance = Math.sqrt(
|
||
Math.pow(clientX - clickStartX, 2) +
|
||
Math.pow(clientY - clickStartY, 2)
|
||
);
|
||
|
||
// 只要有移动就开始拖拽,实现 1px 精确移动
|
||
if (moveDistance > 0) {
|
||
hasMoved = true;
|
||
if (dragTimer) {
|
||
clearTimeout(dragTimer);
|
||
dragTimer = null;
|
||
}
|
||
if (!isDragging) {
|
||
isDragging = true;
|
||
element.classList.add('dragging');
|
||
}
|
||
}
|
||
|
||
if (isDragging) {
|
||
const dx = clientX - startX;
|
||
const dy = clientY - startY;
|
||
const newLeft = startLeft + dx;
|
||
const newTop = startTop + dy;
|
||
|
||
// 不启用吸附,只限制边界,但显示辅助线
|
||
const snapped = getSnapPosition(element, newLeft, newTop, false);
|
||
element.style.left = snapped.left + 'px';
|
||
element.style.top = snapped.top + 'px';
|
||
|
||
// 实时更新位置信息显示
|
||
updatePositionInfo(element, snapped.left, snapped.top);
|
||
|
||
// 显示对齐辅助线
|
||
showAlignmentGuides(element);
|
||
}
|
||
|
||
// 阻止事件冒泡,避免触发图片滑动
|
||
e.stopPropagation();
|
||
}
|
||
|
||
// 鼠标/触摸释放
|
||
element.addEventListener('mouseup', handleEnd);
|
||
element.addEventListener('touchend', handleEnd);
|
||
|
||
element.addEventListener('mouseleave', (e) => {
|
||
if (isMouseDown) {
|
||
handleEnd(e);
|
||
}
|
||
});
|
||
|
||
function handleEnd(e) {
|
||
isMouseDown = false;
|
||
|
||
if (dragTimer) {
|
||
clearTimeout(dragTimer);
|
||
dragTimer = null;
|
||
}
|
||
|
||
if (isDragging) {
|
||
isDragging = false;
|
||
element.classList.remove('dragging');
|
||
hideGuides();
|
||
|
||
// 保存状态(拖拽结束只保存一次,使用 immediate 模式)
|
||
saveState(true);
|
||
}
|
||
|
||
// 阻止事件冒泡
|
||
if (e) {
|
||
e.stopPropagation();
|
||
}
|
||
}
|
||
|
||
// 双击进入编辑模式(电脑端)
|
||
element.addEventListener('dblclick', (e) => {
|
||
e.stopPropagation();
|
||
startEdit(element);
|
||
element.dataset.justEdited = 'true';
|
||
setTimeout(() => { element.dataset.justEdited = 'false'; }, 200);
|
||
});
|
||
|
||
// 单击进入编辑模式(同时支持桌面和移动端)
|
||
element.addEventListener('click', (e) => {
|
||
if (hasMoved) return;
|
||
if (element.dataset.justEdited === 'true') return;
|
||
|
||
// 如果已经在编辑中,不重复进入
|
||
if (editingInput === element) return;
|
||
|
||
// 阻止事件冒泡,避免触发容器点击
|
||
e.stopPropagation();
|
||
|
||
// 进入编辑模式(selectInput 已在 mousedown 中调用)
|
||
startEdit(element);
|
||
});
|
||
}
|
||
|
||
// 选中输入框
|
||
function selectInput(element) {
|
||
if (selectedOverlay) {
|
||
selectedOverlay.classList.remove('selected');
|
||
selectedOverlay = null;
|
||
}
|
||
if (editingInput && editingInput !== element) {
|
||
endEdit(editingInput);
|
||
}
|
||
|
||
if (selectedInput && selectedInput !== element) {
|
||
selectedInput.classList.remove('selected');
|
||
}
|
||
|
||
selectedInput = element;
|
||
element.classList.add('selected');
|
||
element.classList.remove('editing');
|
||
element.contentEditable = 'false';
|
||
|
||
// 显示编辑器工具栏和文本工具
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.add('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.remove('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 更新工具栏状态
|
||
updateToolbarState(element);
|
||
|
||
// 显示位置信息
|
||
showPositionInfo(element);
|
||
}
|
||
|
||
// 开始编辑
|
||
function startEdit(element) {
|
||
if (editingInput && editingInput !== element) {
|
||
endEdit(editingInput);
|
||
}
|
||
|
||
editingInput = element;
|
||
selectedInput = element;
|
||
|
||
element.classList.add('editing');
|
||
element.classList.remove('selected');
|
||
element.contentEditable = 'true';
|
||
|
||
// 显示编辑器工具栏和文本工具
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.add('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.remove('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
|
||
// 移动端/编辑模式:固定在顶部始终可见
|
||
if (window.innerWidth <= 768 || 'ontouchstart' in window) {
|
||
editorToolbar.classList.add('editing-mode');
|
||
// 滚动使工具栏和编辑元素可见
|
||
setTimeout(() => {
|
||
const rect = element.getBoundingClientRect();
|
||
const toolbarHeight = editorToolbar.offsetHeight || 50;
|
||
// 如果文本被工具栏遮挡,滚动页面
|
||
if (rect.top < toolbarHeight + 10) {
|
||
window.scrollBy({ top: rect.top - toolbarHeight - 20, behavior: 'smooth' });
|
||
}
|
||
}, 300); // 等待键盘弹出
|
||
}
|
||
|
||
// 更新工具栏状态
|
||
updateToolbarState(element);
|
||
|
||
// 立即 focus 以触发键盘(必须在用户交互的同步上下文中)
|
||
element.focus();
|
||
|
||
// 如果没有选中文本,则定位光标到末尾
|
||
setTimeout(() => {
|
||
const sel = window.getSelection();
|
||
if (sel.isCollapsed) {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(element);
|
||
range.collapse(false);
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
}, 0);
|
||
|
||
// 使用 Virtual Keyboard API(如果支持)
|
||
if ('virtualKeyboard' in navigator) {
|
||
navigator.virtualKeyboard.show();
|
||
}
|
||
}
|
||
|
||
// 结束编辑
|
||
function endEdit(element) {
|
||
element.classList.remove('editing');
|
||
element.classList.add('selected');
|
||
element.contentEditable = 'false';
|
||
|
||
// 移除移动端编辑模式样式
|
||
editorToolbar.classList.remove('editing-mode');
|
||
|
||
// 检查是否需要显示 placeholder
|
||
const textContent = Array.from(element.childNodes)
|
||
.filter(node => node.nodeType === Node.TEXT_NODE || (node.nodeType === Node.ELEMENT_NODE && !node.classList.contains('text-delete-btn')))
|
||
.map(node => node.textContent)
|
||
.join('')
|
||
.trim();
|
||
|
||
if (!textContent) {
|
||
element.classList.add('show-placeholder');
|
||
} else {
|
||
element.classList.remove('show-placeholder');
|
||
}
|
||
|
||
element.blur();
|
||
editingInput = null;
|
||
// 隐藏编辑器工具栏
|
||
editorToolbar.classList.remove('active');
|
||
|
||
// 保存状态
|
||
saveState();
|
||
}
|
||
|
||
// 更新工具栏状态
|
||
function updateToolbarState(element) {
|
||
const style = window.getComputedStyle(element);
|
||
const fontWeight = style.fontWeight;
|
||
const fontStyle = style.fontStyle;
|
||
const color = style.color;
|
||
const fontFamily = style.fontFamily.replace(/['"]/g, '');
|
||
const fontSize = parseInt(style.fontSize);
|
||
|
||
// 更新颜色选择器
|
||
italicBtn.classList.toggle('active', fontStyle === 'italic');
|
||
|
||
// 更新颜色选择器
|
||
colorPicker.value = rgbToHex(color);
|
||
|
||
// 更新字体选择器 - 匹配字体栈中的主要字体
|
||
let matched = false;
|
||
for (let option of fontPicker.options) {
|
||
const optionFonts = option.value.toLowerCase().split(',').map(f => f.trim());
|
||
const currentFonts = fontFamily.toLowerCase().split(',').map(f => f.trim());
|
||
// 检查是否有主要字体匹配
|
||
if (optionFonts.some(of => currentFonts.includes(of))) {
|
||
fontPicker.value = option.value;
|
||
matched = true;
|
||
break;
|
||
}
|
||
}
|
||
// 如果没有匹配,默认选择第一个(系统默认)
|
||
if (!matched && fontPicker.options.length > 0) {
|
||
fontPicker.value = fontPicker.options[0].value;
|
||
}
|
||
|
||
// 更新字重滑动条(兼容关键字 bold/normal 和数值)
|
||
var numericWeight = parseInt(style.fontWeight);
|
||
if (!isNaN(numericWeight)) {
|
||
weightSlider.value = numericWeight;
|
||
weightValue.textContent = numericWeight;
|
||
} else if (fontWeight === 'bold') {
|
||
weightSlider.value = 700;
|
||
weightValue.textContent = '700';
|
||
} else {
|
||
weightSlider.value = 400;
|
||
weightValue.textContent = '400';
|
||
}
|
||
|
||
// 更新锁定按钮状态
|
||
lockBtn.classList.toggle('active', element.classList.contains('locked'));
|
||
|
||
// 更新字号选择器
|
||
const fontSizeValueEl = fontSizePicker.querySelector('.roller-value');
|
||
if (fontSizeValueEl) {
|
||
// 优先读取 inline style(用户调整过的字号),再回退到 computed style
|
||
const inlineSize = parseInt(element.style.fontSize);
|
||
const displaySize = inlineSize || (isNaN(fontSize) ? 16 : fontSize);
|
||
fontSizeValueEl.textContent = displaySize.toString();
|
||
fontSizeValue = displaySize;
|
||
}
|
||
}
|
||
|
||
// 获取吸附后的位置(可选吸附,不强制)
|
||
function getSnapPosition(draggedEl, newLeft, newTop, enableSnap = false) {
|
||
const container = imageContainer;
|
||
const containerRect = container.getBoundingClientRect();
|
||
|
||
const elWidth = draggedEl.offsetWidth;
|
||
const elHeight = draggedEl.offsetHeight;
|
||
|
||
// 计算元素各参考点相对容器的位置
|
||
const elCenterX = newLeft + elWidth / 2;
|
||
const elCenterY = newTop + elHeight / 2;
|
||
const elLeft = newLeft;
|
||
const elRight = newLeft + elWidth;
|
||
const elTop = newTop;
|
||
const elBottom = newTop + elHeight;
|
||
|
||
// 容器尺寸
|
||
const cWidth = containerRect.width;
|
||
const cHeight = containerRect.height;
|
||
const cCenterX = cWidth / 2;
|
||
const cCenterY = cHeight / 2;
|
||
|
||
// 默认返回原始位置(不吸附)
|
||
let snapLeft = newLeft;
|
||
let snapTop = newTop;
|
||
let showGuide = false;
|
||
|
||
// 如果不启用吸附,只限制边界并返回
|
||
if (!enableSnap) {
|
||
// 边界限制
|
||
snapLeft = Math.max(0, Math.min(snapLeft, cWidth - elWidth));
|
||
snapTop = Math.max(0, Math.min(snapTop, cHeight - elHeight));
|
||
return { left: snapLeft, top: snapTop, showGuide };
|
||
}
|
||
|
||
const threshold = 5;
|
||
|
||
// 显示网格线
|
||
if (gridLines) {
|
||
gridLines.classList.add('visible');
|
||
}
|
||
|
||
// 水平垂直分别检测
|
||
let snapX = false;
|
||
let snapY = false;
|
||
let snapXPos = null;
|
||
let snapYPos = null;
|
||
|
||
// 检测容器中心对齐
|
||
if (Math.abs(elCenterX - cCenterX) < threshold) {
|
||
snapX = true;
|
||
snapXPos = cCenterX - elWidth / 2;
|
||
}
|
||
if (Math.abs(elCenterY - cCenterY) < threshold) {
|
||
snapY = true;
|
||
snapYPos = cCenterY - elHeight / 2;
|
||
}
|
||
|
||
// 检测容器边缘对齐
|
||
if (Math.abs(elLeft) < threshold) {
|
||
snapX = true;
|
||
snapXPos = 0;
|
||
}
|
||
if (Math.abs(elRight - cWidth) < threshold) {
|
||
snapX = true;
|
||
snapXPos = cWidth;
|
||
}
|
||
if (Math.abs(elTop) < threshold) {
|
||
snapY = true;
|
||
snapYPos = 0;
|
||
}
|
||
if (Math.abs(elBottom - cHeight) < threshold) {
|
||
snapY = true;
|
||
snapYPos = cHeight;
|
||
}
|
||
|
||
// 检测其他元素对齐(输入框和层叠图)
|
||
const otherInputs = [input1, input2, input3].filter(el => el && el !== draggedEl);
|
||
const otherOverlays = Array.from(overlayLayer.querySelectorAll('.overlay-image'))
|
||
.filter(el => el !== draggedEl);
|
||
|
||
for (const otherEl of otherInputs) {
|
||
if (!otherEl) continue;
|
||
|
||
const oLeft = otherEl.offsetLeft;
|
||
const oTop = otherEl.offsetTop;
|
||
const oWidth = otherEl.offsetWidth;
|
||
const oHeight = otherEl.offsetHeight;
|
||
const oRight = oLeft + oWidth;
|
||
const oBottom = oTop + oHeight;
|
||
const oCenterX = oLeft + oWidth / 2;
|
||
const oCenterY = oTop + oHeight / 2;
|
||
|
||
// 垂直边缘对齐
|
||
if (Math.abs(elLeft - oLeft) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oLeft;
|
||
}
|
||
if (Math.abs(elRight - oRight) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oRight - elWidth;
|
||
}
|
||
if (Math.abs(elLeft - oRight) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oRight;
|
||
}
|
||
if (Math.abs(elRight - oLeft) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oLeft - elWidth;
|
||
}
|
||
|
||
// 水平边缘对齐
|
||
if (Math.abs(elTop - oTop) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oTop;
|
||
}
|
||
if (Math.abs(elBottom - oBottom) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oBottom - elHeight;
|
||
}
|
||
if (Math.abs(elTop - oBottom) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oBottom;
|
||
}
|
||
if (Math.abs(elBottom - oTop) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oTop - elHeight;
|
||
}
|
||
|
||
// 中心对齐
|
||
if (Math.abs(elCenterX - oCenterX) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oCenterX - elWidth / 2;
|
||
}
|
||
if (Math.abs(elCenterY - oCenterY) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oCenterY - elHeight / 2;
|
||
}
|
||
}
|
||
|
||
// 检测层叠图之间对齐
|
||
for (const otherEl of otherOverlays) {
|
||
if (!otherEl) continue;
|
||
|
||
const oLeft = otherEl.offsetLeft;
|
||
const oTop = otherEl.offsetTop;
|
||
const oWidth = otherEl.offsetWidth;
|
||
const oHeight = otherEl.offsetHeight;
|
||
const oRight = oLeft + oWidth;
|
||
const oBottom = oTop + oHeight;
|
||
const oCenterX = oLeft + oWidth / 2;
|
||
const oCenterY = oTop + oHeight / 2;
|
||
|
||
// 垂直边缘对齐
|
||
if (Math.abs(elLeft - oLeft) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oLeft;
|
||
}
|
||
if (Math.abs(elRight - oRight) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oRight - elWidth;
|
||
}
|
||
if (Math.abs(elLeft - oRight) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oRight;
|
||
}
|
||
if (Math.abs(elRight - oLeft) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oLeft - elWidth;
|
||
}
|
||
|
||
// 水平边缘对齐
|
||
if (Math.abs(elTop - oTop) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oTop;
|
||
}
|
||
if (Math.abs(elBottom - oBottom) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oBottom - elHeight;
|
||
}
|
||
if (Math.abs(elTop - oBottom) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oBottom;
|
||
}
|
||
if (Math.abs(elBottom - oTop) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oTop - elHeight;
|
||
}
|
||
|
||
// 中心对齐
|
||
if (Math.abs(elCenterX - oCenterX) < threshold) {
|
||
snapX = true;
|
||
snapXPos = oCenterX - elWidth / 2;
|
||
}
|
||
if (Math.abs(elCenterY - oCenterY) < threshold) {
|
||
snapY = true;
|
||
snapYPos = oCenterY - elHeight / 2;
|
||
}
|
||
}
|
||
|
||
// 应用吸附(仅在启用吸附时)
|
||
if (snapX && snapXPos !== null) {
|
||
snapLeft = snapXPos;
|
||
showGuide = true;
|
||
}
|
||
if (snapY && snapYPos !== null) {
|
||
snapTop = snapYPos;
|
||
showGuide = true;
|
||
}
|
||
|
||
// 边界限制
|
||
snapLeft = Math.max(0, Math.min(snapLeft, cWidth - elWidth));
|
||
snapTop = Math.max(0, Math.min(snapTop, cHeight - elHeight));
|
||
|
||
return { left: snapLeft, top: snapTop, showGuide };
|
||
}
|
||
|
||
// RGB转十六进制
|
||
function rgbToHex(rgb) {
|
||
if (rgb.startsWith('#')) return rgb;
|
||
const match = rgb.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/);
|
||
if (!match) return '#000000';
|
||
return '#' + [match[1], match[2], match[3]].map(x => parseInt(x).toString(16).padStart(2, '0')).join('');
|
||
}
|
||
|
||
// 对齐辅助线 - 水平和垂直居中、对齐其他元素
|
||
function showAlignmentGuides(draggedEl) {
|
||
const container = imageContainer;
|
||
const cWidth = container.offsetWidth;
|
||
const cHeight = container.offsetHeight;
|
||
|
||
const elWidth = draggedEl.offsetWidth;
|
||
const elHeight = draggedEl.offsetHeight;
|
||
const elLeft = draggedEl.offsetLeft;
|
||
const elTop = draggedEl.offsetTop;
|
||
const elRight = elLeft + elWidth;
|
||
const elBottom = elTop + elHeight;
|
||
const elCenterX = elLeft + elWidth / 2;
|
||
const elCenterY = elTop + elHeight / 2;
|
||
|
||
const threshold = 1;
|
||
let guideHPos = null;
|
||
let guideVPos = null;
|
||
|
||
// 容器中心对齐
|
||
const cCenterX = cWidth / 2;
|
||
const cCenterY = cHeight / 2;
|
||
|
||
if (Math.abs(elCenterX - cCenterX) < threshold) {
|
||
guideVPos = cCenterX;
|
||
}
|
||
if (Math.abs(elCenterY - cCenterY) < threshold) {
|
||
guideHPos = cCenterY;
|
||
}
|
||
|
||
// 容器边缘对齐
|
||
if (Math.abs(elLeft) < threshold) {
|
||
guideVPos = 0;
|
||
}
|
||
if (Math.abs(elRight - cWidth) < threshold) {
|
||
guideVPos = cWidth;
|
||
}
|
||
if (Math.abs(elTop) < threshold) {
|
||
guideHPos = 0;
|
||
}
|
||
if (Math.abs(elBottom - cHeight) < threshold) {
|
||
guideHPos = cHeight;
|
||
}
|
||
|
||
// 其他输入框对齐检测
|
||
const otherInputs = [input1, input2].filter(el => el && el !== draggedEl);
|
||
const otherOverlays = Array.from(overlayLayer.querySelectorAll('.overlay-image'))
|
||
.filter(el => el !== draggedEl);
|
||
|
||
for (const otherEl of otherInputs) {
|
||
if (!otherEl) continue;
|
||
|
||
const oLeft = otherEl.offsetLeft;
|
||
const oTop = otherEl.offsetTop;
|
||
const oWidth = otherEl.offsetWidth;
|
||
const oHeight = otherEl.offsetHeight;
|
||
const oRight = oLeft + oWidth;
|
||
const oBottom = oTop + oHeight;
|
||
const oCenterX = oLeft + oWidth / 2;
|
||
const oCenterY = oTop + oHeight / 2;
|
||
|
||
// 垂直边缘对齐
|
||
if (Math.abs(elLeft - oLeft) < threshold) {
|
||
guideVPos = oLeft;
|
||
}
|
||
if (Math.abs(elRight - oRight) < threshold) {
|
||
guideVPos = oRight;
|
||
}
|
||
if (Math.abs(elLeft - oRight) < threshold) {
|
||
guideVPos = oRight;
|
||
}
|
||
if (Math.abs(elRight - oLeft) < threshold) {
|
||
guideVPos = oLeft;
|
||
}
|
||
|
||
// 水平边缘对齐
|
||
if (Math.abs(elTop - oTop) < threshold) {
|
||
guideHPos = oTop;
|
||
}
|
||
if (Math.abs(elBottom - oBottom) < threshold) {
|
||
guideHPos = oBottom;
|
||
}
|
||
if (Math.abs(elTop - oBottom) < threshold) {
|
||
guideHPos = oBottom;
|
||
}
|
||
if (Math.abs(elBottom - oTop) < threshold) {
|
||
guideHPos = oTop;
|
||
}
|
||
|
||
// 中心对齐
|
||
if (Math.abs(elCenterX - oCenterX) < threshold) {
|
||
guideVPos = oCenterX;
|
||
}
|
||
if (Math.abs(elCenterY - oCenterY) < threshold) {
|
||
guideHPos = oCenterY;
|
||
}
|
||
}
|
||
|
||
// 其他层叠图对齐检测
|
||
for (const otherEl of otherOverlays) {
|
||
if (!otherEl) continue;
|
||
|
||
const oLeft = otherEl.offsetLeft;
|
||
const oTop = otherEl.offsetTop;
|
||
const oWidth = otherEl.offsetWidth;
|
||
const oHeight = otherEl.offsetHeight;
|
||
const oRight = oLeft + oWidth;
|
||
const oBottom = oTop + oHeight;
|
||
const oCenterX = oLeft + oWidth / 2;
|
||
const oCenterY = oTop + oHeight / 2;
|
||
|
||
// 垂直边缘对齐
|
||
if (Math.abs(elLeft - oLeft) < threshold) {
|
||
guideVPos = oLeft;
|
||
}
|
||
if (Math.abs(elRight - oRight) < threshold) {
|
||
guideVPos = oRight;
|
||
}
|
||
if (Math.abs(elLeft - oRight) < threshold) {
|
||
guideVPos = oRight;
|
||
}
|
||
if (Math.abs(elRight - oLeft) < threshold) {
|
||
guideVPos = oLeft;
|
||
}
|
||
|
||
// 水平边缘对齐
|
||
if (Math.abs(elTop - oTop) < threshold) {
|
||
guideHPos = oTop;
|
||
}
|
||
if (Math.abs(elBottom - oBottom) < threshold) {
|
||
guideHPos = oBottom;
|
||
}
|
||
if (Math.abs(elTop - oBottom) < threshold) {
|
||
guideHPos = oBottom;
|
||
}
|
||
if (Math.abs(elBottom - oTop) < threshold) {
|
||
guideHPos = oTop;
|
||
}
|
||
|
||
// 中心对齐
|
||
if (Math.abs(elCenterX - oCenterX) < threshold) {
|
||
guideVPos = oCenterX;
|
||
}
|
||
if (Math.abs(elCenterY - oCenterY) < threshold) {
|
||
guideHPos = oCenterY;
|
||
}
|
||
}
|
||
|
||
if (guideHPos !== null) {
|
||
guideH.style.top = guideHPos + 'px';
|
||
guideH.classList.add('visible');
|
||
} else {
|
||
guideH.classList.remove('visible');
|
||
}
|
||
|
||
if (guideVPos !== null) {
|
||
guideV.style.left = guideVPos + 'px';
|
||
guideV.classList.add('visible');
|
||
} else {
|
||
guideV.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
function hideGuides() {
|
||
guideH.classList.remove('visible');
|
||
guideV.classList.remove('visible');
|
||
if (gridLines) {
|
||
gridLines.classList.remove('visible');
|
||
}
|
||
}
|
||
|
||
// 取消所有选中
|
||
function deselectAll() {
|
||
if (editingInput) {
|
||
endEdit(editingInput);
|
||
}
|
||
if (selectedInput) {
|
||
selectedInput.classList.remove('selected');
|
||
selectedInput = null;
|
||
hidePositionInfo();
|
||
}
|
||
// 隐藏编辑器工具栏
|
||
editorToolbar.classList.remove('active');
|
||
}
|
||
|
||
// 初始化右键菜单(只在3:5预览区域)
|
||
function initContextMenu() {
|
||
// 只在图片容器上禁用默认右键菜单并显示自定义菜单
|
||
imageContainer.addEventListener('contextmenu', (e) => {
|
||
// 如果右键点击的是输入框,显示默认菜单
|
||
if (e.target.closest('.text-input')) {
|
||
return;
|
||
}
|
||
|
||
e.preventDefault();
|
||
showContextMenu(e.clientX, e.clientY);
|
||
});
|
||
|
||
// 点击其他地方关闭菜单
|
||
document.addEventListener('click', () => {
|
||
hideContextMenu();
|
||
});
|
||
}
|
||
|
||
// 初始化文本编辑器工具栏
|
||
function initTextEditorToolbar() {
|
||
// 字重滑动条
|
||
weightSlider.addEventListener('input', function() {
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return;
|
||
var val = parseInt(this.value);
|
||
target.style.fontWeight = val;
|
||
weightValue.textContent = val;
|
||
});
|
||
weightSlider.addEventListener('change', function() {
|
||
saveState();
|
||
});
|
||
|
||
// 斜体按钮
|
||
italicBtn.addEventListener('click', () => {
|
||
const targetInput = editingInput || selectedInput;
|
||
if (!targetInput) return;
|
||
const selection = window.getSelection();
|
||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||
document.execCommand('italic', false, null);
|
||
} else {
|
||
const isItalic = italicBtn.classList.contains('active');
|
||
if (isItalic) {
|
||
targetInput.style.fontStyle = 'normal';
|
||
} else {
|
||
targetInput.style.fontStyle = 'italic';
|
||
}
|
||
italicBtn.classList.toggle('active');
|
||
}
|
||
saveState();
|
||
});
|
||
|
||
// 锁定按钮
|
||
lockBtn.addEventListener('click', () => {
|
||
const targetInput = editingInput || selectedInput;
|
||
if (!targetInput) return;
|
||
|
||
const isLocked = targetInput.classList.contains('locked');
|
||
if (isLocked) {
|
||
targetInput.classList.remove('locked');
|
||
} else {
|
||
targetInput.classList.add('locked');
|
||
}
|
||
lockBtn.classList.toggle('active', !isLocked);
|
||
saveState();
|
||
});
|
||
|
||
// 颜色选择器
|
||
colorPicker.addEventListener('input', () => {
|
||
const targetInput = editingInput || selectedInput;
|
||
if (!targetInput) return;
|
||
const selection = window.getSelection();
|
||
if (selection.rangeCount > 0 && !selection.isCollapsed) {
|
||
document.execCommand('foreColor', false, colorPicker.value);
|
||
} else {
|
||
targetInput.style.color = colorPicker.value;
|
||
}
|
||
});
|
||
colorPicker.addEventListener('change', () => {
|
||
saveState();
|
||
});
|
||
|
||
// 字体选择器
|
||
fontPicker.addEventListener('change', () => {
|
||
const targetInput = editingInput || selectedInput;
|
||
if (!targetInput) return;
|
||
targetInput.style.fontFamily = fontPicker.value;
|
||
saveState();
|
||
});
|
||
|
||
// 字号选择器 - 密码锁式滚轮
|
||
let fontSizeValue = 16;
|
||
const fontSizeRoller = document.getElementById('fontSizePicker');
|
||
const fontSizeValueEl = fontSizeRoller.querySelector('.roller-value');
|
||
const fontSizeUpBtn = fontSizeRoller.querySelector('.roller-up');
|
||
const fontSizeDownBtn = fontSizeRoller.querySelector('.roller-down');
|
||
|
||
// 获取当前选中输入框的实际字号
|
||
function getCurrentFontSize() {
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return fontSizeValue;
|
||
var size = parseInt(window.getComputedStyle(target).fontSize);
|
||
return isNaN(size) ? 16 : size;
|
||
}
|
||
|
||
function updateFontSize(newSize, forceUpdate) {
|
||
newSize = Math.max(10, Math.min(42, newSize));
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return;
|
||
target.style.fontSize = newSize + 'px';
|
||
fontSizeValue = newSize;
|
||
fontSizeValueEl.textContent = newSize;
|
||
}
|
||
|
||
fontSizeUpBtn.addEventListener('click', function() {
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return;
|
||
var currentSize = parseInt(target.style.fontSize) || parseInt(window.getComputedStyle(target).fontSize) || fontSizeValue;
|
||
updateFontSize(currentSize + 1);
|
||
saveState();
|
||
});
|
||
|
||
fontSizeDownBtn.addEventListener('click', function() {
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return;
|
||
var currentSize = parseInt(target.style.fontSize) || parseInt(window.getComputedStyle(target).fontSize) || fontSizeValue;
|
||
updateFontSize(currentSize - 1);
|
||
saveState();
|
||
});
|
||
|
||
fontSizeRoller.addEventListener('wheel', function(e) {
|
||
e.preventDefault();
|
||
var target = editingInput || selectedInput;
|
||
if (!target) return;
|
||
var currentSize = parseInt(target.style.fontSize) || parseInt(window.getComputedStyle(target).fontSize) || fontSizeValue;
|
||
var delta = e.deltaY > 0 ? -1 : 1;
|
||
updateFontSize(currentSize + delta);
|
||
saveState();
|
||
}, { passive: false });
|
||
}
|
||
|
||
// ==================== 图层管理系统 ====================
|
||
let selectedLayerId = null;
|
||
|
||
function initLayerManager() {
|
||
const layersBtn = document.getElementById('layersBtn');
|
||
const layersPanel = document.getElementById('layersPanel');
|
||
const layersClose = document.getElementById('layersClose');
|
||
const layersList = document.getElementById('layersList');
|
||
|
||
if (!layersBtn || !layersPanel) return;
|
||
|
||
// 监听二维码图层更新事件
|
||
document.addEventListener('updateLayers', () => {
|
||
updateLayersList();
|
||
});
|
||
|
||
// 打开/关闭图层面板(同时支持桌面和移动端)
|
||
const toggleLayersPanel = () => {
|
||
layersPanel.classList.toggle('active');
|
||
updateLayersList();
|
||
};
|
||
layersBtn.addEventListener('click', toggleLayersPanel);
|
||
layersBtn.addEventListener('touchend', (e) => { e.preventDefault(); toggleLayersPanel(); });
|
||
|
||
const closeLayersPanel = () => {
|
||
layersPanel.classList.remove('active');
|
||
};
|
||
layersClose.addEventListener('click', closeLayersPanel);
|
||
layersClose.addEventListener('touchend', (e) => { e.preventDefault(); closeLayersPanel(); });
|
||
|
||
// 点击空白区域关闭图层面板
|
||
const handleClickOutside = (e) => {
|
||
// 如果点击的是图层面板内部,不关闭
|
||
if (layersPanel.contains(e.target) || layersBtn.contains(e.target)) {
|
||
return;
|
||
}
|
||
// 关闭图层面板
|
||
layersPanel.classList.remove('active');
|
||
};
|
||
|
||
// 桌面端使用 click 事件
|
||
document.addEventListener('click', handleClickOutside);
|
||
// 移动端使用 touchend 事件
|
||
document.addEventListener('touchend', (e) => {
|
||
if (layersPanel.classList.contains('active')) {
|
||
handleClickOutside(e);
|
||
}
|
||
});
|
||
|
||
// 图层操作按钮 - 只保留显示/隐藏(同时支持桌面和移动端)
|
||
const layerToggleVisibleBtn = document.getElementById('layerToggleVisible');
|
||
if (layerToggleVisibleBtn) {
|
||
const handleToggleVisible = (e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
toggleLayerVisibility();
|
||
};
|
||
layerToggleVisibleBtn.addEventListener('click', handleToggleVisible);
|
||
layerToggleVisibleBtn.addEventListener('touchend', handleToggleVisible);
|
||
}
|
||
|
||
// 点击图层列表项选中图层(同时支持桌面和移动端)
|
||
let isProcessingLayerClick = false;
|
||
const handleLayerListClick = (e) => {
|
||
// 防止重复触发
|
||
if (isProcessingLayerClick) return;
|
||
isProcessingLayerClick = true;
|
||
setTimeout(() => { isProcessingLayerClick = false; }, 100);
|
||
|
||
console.log('图层列表点击', e.target, e.target.className);
|
||
|
||
// 处理眼睛图标点击 - 直接切换可见性
|
||
const visibilityBtn = e.target.closest('.layer-visibility');
|
||
if (visibilityBtn) {
|
||
console.log('点击眼睛图标', visibilityBtn.dataset.layerId);
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
const layerId = visibilityBtn.dataset.layerId;
|
||
const element = getLayerElement(layerId);
|
||
console.log('获取元素', element, '当前layer-hidden:', element?.classList.contains('layer-hidden'));
|
||
if (element) {
|
||
const newHiddenState = !element.classList.contains('layer-hidden');
|
||
console.log('切换隐藏状态为:', newHiddenState);
|
||
if (newHiddenState) {
|
||
element.classList.add('layer-hidden');
|
||
} else {
|
||
element.classList.remove('layer-hidden');
|
||
}
|
||
console.log('切换后layer-hidden:', element.classList.contains('layer-hidden'));
|
||
updateLayersList();
|
||
saveState(true);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const layerItem = e.target.closest('.layer-item');
|
||
if (!layerItem) return;
|
||
|
||
const layerId = layerItem.dataset.layerId;
|
||
selectLayer(layerId);
|
||
};
|
||
|
||
// 只在桌面端使用 click 事件
|
||
if (!('ontouchstart' in window)) {
|
||
layersList.addEventListener('click', handleLayerListClick);
|
||
} else {
|
||
// 移动端使用 touchend 事件
|
||
layersList.addEventListener('touchend', handleLayerListClick);
|
||
}
|
||
}
|
||
|
||
// 更新图层列表
|
||
function updateLayersList() {
|
||
const layersList = document.getElementById('layersList');
|
||
if (!layersList) return;
|
||
|
||
// 保存当前滚动位置
|
||
const scrollTop = layersList.scrollTop;
|
||
|
||
// 使用 requestAnimationFrame 确保在下一帧更新,避免事件冲突
|
||
requestAnimationFrame(() => {
|
||
layersList.innerHTML = '';
|
||
|
||
// 获取所有图层(按指定顺序:背景图-企业-嘉宾-编号-其他文本-层叠图)
|
||
const layers = [];
|
||
|
||
// 1. 背景图(最底层)
|
||
if (currentImage) {
|
||
layers.push({
|
||
id: 'background',
|
||
element: bgImage,
|
||
name: '背景图',
|
||
type: 'background',
|
||
icon: '🌄',
|
||
visible: !bgImage.classList.contains('layer-hidden')
|
||
});
|
||
}
|
||
|
||
// 2. 固定文本图层(按顺序:企业、嘉宾、编号)
|
||
const fixedOrder = ['input1', 'input2', 'input3'];
|
||
fixedOrder.forEach(id => {
|
||
const input = document.getElementById(id);
|
||
if (input) {
|
||
layers.push({
|
||
id: input.id,
|
||
element: input,
|
||
name: getLayerName(input),
|
||
type: 'text',
|
||
icon: '📝',
|
||
visible: !input.classList.contains('layer-hidden')
|
||
});
|
||
}
|
||
});
|
||
|
||
// 3. 其他文本输入框(新添加的)
|
||
const textInputs = document.querySelectorAll('.preview-input-layer .text-input');
|
||
textInputs.forEach((input) => {
|
||
// 跳过已处理的固定图层
|
||
if (!fixedOrder.includes(input.id)) {
|
||
layers.push({
|
||
id: input.id,
|
||
element: input,
|
||
name: getLayerName(input),
|
||
type: 'text',
|
||
icon: '📝',
|
||
visible: !input.classList.contains('layer-hidden')
|
||
});
|
||
}
|
||
});
|
||
|
||
// 4. 二维码
|
||
const qrItems = document.querySelectorAll('.qr-item');
|
||
qrItems.forEach((qr, index) => {
|
||
layers.push({
|
||
id: qr.dataset.layerId || `qr_${index}`,
|
||
element: qr,
|
||
name: `二维码 ${index + 1}`,
|
||
type: 'qrcode',
|
||
icon: '🔲',
|
||
visible: !qr.classList.contains('layer-hidden')
|
||
});
|
||
});
|
||
|
||
// 5. 层叠图(最上层)
|
||
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||
overlayImages.forEach((overlay, index) => {
|
||
layers.push({
|
||
id: overlay.dataset.layerId || `overlay_${index}`,
|
||
element: overlay,
|
||
name: `层叠图 ${index + 1}`,
|
||
type: 'overlay',
|
||
icon: '🖼️',
|
||
visible: !overlay.classList.contains('layer-hidden')
|
||
});
|
||
});
|
||
|
||
// 显示图层列表(已经是正确顺序,不需要倒序)
|
||
console.log('updateLayersList: 总共', layers.length, '个图层');
|
||
layers.forEach((layer, index) => {
|
||
console.log('updateLayersList: 创建图层项', index, layer.id, layer.name, 'visible:', layer.visible);
|
||
const layerItem = document.createElement('div');
|
||
layerItem.className = 'layer-item';
|
||
layerItem.dataset.layerId = layer.id;
|
||
if (layer.id === selectedLayerId) {
|
||
layerItem.classList.add('selected');
|
||
}
|
||
if (!layer.visible) {
|
||
layerItem.classList.add('hidden');
|
||
}
|
||
|
||
layerItem.innerHTML = `
|
||
<div class="layer-icon">${layer.icon}</div>
|
||
<div class="layer-info">
|
||
<div class="layer-name">${layer.name}</div>
|
||
<div class="layer-type">${getLayerTypeName(layer.type)}</div>
|
||
</div>
|
||
<div class="layer-visibility ${!layer.visible ? 'is-hidden' : ''}" title="${layer.visible ? '点击隐藏图层' : '点击显示图层'}" data-layer-id="${layer.id}" data-visible="${layer.visible}">
|
||
${layer.visible ? '✓' : '✕'}
|
||
</div>
|
||
`;
|
||
|
||
layersList.appendChild(layerItem);
|
||
console.log('updateLayersList: 已添加到列表', layer.id);
|
||
});
|
||
|
||
// 恢复滚动位置
|
||
layersList.scrollTop = scrollTop;
|
||
|
||
// 更新按钮状态
|
||
updateLayerButtons();
|
||
});
|
||
}
|
||
|
||
// 获取图层名称
|
||
function getLayerName(element) {
|
||
console.log('getLayerName', element.id, element.textContent);
|
||
const text = element.textContent.trim();
|
||
if (text) {
|
||
return text.length > 10 ? text.substring(0, 10) + '...' : text;
|
||
}
|
||
const placeholder = element.dataset.placeholder;
|
||
const id = element.id;
|
||
if (id === 'input1') return '企业';
|
||
if (id === 'input2') return '嘉宾';
|
||
if (id === 'input3') return '编号';
|
||
return placeholder || '文本';
|
||
}
|
||
|
||
// 获取图层类型名称
|
||
function getLayerTypeName(type) {
|
||
const names = {
|
||
'text': '文本',
|
||
'overlay': '层叠图',
|
||
'background': '背景图',
|
||
'qrcode': '二维码'
|
||
};
|
||
return names[type] || type;
|
||
}
|
||
|
||
// 选中图层
|
||
function selectLayer(layerId) {
|
||
selectedLayerId = layerId;
|
||
|
||
// 取消所有选中
|
||
document.querySelectorAll('.layer-item').forEach(item => {
|
||
item.classList.remove('selected');
|
||
});
|
||
|
||
// 高亮当前选中的图层项
|
||
const layerItem = document.querySelector(`.layer-item[data-layer-id="${layerId}"]`);
|
||
if (layerItem) {
|
||
layerItem.classList.add('selected');
|
||
}
|
||
|
||
// 选中对应的元素
|
||
const element = getLayerElement(layerId);
|
||
if (element) {
|
||
if (element.classList.contains('text-input')) {
|
||
selectInput(element);
|
||
} else if (element.classList.contains('overlay-image')) {
|
||
selectOverlay(element);
|
||
} else if (element.classList.contains('qr-item')) {
|
||
// 选中二维码
|
||
if (window.QRCodeAPI) {
|
||
window.QRCodeAPI.hideTools();
|
||
}
|
||
// 取消其他选中
|
||
document.querySelectorAll('.qr-item').forEach(el => {
|
||
el.classList.remove('qr-selected');
|
||
});
|
||
// 选中当前二维码
|
||
element.classList.add('qr-selected');
|
||
// 显示二维码工具栏
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.remove('active');
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.add('active');
|
||
// 同步工具栏值
|
||
const qrImg = element.querySelector('img');
|
||
const qrOpacitySlider = document.getElementById('qrOpacitySlider');
|
||
const qrOpacityValue = document.getElementById('qrOpacityValue');
|
||
const qrScaleSlider = document.getElementById('qrScaleSlider');
|
||
const qrScaleValue = document.getElementById('qrScaleValue');
|
||
if (qrImg && qrOpacitySlider) {
|
||
const opacity = parseFloat(qrImg.style.opacity) || 1;
|
||
qrOpacitySlider.value = Math.round(opacity * 100);
|
||
qrOpacityValue.textContent = Math.round(opacity * 100) + '%';
|
||
}
|
||
if (qrScaleSlider) {
|
||
const size = parseInt(element.style.width) || 100;
|
||
qrScaleSlider.value = size;
|
||
qrScaleValue.textContent = size + '%';
|
||
}
|
||
} else if (element.id === 'bgImage') {
|
||
// 选中背景图
|
||
editorToolbar.classList.add('active');
|
||
textTools.classList.remove('active');
|
||
imageTools.classList.remove('active');
|
||
bgTools.classList.add('active');
|
||
// 隐藏二维码工具栏
|
||
const qrTools = document.getElementById('qrTools');
|
||
if (qrTools) qrTools.classList.remove('active');
|
||
}
|
||
}
|
||
|
||
updateLayerButtons();
|
||
}
|
||
|
||
// 获取图层元素
|
||
function getLayerElement(layerId) {
|
||
if (layerId === 'background') return bgImage;
|
||
return document.getElementById(layerId) || document.querySelector(`[data-layer-id="${layerId}"]`);
|
||
}
|
||
|
||
// 更新图层按钮状态
|
||
function updateLayerButtons() {
|
||
const hasSelection = selectedLayerId !== null;
|
||
|
||
// 显示/隐藏按钮需要选中图层才能使用
|
||
const visibleBtn = document.getElementById('layerToggleVisible');
|
||
if (visibleBtn) {
|
||
visibleBtn.disabled = !hasSelection;
|
||
}
|
||
}
|
||
|
||
// 切换图层可见性
|
||
function toggleLayerVisibility() {
|
||
if (!selectedLayerId) return;
|
||
|
||
const element = getLayerElement(selectedLayerId);
|
||
if (!element) return;
|
||
|
||
element.classList.toggle('layer-hidden');
|
||
updateLayersList();
|
||
saveState(true);
|
||
}
|
||
|
||
// 初始化键盘微调功能(方向键 1px 移动)
|
||
function initKeyboardNudge() {
|
||
document.addEventListener('keydown', (e) => {
|
||
// 撤销重做快捷键
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
|
||
e.preventDefault();
|
||
if (e.shiftKey) {
|
||
redo();
|
||
} else {
|
||
undo();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
|
||
e.preventDefault();
|
||
redo();
|
||
return;
|
||
}
|
||
|
||
// 如果正在编辑文本,不处理方向键
|
||
if (editingInput) return;
|
||
|
||
// 获取当前选中的元素
|
||
const targetEl = selectedInput || selectedOverlay;
|
||
if (!targetEl) return;
|
||
|
||
// 如果已锁定,禁止键盘微调
|
||
if (targetEl.classList.contains('locked')) {
|
||
return;
|
||
}
|
||
|
||
// 防止默认行为(页面滚动)
|
||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||
e.preventDefault();
|
||
}
|
||
|
||
let currentLeft = parseInt(targetEl.style.left) || targetEl.offsetLeft;
|
||
let currentTop = parseInt(targetEl.style.top) || targetEl.offsetTop;
|
||
|
||
switch(e.key) {
|
||
case 'ArrowUp':
|
||
currentTop -= 1;
|
||
break;
|
||
case 'ArrowDown':
|
||
currentTop += 1;
|
||
break;
|
||
case 'ArrowLeft':
|
||
currentLeft -= 1;
|
||
break;
|
||
case 'ArrowRight':
|
||
currentLeft += 1;
|
||
break;
|
||
default:
|
||
return;
|
||
}
|
||
|
||
// 应用新位置(不启用吸附)
|
||
targetEl.style.left = currentLeft + 'px';
|
||
targetEl.style.top = currentTop + 'px';
|
||
});
|
||
}
|
||
|
||
// 显示右键菜单
|
||
function showContextMenu(x, y) {
|
||
hideContextMenu();
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'context-menu';
|
||
menu.id = 'contextMenu';
|
||
menu.style.left = x + 'px';
|
||
menu.style.top = y + 'px';
|
||
|
||
const previewItem = document.createElement('div');
|
||
previewItem.className = 'context-menu-item';
|
||
previewItem.textContent = '预览图片';
|
||
previewItem.addEventListener('click', () => {
|
||
showPreview();
|
||
hideContextMenu();
|
||
});
|
||
|
||
const copyItem = document.createElement('div');
|
||
copyItem.className = 'context-menu-item';
|
||
copyItem.textContent = '复制图片';
|
||
copyItem.addEventListener('click', async () => {
|
||
await copyImageToClipboard();
|
||
hideContextMenu();
|
||
});
|
||
|
||
const saveItem = document.createElement('div');
|
||
saveItem.className = 'context-menu-item';
|
||
saveItem.textContent = '保存图片';
|
||
saveItem.addEventListener('click', () => {
|
||
saveImage();
|
||
hideContextMenu();
|
||
});
|
||
|
||
menu.appendChild(previewItem);
|
||
menu.appendChild(copyItem);
|
||
menu.appendChild(saveItem);
|
||
document.body.appendChild(menu);
|
||
}
|
||
|
||
// 从 blob 显示预览
|
||
function showPreviewFromBlob(blob) {
|
||
console.log('预览图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||
const dataUrl = URL.createObjectURL(blob);
|
||
showPreviewModal(dataUrl, blob);
|
||
}
|
||
|
||
// 显示预览
|
||
async function showPreview() {
|
||
const canvas = await generateCanvas();
|
||
console.log('预览画布尺寸:', canvas.width, 'x', canvas.height);
|
||
|
||
// 尝试使用 toDataURL 获取更高质量的图片
|
||
let dataUrl;
|
||
let blob;
|
||
|
||
try {
|
||
// 先尝试 toDataURL
|
||
dataUrl = canvas.toDataURL('image/png');
|
||
console.log('预览 toDataURL 成功,dataUrl 长度:', dataUrl.length);
|
||
|
||
// 将 dataUrl 转换为 blob
|
||
const response = await fetch(dataUrl);
|
||
blob = await response.blob();
|
||
console.log('预览 dataUrl 转 blob 成功,blob 尺寸:', blob.size, 'bytes');
|
||
} catch (err) {
|
||
console.log('预览 toDataURL 失败,使用 toBlob:', err);
|
||
// 如果 toDataURL 失败,使用 toBlob
|
||
blob = await new Promise(resolve => {
|
||
canvas.toBlob((b) => {
|
||
console.log('预览 toBlob 回调 - blob 尺寸:', b.size, 'bytes');
|
||
resolve(b);
|
||
}, 'image/png', 1.0);
|
||
});
|
||
dataUrl = URL.createObjectURL(blob);
|
||
}
|
||
|
||
console.log('预览图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||
showPreviewModal(dataUrl, blob);
|
||
}
|
||
|
||
// 显示预览弹窗
|
||
function showPreviewModal(dataUrl, blob) {
|
||
|
||
const tipOverlay = document.createElement('div');
|
||
tipOverlay.id = 'previewTip';
|
||
tipOverlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.9);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
padding: 20px;
|
||
`;
|
||
|
||
const tipText = document.createElement('p');
|
||
tipText.innerHTML = '预览图片<br><span style="font-size:14px;opacity:0.8">(点击保存到相册)</span>';
|
||
tipText.style.cssText = `
|
||
color: #fff;
|
||
font-size: 18px;
|
||
margin-bottom: 20px;
|
||
text-align: center;
|
||
`;
|
||
|
||
const img = document.createElement('img');
|
||
img.src = dataUrl;
|
||
img.style.cssText = `
|
||
max-width: 100%;
|
||
max-height: 70vh;
|
||
border-radius: 8px;
|
||
box-shadow: 0 4px 20px rgba(255,255,255,0.2);
|
||
-webkit-touch-callout: default;
|
||
-webkit-user-select: auto;
|
||
user-select: auto;
|
||
`;
|
||
|
||
const btnContainer = document.createElement('div');
|
||
btnContainer.style.cssText = `
|
||
display: flex;
|
||
gap: 12px;
|
||
margin-top: 20px;
|
||
`;
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.textContent = '保存到相册';
|
||
saveBtn.style.cssText = `
|
||
padding: 12px 30px;
|
||
font-size: 16px;
|
||
background: #007AFF;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 25px;
|
||
cursor: pointer;
|
||
`;
|
||
saveBtn.onclick = async () => {
|
||
tipOverlay.remove();
|
||
await saveImageToGallery(dataUrl, blob);
|
||
// 重新隐藏 canvas
|
||
exportCanvas.setAttribute('hidden', '');
|
||
exportCanvas.style.position = '';
|
||
exportCanvas.style.left = '';
|
||
exportCanvas.style.top = '';
|
||
};
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.textContent = '关闭';
|
||
closeBtn.style.cssText = `
|
||
padding: 12px 30px;
|
||
font-size: 16px;
|
||
background: #fff;
|
||
color: #333;
|
||
border: none;
|
||
border-radius: 25px;
|
||
cursor: pointer;
|
||
`;
|
||
closeBtn.onclick = () => {
|
||
tipOverlay.remove();
|
||
// 重新隐藏 canvas
|
||
exportCanvas.setAttribute('hidden', '');
|
||
exportCanvas.style.position = '';
|
||
exportCanvas.style.left = '';
|
||
exportCanvas.style.top = '';
|
||
};
|
||
|
||
btnContainer.appendChild(saveBtn);
|
||
btnContainer.appendChild(closeBtn);
|
||
tipOverlay.appendChild(tipText);
|
||
tipOverlay.appendChild(img);
|
||
tipOverlay.appendChild(btnContainer);
|
||
document.body.appendChild(tipOverlay);
|
||
}
|
||
|
||
// 保存图片到相册
|
||
async function saveImageToGallery(dataUrl, blob) {
|
||
const filename = '邀请函_' + Date.now() + '.png';
|
||
|
||
// 如果没有传入 blob,尝试从 window._lastExportBlob 获取
|
||
if (!blob && window._lastExportBlob) {
|
||
blob = window._lastExportBlob;
|
||
}
|
||
|
||
// 尝试使用 File System Access API (Android/Chrome)
|
||
if ('showSaveFilePicker' in window) {
|
||
try {
|
||
const handle = await window.showSaveFilePicker({
|
||
suggestedName: filename,
|
||
types: [{
|
||
description: 'PNG 图片',
|
||
accept: { 'image/png': ['.png'] }
|
||
}]
|
||
});
|
||
const writable = await handle.createWritable();
|
||
await writable.write(blob);
|
||
await writable.close();
|
||
showToast('图片已保存到相册');
|
||
return;
|
||
} catch (err) {
|
||
console.log('File System Access API 失败,使用传统方式');
|
||
}
|
||
}
|
||
|
||
// 尝试使用 Web Share API (iOS Safari)
|
||
if (navigator.share && navigator.canShare) {
|
||
try {
|
||
const file = new File([blob], filename, { type: 'image/png' });
|
||
|
||
if (navigator.canShare({ files: [file] })) {
|
||
await navigator.share({
|
||
files: [file],
|
||
title: '邀请函',
|
||
text: '保存邀请函图片'
|
||
});
|
||
showToast('图片已分享');
|
||
return;
|
||
}
|
||
} catch (err) {
|
||
console.log('Web Share API 失败,使用传统方式');
|
||
}
|
||
}
|
||
|
||
// 传统下载方式
|
||
const link = document.createElement('a');
|
||
link.download = filename;
|
||
link.href = dataUrl;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
|
||
showToast('图片已保存,请在相册中查看');
|
||
}
|
||
|
||
// 隐藏右键菜单
|
||
function hideContextMenu() {
|
||
const existingMenu = document.getElementById('contextMenu');
|
||
if (existingMenu) {
|
||
existingMenu.remove();
|
||
}
|
||
}
|
||
|
||
// 复制图片到剪贴板
|
||
async function copyImageToClipboard() {
|
||
if (!currentImage) {
|
||
alert('请先添加背景图片');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
deselectAll();
|
||
const canvas = await generateCanvas();
|
||
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
|
||
|
||
await navigator.clipboard.write([
|
||
new ClipboardItem({ 'image/png': blob })
|
||
]);
|
||
|
||
alert('图片已复制到剪贴板');
|
||
} catch (err) {
|
||
console.error('复制失败:', err);
|
||
alert('复制失败,请尝试保存图片');
|
||
}
|
||
}
|
||
|
||
// 检测是否在微信内置浏览器
|
||
function isWechat() {
|
||
return /MicroMessenger/i.test(navigator.userAgent);
|
||
}
|
||
|
||
// 生成画布(高清无损,保持与显示容器相同的宽高比)- 性能优化版
|
||
async function generateCanvas() {
|
||
const startTime = performance.now();
|
||
const canvas = exportCanvas;
|
||
const container = imageContainer;
|
||
|
||
// 获取显示容器的实际尺寸和宽高比
|
||
const displayWidth = container.clientWidth;
|
||
const displayHeight = container.clientHeight;
|
||
const displayRatio = displayWidth / displayHeight;
|
||
|
||
// 根据设备像素比决定输出分辨率,移动端至少 2x
|
||
const dpr = window.devicePixelRatio || 1;
|
||
const minScale = Math.max(dpr, 2); // 至少 2x
|
||
|
||
// 先计算目标尺寸
|
||
let TARGET_WIDTH = Math.round(displayWidth * minScale);
|
||
let TARGET_HEIGHT = Math.round(displayHeight * minScale);
|
||
|
||
// 微信特殊处理:微信内置浏览器 canvas 限制更严格
|
||
// 实测微信中 canvas 最大尺寸约为 4096,但超过 2048 可能有问题
|
||
const MAX_CANVAS_SIZE = isWechat() ? 2048 : 4096;
|
||
|
||
// 如果超出限制,按宽高比等比例缩放
|
||
if (TARGET_HEIGHT > MAX_CANVAS_SIZE || TARGET_WIDTH > MAX_CANVAS_SIZE) {
|
||
const maxDimension = Math.max(TARGET_WIDTH, TARGET_HEIGHT);
|
||
const scaleFactor = MAX_CANVAS_SIZE / maxDimension;
|
||
TARGET_WIDTH = Math.round(TARGET_WIDTH * scaleFactor);
|
||
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * scaleFactor);
|
||
}
|
||
|
||
// 确保最小分辨率,同时保持宽高比
|
||
const minWidth = 1080;
|
||
const minHeight = 1920;
|
||
if (TARGET_WIDTH < minWidth || TARGET_HEIGHT < minHeight) {
|
||
const widthScale = minWidth / TARGET_WIDTH;
|
||
const heightScale = minHeight / TARGET_HEIGHT;
|
||
const scaleFactor = Math.max(widthScale, heightScale);
|
||
TARGET_WIDTH = Math.round(TARGET_WIDTH * scaleFactor);
|
||
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * scaleFactor);
|
||
|
||
// 再次检查是否超出限制
|
||
if (TARGET_HEIGHT > MAX_CANVAS_SIZE || TARGET_WIDTH > MAX_CANVAS_SIZE) {
|
||
const maxDimension = Math.max(TARGET_WIDTH, TARGET_HEIGHT);
|
||
const limitScale = MAX_CANVAS_SIZE / maxDimension;
|
||
TARGET_WIDTH = Math.round(TARGET_WIDTH * limitScale);
|
||
TARGET_HEIGHT = Math.round(TARGET_HEIGHT * limitScale);
|
||
}
|
||
}
|
||
|
||
console.log('目标画布尺寸:', TARGET_WIDTH, 'x', TARGET_HEIGHT, 'dpr:', dpr, '微信:', isWechat());
|
||
console.log('显示容器尺寸:', displayWidth, 'x', displayHeight);
|
||
|
||
// 更新 Loading 提示
|
||
if (document.getElementById('globalLoading')) {
|
||
loadingManager.updateMessage('正在初始化画布...');
|
||
}
|
||
|
||
// 临时显示 canvas 以避免浏览器对隐藏 canvas 的尺寸限制
|
||
canvas.removeAttribute('hidden');
|
||
canvas.style.position = 'fixed';
|
||
canvas.style.left = '-9999px';
|
||
canvas.style.top = '-9999px';
|
||
|
||
// 设置画布为高清分辨率
|
||
canvas.width = TARGET_WIDTH;
|
||
canvas.height = TARGET_HEIGHT;
|
||
|
||
// 微信中:验证 canvas 尺寸是否设置成功
|
||
if (isWechat() && (canvas.width !== TARGET_WIDTH || canvas.height !== TARGET_HEIGHT)) {
|
||
console.warn('Canvas 尺寸设置失败,使用实际尺寸:', canvas.width, 'x', canvas.height);
|
||
TARGET_WIDTH = canvas.width;
|
||
TARGET_HEIGHT = canvas.height;
|
||
}
|
||
|
||
console.log('实际画布尺寸:', canvas.width, 'x', canvas.height);
|
||
|
||
// 获取 2d 上下文(必须在设置尺寸之后)
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
// 启用最高质量渲染
|
||
ctx.imageSmoothingEnabled = true;
|
||
ctx.imageSmoothingQuality = 'high';
|
||
|
||
// 白色背景
|
||
ctx.fillStyle = '#fff';
|
||
ctx.fillRect(0, 0, TARGET_WIDTH, TARGET_HEIGHT);
|
||
|
||
// 计算从显示尺寸到目标分辨率的缩放比例
|
||
// 使用统一的缩放比例,保持宽高比,确保所见即所得
|
||
// 注意:必须在 canvas 尺寸验证之后计算 scale
|
||
const scale = TARGET_WIDTH / displayWidth;
|
||
console.log('统一缩放比例:', scale, '画布宽高比:', TARGET_WIDTH/TARGET_HEIGHT, '容器宽高比:', displayRatio);
|
||
|
||
// 绘制背景图(检查是否被隐藏)
|
||
if (currentImage && !bgImage.classList.contains('layer-hidden')) {
|
||
if (document.getElementById('globalLoading')) {
|
||
loadingManager.updateMessage('正在绘制背景...');
|
||
}
|
||
console.log('背景图原始尺寸:', currentImage.naturalWidth, 'x', currentImage.naturalHeight);
|
||
drawBgImageOnCanvasHD(ctx, currentImage, TARGET_WIDTH, TARGET_HEIGHT, scale);
|
||
}
|
||
|
||
// 绘制层叠图
|
||
if (document.getElementById('globalLoading')) {
|
||
loadingManager.updateMessage('正在绘制层叠图...');
|
||
}
|
||
await drawOverlayImagesHD(ctx, scale);
|
||
|
||
// 绘制文字
|
||
if (document.getElementById('globalLoading')) {
|
||
loadingManager.updateMessage('正在绘制文字...');
|
||
}
|
||
|
||
// 等待所有字体加载完成
|
||
const fontPromises = [];
|
||
const textInputs = container.querySelectorAll('.preview-input-layer .text-input');
|
||
textInputs.forEach(textInput => {
|
||
const style = window.getComputedStyle(textInput);
|
||
const fontFamily = style.fontFamily.split(',')[0].replace(/['"]/g, '').trim();
|
||
if (fontFamily && document.fonts) {
|
||
fontPromises.push(document.fonts.load('16px ' + fontFamily).catch(() => {}));
|
||
}
|
||
});
|
||
|
||
// 最多等待 500ms
|
||
await Promise.race([
|
||
Promise.all(fontPromises),
|
||
new Promise(resolve => setTimeout(resolve, 500))
|
||
]);
|
||
|
||
textInputs.forEach(textInput => {
|
||
// 跳过隐藏的文本图层
|
||
if (!textInput.classList.contains('layer-hidden')) {
|
||
drawTextOnCanvasHD(ctx, textInput, scale);
|
||
}
|
||
});
|
||
|
||
// 绘制二维码
|
||
const qrWrappers = container.querySelectorAll('.qr-item');
|
||
for (const qrWrapper of qrWrappers) {
|
||
if (!qrWrapper.classList.contains('layer-hidden')) {
|
||
const qrImg = qrWrapper.querySelector('img');
|
||
if (!qrImg) continue;
|
||
|
||
const rect = qrWrapper.getBoundingClientRect();
|
||
const containerRect = container.getBoundingClientRect();
|
||
const x = (rect.left - containerRect.left) * scale;
|
||
const y = (rect.top - containerRect.top) * scale;
|
||
const w = rect.width * scale;
|
||
const h = rect.height * scale;
|
||
|
||
// 应用透明度
|
||
const opacity = parseFloat(qrImg.style.opacity) || 1;
|
||
ctx.save();
|
||
ctx.globalAlpha = opacity;
|
||
|
||
// 确保图片已加载
|
||
if (qrImg.complete && qrImg.naturalWidth > 0) {
|
||
ctx.drawImage(qrImg, x, y, w, h);
|
||
} else {
|
||
// 等待图片加载完成
|
||
await new Promise(resolve => {
|
||
qrImg.onload = () => {
|
||
ctx.drawImage(qrImg, x, y, w, h);
|
||
resolve();
|
||
};
|
||
qrImg.onerror = () => resolve();
|
||
// 超时处理
|
||
setTimeout(resolve, 100);
|
||
});
|
||
}
|
||
|
||
ctx.restore();
|
||
}
|
||
}
|
||
|
||
// 记录总耗时
|
||
const endTime = performance.now();
|
||
console.log(`画布生成总耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||
|
||
return canvas;
|
||
}
|
||
|
||
// 绘制背景图
|
||
function drawBgImageOnCanvas(ctx, img, width, height) {
|
||
// 使用与编辑时完全相同的计算逻辑
|
||
const containerWidth = width;
|
||
const containerHeight = height;
|
||
const imgRatio = img.naturalWidth / img.naturalHeight;
|
||
const containerRatio = containerWidth / containerHeight;
|
||
|
||
let imgWidth, imgHeight;
|
||
|
||
if (imgRatio > containerRatio) {
|
||
imgHeight = containerHeight;
|
||
imgWidth = imgHeight * imgRatio;
|
||
} else {
|
||
imgWidth = containerWidth;
|
||
imgHeight = imgWidth / imgRatio;
|
||
}
|
||
|
||
// 应用缩放
|
||
const scaledWidth = imgWidth * bgImageScale;
|
||
const scaledHeight = imgHeight * bgImageScale;
|
||
|
||
// 计算位置(与 updateBgImage 函数完全一致)
|
||
const baseLeft = (containerWidth - scaledWidth) / 2;
|
||
const drawX = baseLeft + imageOffsetX;
|
||
const drawY = (containerHeight - scaledHeight) / 2;
|
||
|
||
ctx.globalAlpha = bgImageOpacity;
|
||
ctx.drawImage(img, drawX, drawY, scaledWidth, scaledHeight);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// 绘制背景图(高清版本)
|
||
function drawBgImageOnCanvasHD(ctx, img, canvasWidth, canvasHeight, scale) {
|
||
// 先按显示容器的尺寸计算(与 updateBgImage 完全一致)
|
||
const displayContainer = imageContainer;
|
||
const displayWidth = displayContainer.clientWidth;
|
||
const displayHeight = displayContainer.clientHeight;
|
||
const imgRatio = img.naturalWidth / img.naturalHeight;
|
||
const displayRatio = displayWidth / displayHeight;
|
||
|
||
let imgWidth, imgHeight;
|
||
|
||
if (imgRatio > displayRatio) {
|
||
imgHeight = displayHeight;
|
||
imgWidth = imgHeight * imgRatio;
|
||
} else {
|
||
imgWidth = displayWidth;
|
||
imgHeight = imgWidth / imgRatio;
|
||
}
|
||
|
||
// 应用缩放(与编辑时的 bgImageScale 一致)
|
||
const scaledWidth = imgWidth * bgImageScale;
|
||
const scaledHeight = imgHeight * bgImageScale;
|
||
|
||
// 计算位置(与 updateBgImage 函数完全一致)
|
||
const baseLeft = (displayWidth - scaledWidth) / 2;
|
||
const displayDrawX = baseLeft + imageOffsetX;
|
||
const displayDrawY = (displayHeight - scaledHeight) / 2;
|
||
|
||
// 将显示坐标转换为 canvas 坐标
|
||
const drawX = displayDrawX * scale;
|
||
const drawY = displayDrawY * scale;
|
||
const canvasScaledWidth = scaledWidth * scale;
|
||
const canvasScaledHeight = scaledHeight * scale;
|
||
|
||
ctx.globalAlpha = bgImageOpacity;
|
||
|
||
// 调试信息
|
||
console.log('背景图绘制:', {
|
||
canvas: canvasWidth + 'x' + canvasHeight,
|
||
scale: scale,
|
||
displayDrawX: displayDrawX,
|
||
displayDrawY: displayDrawY,
|
||
drawX: drawX,
|
||
drawY: drawY,
|
||
canvasScaledWidth: canvasScaledWidth,
|
||
canvasScaledHeight: canvasScaledHeight
|
||
});
|
||
|
||
ctx.drawImage(img, drawX, drawY, canvasScaledWidth, canvasScaledHeight);
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
// 绘制层叠图
|
||
function drawOverlayImages(ctx) {
|
||
return new Promise((resolve) => {
|
||
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||
if (overlayImages.length === 0) {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
let loadedCount = 0;
|
||
overlayImages.forEach(overlayEl => {
|
||
const imgEl = overlayEl.querySelector('img');
|
||
if (!imgEl) {
|
||
loadedCount++;
|
||
return;
|
||
}
|
||
|
||
// 使用 offsetLeft/offsetTop 获取相对于 overlayLayer 的位置
|
||
const left = overlayEl.offsetLeft;
|
||
const top = overlayEl.offsetTop;
|
||
const width = overlayEl.offsetWidth;
|
||
const height = overlayEl.offsetHeight;
|
||
|
||
ctx.globalAlpha = overlayEl.style.opacity || 1;
|
||
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
ctx.drawImage(img, left, top, width, height);
|
||
ctx.globalAlpha = 1;
|
||
loadedCount++;
|
||
if (loadedCount >= overlayImages.length) {
|
||
resolve();
|
||
}
|
||
};
|
||
img.onerror = () => {
|
||
ctx.globalAlpha = 1;
|
||
loadedCount++;
|
||
if (loadedCount >= overlayImages.length) {
|
||
resolve();
|
||
}
|
||
};
|
||
img.src = imgEl.src;
|
||
});
|
||
});
|
||
}
|
||
|
||
// 绘制层叠图(高清版本)- 性能优化版,使用图片缓存
|
||
async function drawOverlayImagesHD(ctx, scale) {
|
||
const overlayImages = overlayLayer.querySelectorAll('.overlay-image');
|
||
if (overlayImages.length === 0) {
|
||
return;
|
||
}
|
||
|
||
const totalImages = overlayImages.length;
|
||
let processedCount = 0;
|
||
|
||
// 批量处理,每批最多5个
|
||
const BATCH_SIZE = 5;
|
||
const batches = [];
|
||
|
||
for (let i = 0; i < overlayImages.length; i += BATCH_SIZE) {
|
||
batches.push(Array.from(overlayImages).slice(i, i + BATCH_SIZE));
|
||
}
|
||
|
||
for (const batch of batches) {
|
||
await Promise.all(batch.map(async (overlayEl) => {
|
||
// 跳过隐藏的图层
|
||
if (overlayEl.classList.contains('layer-hidden')) {
|
||
processedCount++;
|
||
return;
|
||
}
|
||
|
||
const imgEl = overlayEl.querySelector('img');
|
||
if (!imgEl) {
|
||
processedCount++;
|
||
return;
|
||
}
|
||
|
||
// 使用 offsetLeft/offsetTop 获取相对于 overlayLayer 的位置,并应用统一缩放
|
||
const left = overlayEl.offsetLeft * scale;
|
||
const top = overlayEl.offsetTop * scale;
|
||
const width = overlayEl.offsetWidth * scale;
|
||
const height = overlayEl.offsetHeight * scale;
|
||
|
||
ctx.globalAlpha = overlayEl.style.opacity || 1;
|
||
|
||
// 获取旋转角度
|
||
const overlayTransform = overlayEl.style.transform || '';
|
||
const rotateMatch = overlayTransform.match(/rotate\(([-\d.]+)deg\)/);
|
||
const rotateDeg = rotateMatch ? parseFloat(rotateMatch[1]) : 0;
|
||
|
||
// 获取镜像状态
|
||
const imgTransform = imgEl.style.transform || '';
|
||
const isMirroredH = imgTransform.includes('scaleX(-1)');
|
||
const isMirroredV = imgTransform.includes('scaleY(-1)');
|
||
|
||
try {
|
||
// 优先使用缓存的图片
|
||
let imgToDraw;
|
||
if (imgEl.complete && imgEl.naturalWidth > 0) {
|
||
imgToDraw = imgEl;
|
||
} else {
|
||
// 尝试从缓存获取
|
||
imgToDraw = await getCachedImage(imgEl.src);
|
||
}
|
||
|
||
drawImageWithTransform(ctx, imgToDraw, left, top, width, height, rotateDeg, isMirroredH, isMirroredV);
|
||
} catch (err) {
|
||
console.warn('层叠图绘制失败:', err);
|
||
} finally {
|
||
ctx.globalAlpha = 1;
|
||
processedCount++;
|
||
|
||
// 更新 Loading 进度
|
||
if (document.getElementById('globalLoading') && processedCount % 3 === 0) {
|
||
loadingManager.updateMessage(`正在绘制层叠图 (${processedCount}/${totalImages})...`);
|
||
}
|
||
}
|
||
}));
|
||
|
||
// 每批处理后让出主线程,避免阻塞
|
||
await new Promise(resolve => setTimeout(resolve, 0));
|
||
}
|
||
}
|
||
|
||
// 绘制带旋转和镜像变换的图片
|
||
function drawImageWithTransform(ctx, img, x, y, width, height, rotateDeg, isMirroredH, isMirroredV) {
|
||
ctx.save();
|
||
|
||
// 移动到图片中心点
|
||
const centerX = x + width / 2;
|
||
const centerY = y + height / 2;
|
||
ctx.translate(centerX, centerY);
|
||
|
||
// 应用旋转
|
||
if (rotateDeg !== 0) {
|
||
ctx.rotate(rotateDeg * Math.PI / 180);
|
||
}
|
||
|
||
// 应用镜像
|
||
let scaleX = isMirroredH ? -1 : 1;
|
||
let scaleY = isMirroredV ? -1 : 1;
|
||
ctx.scale(scaleX, scaleY);
|
||
|
||
// 绘制图片(从中心点偏移一半宽高)
|
||
ctx.drawImage(img, -width / 2, -height / 2, width, height);
|
||
|
||
ctx.restore();
|
||
}
|
||
|
||
// 在画布上绘制文字
|
||
function drawTextOnCanvas(ctx, inputElement) {
|
||
const deleteBtn = inputElement.querySelector('.text-delete-btn');
|
||
let text = inputElement.textContent;
|
||
if (deleteBtn) {
|
||
text = text.replace(deleteBtn.textContent, '');
|
||
}
|
||
if (!text.trim()) return;
|
||
|
||
// 使用 offsetLeft/offsetTop 获取相对于 preview-input-layer 的位置
|
||
const inputLeft = inputElement.offsetLeft;
|
||
const inputTop = inputElement.offsetTop;
|
||
|
||
const style = window.getComputedStyle(inputElement);
|
||
const fontSize = parseInt(style.fontSize);
|
||
const lineHeight = parseFloat(style.lineHeight);
|
||
|
||
ctx.font = `${style.fontWeight} ${fontSize}px ${style.fontFamily}`;
|
||
ctx.fillStyle = style.color;
|
||
ctx.textBaseline = 'alphabetic';
|
||
|
||
// 计算文本垂直偏移量,使其与 DOM 渲染一致
|
||
// DOM 中 line-height 会影响文本位置,需要计算实际文本基线位置
|
||
const actualLineHeight = lineHeight || fontSize * 1.4;
|
||
const textOffsetY = (actualLineHeight - fontSize) / 2 + fontSize * 0.85;
|
||
|
||
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
||
}
|
||
|
||
// 在画布上绘制文字(高清版本)
|
||
function drawTextOnCanvasHD(ctx, inputElement, scale) {
|
||
const deleteBtn = inputElement.querySelector('.text-delete-btn');
|
||
let text = inputElement.textContent;
|
||
if (deleteBtn) {
|
||
text = text.replace(deleteBtn.textContent, '');
|
||
}
|
||
if (!text.trim()) return;
|
||
|
||
// 使用 offsetLeft/offsetTop 获取相对于 preview-input-layer 的位置,并应用统一缩放
|
||
const inputLeft = inputElement.offsetLeft * scale;
|
||
const inputTop = inputElement.offsetTop * scale;
|
||
|
||
// 强制刷新样式,确保获取最新计算值
|
||
inputElement.getBoundingClientRect();
|
||
|
||
const style = window.getComputedStyle(inputElement);
|
||
const originalFontSize = parseInt(style.fontSize);
|
||
const fontSize = originalFontSize * scale; // 字体大小也按比例缩放
|
||
|
||
// lineHeight 可能是像素值或数字(倍数),需要正确处理
|
||
let lineHeight = parseFloat(style.lineHeight);
|
||
// 如果 lineHeight 是数字(小于 10 认为是倍数),转换为像素值
|
||
if (lineHeight && lineHeight < 10) {
|
||
lineHeight = lineHeight * originalFontSize * scale;
|
||
} else if (lineHeight) {
|
||
// 已经是像素值,按比例缩放
|
||
lineHeight = lineHeight * scale;
|
||
}
|
||
|
||
// 处理字体粗细 - 直接使用原始数值(canvas 支持 1~1000 的数值权重)
|
||
var fontWeight = style.fontWeight;
|
||
// 仅将 CSS 关键字映射为对应数值
|
||
if (fontWeight === 'normal') fontWeight = '400';
|
||
else if (fontWeight === 'bold') fontWeight = '700';
|
||
|
||
// 获取斜体样式
|
||
let fontStyle = style.fontStyle;
|
||
if (fontStyle === 'oblique') fontStyle = 'italic';
|
||
else if (fontStyle !== 'italic') fontStyle = 'normal';
|
||
|
||
// 清理字体名称中的引号
|
||
let fontFamily = style.fontFamily.replace(/['"]/g, '');
|
||
// 确保字体堆栈有效
|
||
if (!fontFamily.includes('sans-serif') && !fontFamily.includes('serif') && !fontFamily.includes('monospace')) {
|
||
fontFamily += ', sans-serif';
|
||
}
|
||
|
||
// 使用高清字体渲染 - 按标准顺序:style weight size family
|
||
const fontString = `${fontStyle} ${fontWeight} ${fontSize}px ${fontFamily}`;
|
||
ctx.font = fontString;
|
||
ctx.fillStyle = style.color;
|
||
|
||
// 调试:输出实际应用的字体样式
|
||
console.log('文本绘制样式:', {
|
||
text: text.substring(0, 10) + (text.length > 10 ? '...' : ''),
|
||
fontString: fontString,
|
||
fontSize: fontSize,
|
||
fontWeight: fontWeight,
|
||
fontStyle: fontStyle,
|
||
fontFamily: fontFamily,
|
||
originalFontFamily: style.fontFamily,
|
||
color: style.color,
|
||
actualFont: ctx.font,
|
||
isWechat: isWechat()
|
||
});
|
||
ctx.textBaseline = 'alphabetic';
|
||
|
||
// 微信中:开启文字抗锯齿
|
||
if (isWechat()) {
|
||
ctx.textRendering = 'optimizeLegibility';
|
||
}
|
||
|
||
// 计算文本垂直偏移量,使其与 DOM 渲染一致
|
||
// 使用缩放后的 lineHeight 计算
|
||
const actualLineHeight = lineHeight || fontSize * 1.4;
|
||
const textOffsetY = (actualLineHeight - fontSize) / 2 + fontSize * 0.85;
|
||
|
||
ctx.fillText(text, inputLeft, inputTop + textOffsetY);
|
||
}
|
||
|
||
// 保存图片(带 Loading 提示)
|
||
async function saveImage() {
|
||
if (!currentImage) {
|
||
alert('请先添加背景图片');
|
||
return;
|
||
}
|
||
|
||
// 显示 Loading
|
||
loadingManager.show('正在生成图片...');
|
||
|
||
try {
|
||
const startTime = performance.now();
|
||
const canvas = await generateCanvas();
|
||
|
||
// 更新 Loading 提示
|
||
loadingManager.updateMessage('正在准备保存...');
|
||
|
||
await showMobileSaveTip(canvas);
|
||
|
||
const endTime = performance.now();
|
||
console.log(`图片保存耗时: ${(endTime - startTime).toFixed(2)}ms`);
|
||
} catch (err) {
|
||
console.error('保存失败:', err);
|
||
alert('保存失败,请重试');
|
||
} finally {
|
||
// 隐藏 Loading
|
||
loadingManager.hide();
|
||
}
|
||
}
|
||
|
||
// 显示移动端保存选项
|
||
async function showMobileSaveTip(canvas) {
|
||
console.log('导出画布尺寸:', canvas.width, 'x', canvas.height);
|
||
|
||
// 在 toBlob 之前再次确认 canvas 尺寸
|
||
console.log('toBlob 前 canvas 尺寸:', canvas.width, 'x', canvas.height);
|
||
|
||
// 微信中使用 toBlob 更稳定,且质量参数 1.0 确保无损
|
||
let dataUrl;
|
||
let blob;
|
||
|
||
// 微信中优先使用 toBlob,避免 toDataURL 的内存问题
|
||
if (isWechat()) {
|
||
try {
|
||
blob = await new Promise((resolve, reject) => {
|
||
canvas.toBlob((b) => {
|
||
if (b) {
|
||
console.log('微信 toBlob 成功,blob 尺寸:', b.size, 'bytes');
|
||
resolve(b);
|
||
} else {
|
||
reject(new Error('toBlob 返回 null'));
|
||
}
|
||
}, 'image/png', 1.0); // 1.0 = 最高质量无损 PNG
|
||
});
|
||
dataUrl = URL.createObjectURL(blob);
|
||
} catch (err) {
|
||
console.log('微信 toBlob 失败,回退到 toDataURL:', err);
|
||
dataUrl = canvas.toDataURL('image/png');
|
||
const response = await fetch(dataUrl);
|
||
blob = await response.blob();
|
||
}
|
||
} else {
|
||
// 非微信环境,先尝试 toDataURL
|
||
try {
|
||
dataUrl = canvas.toDataURL('image/png');
|
||
console.log('toDataURL 成功,dataUrl 长度:', dataUrl.length);
|
||
|
||
const response = await fetch(dataUrl);
|
||
blob = await response.blob();
|
||
console.log('dataUrl 转 blob 成功,blob 尺寸:', blob.size, 'bytes');
|
||
} catch (err) {
|
||
console.log('toDataURL 失败,使用 toBlob:', err);
|
||
blob = await new Promise(resolve => {
|
||
canvas.toBlob((b) => {
|
||
console.log('toBlob 回调 - blob 尺寸:', b.size, 'bytes');
|
||
resolve(b);
|
||
}, 'image/png', 1.0);
|
||
});
|
||
dataUrl = URL.createObjectURL(blob);
|
||
}
|
||
}
|
||
|
||
console.log('导出图片大小:', (blob.size / 1024).toFixed(2), 'KB');
|
||
const filename = '邀请函_' + Date.now() + '.png';
|
||
|
||
// 保存 blob 供后续使用
|
||
window._lastExportBlob = blob;
|
||
|
||
const tipOverlay = document.createElement('div');
|
||
tipOverlay.id = 'mobileSaveTip';
|
||
tipOverlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.6);
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
`;
|
||
|
||
const savePanel = document.createElement('div');
|
||
savePanel.style.cssText = `
|
||
width: 100%;
|
||
background: #fff;
|
||
border-radius: 16px 16px 0 0;
|
||
padding: 20px;
|
||
padding-bottom: 40px;
|
||
`;
|
||
|
||
const title = document.createElement('p');
|
||
title.textContent = '保存图片';
|
||
title.style.cssText = `
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
color: #333;
|
||
`;
|
||
|
||
const saveBtn = document.createElement('button');
|
||
saveBtn.textContent = '保存到相册';
|
||
saveBtn.style.cssText = `
|
||
width: 100%;
|
||
padding: 16px;
|
||
font-size: 16px;
|
||
background: #007AFF;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
margin-bottom: 12px;
|
||
`;
|
||
saveBtn.onclick = async () => {
|
||
tipOverlay.remove();
|
||
await saveImageToGallery(dataUrl, blob);
|
||
};
|
||
|
||
const cancelBtn = document.createElement('button');
|
||
cancelBtn.textContent = '取消';
|
||
cancelBtn.style.cssText = `
|
||
width: 100%;
|
||
padding: 16px;
|
||
font-size: 16px;
|
||
background: #F2F2F7;
|
||
color: #333;
|
||
border: none;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
`;
|
||
cancelBtn.onclick = () => {
|
||
tipOverlay.remove();
|
||
// 重新隐藏 canvas
|
||
exportCanvas.setAttribute('hidden', '');
|
||
exportCanvas.style.position = '';
|
||
exportCanvas.style.left = '';
|
||
exportCanvas.style.top = '';
|
||
};
|
||
|
||
tipOverlay.addEventListener('click', (e) => {
|
||
if (e.target === tipOverlay) {
|
||
tipOverlay.remove();
|
||
// 重新隐藏 canvas
|
||
exportCanvas.setAttribute('hidden', '');
|
||
exportCanvas.style.position = '';
|
||
exportCanvas.style.left = '';
|
||
exportCanvas.style.top = '';
|
||
}
|
||
});
|
||
|
||
savePanel.appendChild(title);
|
||
savePanel.appendChild(saveBtn);
|
||
savePanel.appendChild(cancelBtn);
|
||
tipOverlay.appendChild(savePanel);
|
||
document.body.appendChild(tipOverlay);
|
||
|
||
// 添加预览按钮
|
||
const previewBtn = document.createElement('button');
|
||
previewBtn.textContent = '预览图片';
|
||
previewBtn.style.cssText = `
|
||
width: 100%;
|
||
padding: 16px;
|
||
font-size: 16px;
|
||
background: #34C759;
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 10px;
|
||
cursor: pointer;
|
||
margin-bottom: 12px;
|
||
`;
|
||
previewBtn.onclick = () => {
|
||
tipOverlay.remove();
|
||
showPreviewFromBlob(blob);
|
||
};
|
||
|
||
// 插入到标题后面
|
||
savePanel.insertBefore(previewBtn, saveBtn);
|
||
}
|
||
|
||
// 显示 Toast 提示
|
||
function showToast(message) {
|
||
const toast = document.createElement('div');
|
||
toast.style.cssText = `
|
||
position: fixed;
|
||
top: 50%;
|
||
left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
background: rgba(0, 0, 0, 0.8);
|
||
color: #fff;
|
||
padding: 16px 24px;
|
||
border-radius: 8px;
|
||
font-size: 14px;
|
||
z-index: 10001;
|
||
pointer-events: none;
|
||
animation: fadeInOut 2s ease forwards;
|
||
`;
|
||
toast.textContent = message;
|
||
|
||
// 添加动画样式
|
||
if (!document.getElementById('toastStyle')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'toastStyle';
|
||
style.textContent = `
|
||
@keyframes fadeInOut {
|
||
0% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
||
20% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||
80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||
100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
document.body.appendChild(toast);
|
||
setTimeout(() => toast.remove(), 2000);
|
||
}
|
||
|
||
// 窗口大小改变时更新图片位置和边界
|
||
window.addEventListener('resize', () => {
|
||
if (currentImage) {
|
||
calculateImageBounds();
|
||
updateImagePosition();
|
||
}
|
||
});
|
||
})();
|