Files
invitation-maker/js/script.js
Zhukj ef6010a8b2 perf: 优化图片渲染性能和内存管理
重构图片渲染逻辑,引入图片缓存系统和状态压缩机制
添加批量处理层叠图功能,每批5个图片处理
实现内存监控和自动清理机制
增加全局Loading管理器显示操作进度
优化撤销重做功能的状态比较和保存逻辑
2026-04-23 16:08:31 +08:00

3482 lines
124 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 极简邀请函制作工具 - 性能优化版本
(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 boldBtn = document.getElementById('boldBtn');
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 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();
// 初始化输入框
initInput(input1);
initInput(input2);
initInput(input3);
// 为三个输入框添加删除按钮
[input1, input2, input3].forEach(inputEl => {
const deleteBtn = document.createElement('button');
deleteBtn.className = 'text-delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
inputEl.remove();
});
inputEl.appendChild(deleteBtn);
});
// 初始化图片滑动
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');
// 点击背景/空白区域
if (isBackground && !isOverlayImage) {
// 如果正在编辑,先结束编辑
if (editingInput) {
endEdit(editingInput);
}
// 移除输入框选中状态
if (selectedInput) {
selectedInput.classList.remove('selected');
selectedInput = null;
}
// 取消层叠图选中状态
if (selectedOverlay) {
selectedOverlay.classList.remove('selected');
selectedOverlay = null;
}
// 隐藏辅助线
hideGuides();
// 隐藏编辑器工具栏
editorToolbar.classList.remove('active');
}
// 点击空白区域(不是层叠图和输入框)
if (!isOverlayImage && !isTextInput && !isBackground) {
if (selectedOverlay) {
selectedOverlay.classList.remove('selected');
selectedOverlay = null;
}
}
}
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();
// 加载默认背景图
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 {
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 : ''
};
}),
// 文本输入框状态
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'
})),
// 添加时间戳用于状态管理
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;
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;
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');
// 清除现有内容(保留删除按钮)
const deleteBtn = input.querySelector('.text-delete-btn');
input.innerHTML = '';
// 设置文本内容
input.appendChild(document.createTextNode(inputData.content));
// 重新添加删除按钮
if (deleteBtn) {
input.appendChild(deleteBtn);
} else {
const newDeleteBtn = document.createElement('button');
newDeleteBtn.className = 'text-delete-btn';
newDeleteBtn.innerHTML = '×';
newDeleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
input.remove();
saveState();
});
input.appendChild(newDeleteBtn);
}
// 应用样式
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');
}
// 检查是否需要显示 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;
}
}
// 加载默认背景图
function loadDefaultBackground() {
const defaultBgUrl = './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.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 deleteBtn = document.createElement('button');
deleteBtn.className = 'text-delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
textEl.remove();
});
textEl.appendChild(deleteBtn);
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.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);
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
overlayEl.remove();
// 保存状态(立即)
saveState(true);
});
overlayEl.appendChild(deleteBtn);
// 居中显示
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.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);
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.innerHTML = '×';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
overlayEl.remove();
saveState(true);
});
overlayEl.appendChild(deleteBtn);
// 恢复位置和尺寸
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 || '';
// 初始化拖拽和缩放
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;
}
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 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;
e.stopPropagation();
isInteractingWithBgImage = true;
// 显示背景图工具栏
editorToolbar.classList.add('active');
textTools.classList.remove('active');
imageTools.classList.remove('active');
bgTools.classList.add('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;
e.stopPropagation();
isInteractingWithBgImage = true;
// 显示背景图工具栏
editorToolbar.classList.add('active');
textTools.classList.remove('active');
imageTools.classList.remove('active');
bgTools.classList.add('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');
// 同步滑块值
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;
}
}
// 初始化输入框 - 每个输入框独立管理自己的拖拽状态
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';
// 显示对齐辅助线
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;
// 选中并立即进入编辑模式
selectInput(element);
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');
// 更新工具栏状态
updateToolbarState(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');
// 移动端/编辑模式:固定在顶部始终可见
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);
// 更新加粗按钮状态
boldBtn.classList.toggle('active', fontWeight === 'bold' || fontWeight === '700' || parseInt(fontWeight) >= 700);
// 更新斜体按钮状态
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;
}
// 更新锁定按钮状态
lockBtn.classList.toggle('active', element.classList.contains('locked'));
// 更新字号选择器
const fontSizeValueEl = fontSizePicker.querySelector('.roller-value');
if (fontSizeValueEl) {
const displaySize = 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;
}
// 隐藏编辑器工具栏
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() {
// 加粗按钮
boldBtn.addEventListener('click', () => {
const targetInput = editingInput || selectedInput;
if (!targetInput) return;
const selection = window.getSelection();
if (selection.rangeCount > 0 && !selection.isCollapsed) {
document.execCommand('bold', false, null);
} else {
const isBold = boldBtn.classList.contains('active');
if (isBold) {
targetInput.style.fontWeight = 'normal';
} else {
targetInput.style.fontWeight = 'bold';
}
boldBtn.classList.toggle('active');
}
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 updateFontSize(newSize, forceUpdate = false) {
newSize = Math.max(10, Math.min(42, newSize));
const targetInput = editingInput || selectedInput;
if (forceUpdate || targetInput) {
targetInput.style.fontSize = newSize + 'px';
fontSizeValue = newSize;
fontSizeValueEl.textContent = newSize;
} else {
fontSizeValue = newSize;
fontSizeValueEl.textContent = newSize;
}
}
fontSizeUpBtn.addEventListener('click', () => {
const targetInput = editingInput || selectedInput;
if (targetInput) {
const currentSize = parseInt(window.getComputedStyle(targetInput).fontSize) || fontSizeValue;
updateFontSize(currentSize + 1, true);
saveState();
}
});
fontSizeDownBtn.addEventListener('click', () => {
const targetInput = editingInput || selectedInput;
if (targetInput) {
const currentSize = parseInt(window.getComputedStyle(targetInput).fontSize) || fontSizeValue;
updateFontSize(currentSize - 1, true);
saveState();
}
});
fontSizeRoller.addEventListener('wheel', (e) => {
e.preventDefault();
const targetInput = editingInput || selectedInput;
if (targetInput) {
const currentSize = parseInt(window.getComputedStyle(targetInput).fontSize) || fontSizeValue;
const delta = e.deltaY > 0 ? -1 : 1;
updateFontSize(currentSize + delta, true);
saveState();
}
}, { passive: false });
}
// 初始化键盘微调功能(方向键 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) {
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 => {
drawTextOnCanvasHD(ctx, textInput, scale);
});
// 记录总耗时
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) => {
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;
}
// 处理字体粗细 - 统一转换为标准格式
let fontWeight = style.fontWeight;
// 数值转换为关键字
if (fontWeight === '400' || fontWeight === 'normal') fontWeight = 'normal';
else if (fontWeight === '700' || fontWeight === 'bold') fontWeight = 'bold';
// 获取斜体样式
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();
}
});
})();