Files
image-editor/templates/index.html

1491 lines
62 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>图片编辑器 - Image Editor v1.1.0</title>
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" rel="stylesheet">
<style>
.canvas-container {
position: relative;
background: #f0f0f0;
border: 2px dashed #ccc;
min-height: 400px;
display: flex;
align-items: center;
justify-content: center;
}
.canvas-container.has-image {
background: transparent;
border: none;
}
.tool-btn {
transition: all 0.2s;
}
.tool-btn:hover {
transform: scale(1.05);
}
.tool-btn.active {
background: #3b82f6;
color: white;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show {
display: flex;
}
.modal-content {
background: white;
padding: 20px;
border-radius: 10px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
input[type="file"] {
display: none;
}
.drop-zone {
border: 3px dashed #3b82f6;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.drop-zone:hover {
background: rgba(59, 130, 246, 0.1);
}
.image-order-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: #f3f4f6;
border-radius: 8px;
margin-bottom: 4px;
}
.image-order-item:hover {
background: #e5e7eb;
}
.drag-handle {
cursor: grab;
padding: 4px;
}
.drag-handle:hover {
background: #d1d5db;
border-radius: 4px;
}
</style>
</head>
<body class="bg-gray-100 min-h-screen">
<!-- 顶部工具栏 -->
<div class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 py-3">
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-800">
<i class="ri-image-edit-line mr-2"></i>图片编辑器
</h1>
<div class="flex gap-2">
<!-- 撤销/恢复按钮 -->
<button onclick="undo()" id="undoBtn" class="px-3 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 disabled:opacity-50" disabled>
<i class="ri-arrow-go-back-line"></i>
</button>
<button onclick="redo()" id="redoBtn" class="px-3 py-2 bg-gray-200 text-gray-600 rounded-lg hover:bg-gray-300 disabled:opacity-50" disabled>
<i class="ri-arrow-go-forward-line"></i>
</button>
<button onclick="saveImage()" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600">
<i class="ri-save-line mr-1"></i>保存
</button>
<button onclick="downloadImage()" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">
<i class="ri-download-line mr-1"></i>下载
</button>
<button onclick="clearCanvas()" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600">
<i class="ri-delete-bin-line mr-1"></i>清空
</button>
</div>
</div>
<!-- 功能按钮 -->
<div class="mt-3 flex gap-2 flex-wrap">
<button onclick="uploadImage()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-upload-cloud-line mr-1"></i>上传图片
</button>
<button onclick="showMergeModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-stack-line mr-1"></i>合并图片
</button>
<button onclick="showSplitModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-layout-grid-line mr-1"></i>分割图片
</button>
<button onclick="showHoleModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-contrast-drop-line mr-1"></i>挖孔填充
</button>
<button onclick="showCircleModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-checkbox-blank-circle-line mr-1"></i>圆形切图
</button>
<button onclick="showTextModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-font-size mr-1"></i>文字图片
</button>
<button onclick="showResizeModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-expand-diagonal-line mr-1"></i>调整大小
</button>
<button onclick="showCropModal()" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-crop-line mr-1"></i>裁剪
</button>
</div>
</div>
</div>
<!-- 主画布区域 -->
<div class="max-w-6xl mx-auto px-4 py-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<div id="canvasContainer" class="canvas-container rounded-lg overflow-hidden">
<canvas id="mainCanvas"></canvas>
<div id="dropOverlay" class="drop-zone absolute inset-0 hidden">
<i class="ri-upload-cloud-2-line text-6xl text-blue-500"></i>
<p class="mt-2 text-gray-600">拖放图片到此处</p>
</div>
<div id="emptyHint" class="text-gray-400 text-center">
<i class="ri-image-add-line text-6xl"></i>
<p class="mt-2">点击"上传图片"或拖放图片开始编辑</p>
</div>
</div>
<!-- 图片列表 -->
<div id="imageList" class="mt-4 hidden">
<h3 class="text-sm font-medium text-gray-600 mb-2">已加载图片 <span id="imageCount">(0张)</span></h3>
<div id="imageItems" class="flex gap-2 overflow-x-auto pb-2"></div>
</div>
</div>
<!-- 操作历史 -->
<div class="bg-white rounded-lg shadow-sm p-4 mt-4">
<h3 class="text-sm font-medium text-gray-600 mb-2">操作历史 <span id="historyInfo">(当前: 0/0)</span></h3>
<div id="historyList" class="flex gap-2 overflow-x-auto pb-2">
<span class="text-gray-400 text-sm">暂无操作</span>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input type="file" id="fileInput" accept="image/*" multiple onchange="handleFileSelect(event)">
<!-- 合并图片模态框 (增强版) -->
<div id="mergeModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-stack-line mr-2"></i>合并图片</h2>
<div class="space-y-4">
<!-- 图片顺序调整 -->
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">图片顺序(拖动调整)</label>
<div id="mergeImageOrder" class="border rounded-lg p-2 min-h-100">
<p class="text-gray-400 text-sm">请先上传图片</p>
</div>
</div>
<!-- 每张图片的尺寸设置 -->
<div id="mergeSizeSettings" class="hidden">
<label class="block text-sm font-medium text-gray-600 mb-2">各图片尺寸设置</label>
<div id="mergeSizeList" class="space-y-2"></div>
<div class="mt-2 flex items-center gap-2">
<input type="checkbox" id="mergeUniformSize" checked class="rounded">
<span class="text-sm">统一尺寸(所有图片使用相同尺寸)</span>
</div>
<div id="uniformSizeSettings" class="mt-2 grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500">宽度</label>
<input type="number" id="mergeUniformWidth" class="w-full border rounded px-2 py-1" placeholder="自动">
</div>
<div>
<label class="block text-xs text-gray-500">高度</label>
<input type="number" id="mergeUniformHeight" class="w-full border rounded px-2 py-1" placeholder="自动">
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">合并方式</label>
<select id="mergeDirection" class="w-full border rounded-lg px-3 py-2">
<option value="horizontal">横向合并(左右拼接)</option>
<option value="vertical">纵向合并(上下拼接)</option>
<option value="grid">网格合并(行列排列)</option>
</select>
</div>
<div id="gridSettings" class="hidden">
<label class="block text-sm font-medium text-gray-600 mb-1">网格设置</label>
<div class="flex gap-2">
<input type="number" id="gridCols" value="2" min="1" max="10" class="border rounded-lg px-3 py-2 w-1/2" placeholder="列数">
<input type="number" id="gridRows" value="2" min="1" max="10" class="border rounded-lg px-3 py-2 w-1/2" placeholder="行数">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">间距(像素)</label>
<input type="number" id="mergeGap" value="0" min="0" max="100" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">背景颜色</label>
<input type="color" id="mergeBgColor" value="#ffffff" class="w-full h-10 rounded-lg">
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('mergeModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="applyMerge()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">应用</button>
</div>
</div>
</div>
<!-- 分割图片模态框 -->
<div id="splitModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-layout-grid-line mr-2"></i>分割图片</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">分割方式</label>
<select id="splitMode" class="w-full border rounded-lg px-3 py-2">
<option value="grid">等分网格</option>
<option value="custom">自定义分割</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">分割数量</label>
<div class="flex gap-2">
<input type="number" id="splitCols" value="2" min="1" max="20" class="border rounded-lg px-3 py-2 w-1/2" placeholder="列数">
<input type="number" id="splitRows" value="2" min="1" max="20" class="border rounded-lg px-3 py-2 w-1/2" placeholder="行数">
</div>
</div>
<p class="text-sm text-gray-500">分割后的图片将显示在画布下方,可单独下载</p>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('splitModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="applySplit()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">应用</button>
</div>
</div>
</div>
<!-- 挖孔填充模态框 -->
<div id="holeModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-contrast-drop-line mr-2"></i>挖孔填充</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">挖孔形状</label>
<select id="holeShape" class="w-full border rounded-lg px-3 py-2">
<option value="rectangle">矩形</option>
<option value="circle">圆形</option>
<option value="ellipse">椭圆</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">填充方式</label>
<select id="holeFill" class="w-full border rounded-lg px-3 py-2">
<option value="color">填充颜色</option>
<option value="transparent">透明</option>
<option value="blur">模糊</option>
<option value="content-aware">智能填充(边缘延伸)</option>
</select>
</div>
<div id="holeColorPicker">
<label class="block text-sm font-medium text-gray-600 mb-1">填充颜色</label>
<input type="color" id="holeColor" value="#ffffff" class="w-full h-10 rounded-lg">
</div>
<div id="holeBlurSetting" class="hidden">
<label class="block text-sm font-medium text-gray-600 mb-1">模糊程度</label>
<input type="range" id="holeBlurRadius" value="10" min="1" max="50" class="w-full">
</div>
<p class="text-sm text-gray-500">在画布上拖动选择要挖孔的区域</p>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('holeModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="startHoleSelection()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">开始选择</button>
</div>
</div>
</div>
<!-- 圆形切图模态框 -->
<div id="circleModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-checkbox-blank-circle-line mr-2"></i>圆形切图</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">圆形直径</label>
<input type="number" id="circleDiameter" value="200" min="10" class="w-full border rounded-lg px-3 py-2">
<p class="text-sm text-gray-500 mt-1">留空则在画布上拖动选择区域</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">边缘处理</label>
<select id="circleEdge" class="w-full border rounded-lg px-3 py-2">
<option value="sharp">锐利边缘</option>
<option value="smooth">平滑边缘</option>
<option value="border">带边框</option>
</select>
</div>
<div id="circleBorderSetting" class="hidden">
<label class="block text-sm font-medium text-gray-600 mb-1">边框颜色</label>
<input type="color" id="circleBorderColor" value="#3b82f6" class="w-full h-10 rounded-lg">
<label class="block text-sm font-medium text-gray-600 mb-1 mt-2">边框宽度</label>
<input type="number" id="circleBorderWidth" value="3" min="1" max="20" class="w-full border rounded-lg px-3 py-2">
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('circleModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="applyCircleCut()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">应用</button>
</div>
</div>
</div>
<!-- 文字图片模态框 -->
<div id="textModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-font-size mr-2"></i>文字图片</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">文字内容</label>
<textarea id="textContent" rows="3" class="w-full border rounded-lg px-3 py-2" placeholder="输入文字内容..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">字体</label>
<select id="textFont" class="w-full border rounded-lg px-3 py-2">
<option value="Arial">Arial</option>
<option value="Microsoft YaHei">微软雅黑</option>
<option value="SimSun">宋体</option>
<option value="SimHei">黑体</option>
<option value="KaiTi">楷体</option>
<option value="sans-serif">Sans-serif</option>
<option value="serif">Serif</option>
<option value="monospace">Monospace</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">字体大小</label>
<input type="number" id="textSize" value="48" min="12" max="200" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">文字颜色</label>
<input type="color" id="textColor" value="#000000" class="w-full h-10 rounded-lg">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">背景颜色</label>
<div class="flex items-center gap-2">
<input type="checkbox" id="textTransparentBg" class="rounded">
<span class="text-sm">透明背景</span>
</div>
<input type="color" id="textBgColor" value="#ffffff" class="w-full h-10 rounded-lg mt-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">文字样式</label>
<div class="flex gap-2">
<button id="textBold" onclick="toggleStyle('bold')" class="px-3 py-1 border rounded">B</button>
<button id="textItalic" onclick="toggleStyle('italic')" class="px-3 py-1 border rounded italic">I</button>
<select id="textAlign" class="border rounded px-2 py-1">
<option value="left">左对齐</option>
<option value="center">居中</option>
<option value="right">右对齐</option>
</select>
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('textModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="createTextImage()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">生成</button>
</div>
</div>
</div>
<!-- 调整大小模态框 -->
<div id="resizeModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-expand-diagonal-line mr-2"></i>调整大小</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">新宽度</label>
<input type="number" id="newWidth" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">新高度</label>
<input type="number" id="newHeight" class="w-full border rounded-lg px-3 py-2">
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="keepRatio" checked class="rounded">
<span class="text-sm">保持宽高比例</span>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('resizeModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="applyResize()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">应用</button>
</div>
</div>
</div>
<!-- 裁剪模态框 -->
<div id="cropModal" class="modal">
<div class="modal-content">
<h2 class="text-lg font-bold mb-4"><i class="ri-crop-line mr-2"></i>裁剪</h2>
<p class="text-sm text-gray-500 mb-4">在画布上拖动选择裁剪区域,或输入具体坐标</p>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">起始 X</label>
<input type="number" id="cropX" value="0" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">起始 Y</label>
<input type="number" id="cropY" value="0" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">宽度</label>
<input type="number" id="cropWidth" class="w-full border rounded-lg px-3 py-2">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">高度</label>
<input type="number" id="cropHeight" class="w-full border rounded-lg px-3 py-2">
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button onclick="closeModal('cropModal')" class="px-4 py-2 bg-gray-200 rounded-lg">取消</button>
<button onclick="startCropSelection()" class="px-4 py-2 bg-blue-500 text-white rounded-lg">开始选择</button>
<button onclick="applyCrop()" class="px-4 py-2 bg-green-500 text-white rounded-lg">应用裁剪</button>
</div>
</div>
</div>
<!-- 分割结果展示 -->
<div id="splitResults" class="hidden max-w-6xl mx-auto px-4 pb-6">
<div class="bg-white rounded-lg shadow-sm p-4">
<h3 class="text-sm font-medium text-gray-600 mb-4">分割结果</h3>
<div id="splitImages" class="grid grid-cols-4 gap-4"></div>
</div>
</div>
<script>
// 全局状态
const state = {
images: [],
historyStack: [], // 撤销栈
redoStack: [], // 恢复栈
currentTool: null,
isSelecting: false,
selection: null,
textStyles: { bold: false, italic: false },
mergeImageOrder: [], // 合并时的图片顺序
mergeImageSizes: {} // 合并时各图片尺寸
};
// Canvas 相关
const canvas = document.getElementById('mainCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('canvasContainer');
// 初始化
function init() {
setupDragDrop();
setupCanvas();
}
// 设置画布
function setupCanvas() {
canvas.width = 800;
canvas.height = 600;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
// 保存状态到历史栈
function saveState(actionName) {
// 保存当前画布状态
const imageData = canvas.toDataURL('image/png');
state.historyStack.push({
imageData: imageData,
images: JSON.parse(JSON.stringify(state.images.map(img => ({
name: img.name,
x: img.x,
y: img.y,
width: img.width,
height: img.height,
dataUrl: img.dataUrl
})))),
canvasWidth: canvas.width,
canvasHeight: canvas.height,
action: actionName,
time: new Date().toLocaleTimeString()
});
// 清空恢复栈
state.redoStack = [];
updateUndoRedoButtons();
addHistory(actionName);
}
// 撤销
function undo() {
if (state.historyStack.length === 0) return;
// 当前状态存入恢复栈
const currentData = canvas.toDataURL('image/png');
state.redoStack.push({
imageData: currentData,
images: JSON.parse(JSON.stringify(state.images.map(img => ({
name: img.name,
x: img.x,
y: img.y,
width: img.width,
height: img.height,
dataUrl: img.dataUrl
})))),
canvasWidth: canvas.width,
canvasHeight: canvas.height
});
// 恢复上一个状态
const prevState = state.historyStack.pop();
restoreState(prevState);
updateUndoRedoButtons();
addHistory('撤销: ' + prevState.action);
}
// 恢复
function redo() {
if (state.redoStack.length === 0) return;
// 当前状态存入历史栈
const currentData = canvas.toDataURL('image/png');
state.historyStack.push({
imageData: currentData,
images: JSON.parse(JSON.stringify(state.images.map(img => ({
name: img.name,
x: img.x,
y: img.y,
width: img.width,
height: img.height,
dataUrl: img.dataUrl
})))),
canvasWidth: canvas.width,
canvasHeight: canvas.height,
action: '恢复操作'
});
// 恢复下一个状态
const nextState = state.redoStack.pop();
restoreState(nextState);
updateUndoRedoButtons();
addHistory('恢复操作');
}
// 恢复状态
function restoreState(savedState) {
canvas.width = savedState.canvasWidth;
canvas.height = savedState.canvasHeight;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
// 恢复图片列表
state.images = savedState.images.map(item => {
const i = new Image();
i.src = item.dataUrl;
return {
img: i,
name: item.name,
x: item.x,
y: item.y,
width: item.width,
height: item.height,
dataUrl: item.dataUrl
};
});
updateImageList();
if (state.images.length > 0) {
hideEmptyHint();
} else {
showEmptyHint();
}
};
img.src = savedState.imageData;
}
// 更新撤销/恢复按钮状态
function updateUndoRedoButtons() {
document.getElementById('undoBtn').disabled = state.historyStack.length === 0;
document.getElementById('redoBtn').disabled = state.redoStack.length === 0;
document.getElementById('historyInfo').textContent =
`(当前: ${state.historyStack.length}/${state.historyStack.length + state.redoStack.length})`;
}
// 设置拖放
function setupDragDrop() {
container.addEventListener('dragover', (e) => {
e.preventDefault();
document.getElementById('dropOverlay').classList.remove('hidden');
});
container.addEventListener('dragleave', () => {
document.getElementById('dropOverlay').classList.add('hidden');
});
container.addEventListener('drop', (e) => {
e.preventDefault();
document.getElementById('dropOverlay').classList.add('hidden');
handleFiles(e.dataTransfer.files);
});
}
// 上传图片
function uploadImage() {
document.getElementById('fileInput').click();
}
// 处理文件选择
function handleFileSelect(event) {
handleFiles(event.target.files);
}
// 处理文件
function handleFiles(files) {
for (let file of files) {
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
loadImage(e.target.result, file.name);
};
reader.readAsDataURL(file);
}
}
}
// 加载图片
function loadImage(dataUrl, name) {
const img = new Image();
img.onload = () => {
state.images.push({
img: img,
name: name,
x: 0,
y: 0,
width: img.width,
height: img.height,
dataUrl: dataUrl
});
// 调整画布大小以适应图片
if (state.images.length === 1) {
canvas.width = img.width;
canvas.height = img.height;
}
drawCanvas();
updateImageList();
hideEmptyHint();
saveState('加载图片: ' + name);
};
img.src = dataUrl;
}
// 绘制画布
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
for (let item of state.images) {
ctx.drawImage(item.img, item.x, item.y, item.width, item.height);
}
// 绘制选择框
if (state.selection) {
ctx.strokeStyle = '#3b82f6';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
ctx.strokeRect(state.selection.x, state.selection.y, state.selection.width, state.selection.height);
ctx.setLineDash([]);
}
}
// 更新图片列表
function updateImageList() {
const list = document.getElementById('imageList');
const items = document.getElementById('imageItems');
document.getElementById('imageCount').textContent = `(${state.images.length}张)`;
if (state.images.length === 0) {
list.classList.add('hidden');
return;
}
list.classList.remove('hidden');
items.innerHTML = '';
state.images.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'flex items-center gap-2 bg-gray-100 rounded-lg px-3 py-2 cursor-pointer hover:bg-gray-200';
div.innerHTML = `
<span class="text-sm truncate max-w-150">${item.name}</span>
<span class="text-xs text-gray-500">${item.width}×${item.height}</span>
<button onclick="removeImage(${index})" class="text-red-500 hover:text-red-600">
<i class="ri-close-line"></i>
</button>
`;
items.appendChild(div);
});
}
// 移除图片
function removeImage(index) {
state.images.splice(index, 1);
drawCanvas();
updateImageList();
saveState('移除图片');
if (state.images.length === 0) {
showEmptyHint();
}
}
// 隐藏空提示
function hideEmptyHint() {
document.getElementById('emptyHint').classList.add('hidden');
container.classList.add('has-image');
}
// 显示空提示
function showEmptyHint() {
document.getElementById('emptyHint').classList.remove('hidden');
container.classList.remove('has-image');
}
// 添加历史记录
function addHistory(action) {
const list = document.getElementById('historyList');
const span = document.createElement('span');
span.className = 'text-xs bg-blue-100 text-blue-700 rounded px-2 py-1';
span.textContent = action;
if (list.querySelector('.text-gray-400')) {
list.innerHTML = '';
}
list.appendChild(span);
}
// 显示模态框
function showModal(id) {
document.getElementById(id).classList.add('show');
}
// 关闭模态框
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
// === 合并图片功能 (增强版) ===
function showMergeModal() {
if (state.images.length < 2) {
alert('请先上传至少2张图片');
return;
}
// 初始化合并顺序(复制当前图片顺序)
state.mergeImageOrder = state.images.map((img, idx) => ({
index: idx,
name: img.name,
dataUrl: img.dataUrl,
width: img.width,
height: img.height
}));
// 初始化尺寸设置
state.mergeImageSizes = {};
state.mergeImageOrder.forEach(item => {
state.mergeImageSizes[item.index] = {
width: item.width,
height: item.height
};
});
renderMergeImageOrder();
renderMergeSizeList();
document.getElementById('mergeSizeSettings').classList.remove('hidden');
showModal('mergeModal');
}
// 渲染合并图片顺序列表
function renderMergeImageOrder() {
const container = document.getElementById('mergeImageOrder');
container.innerHTML = '';
state.mergeImageOrder.forEach((item, idx) => {
const div = document.createElement('div');
div.className = 'image-order-item';
div.draggable = true;
div.dataset.index = idx;
div.innerHTML = `
<span class="drag-handle"><i class="ri-draggable"></i></span>
<img src="${item.dataUrl}" class="w-12 h-12 object-cover rounded">
<span class="text-sm flex-1 truncate">${item.name}</span>
<span class="text-xs text-gray-500">${item.width}×${item.height}</span>
<span class="text-xs bg-blue-500 text-white rounded px-2 py-0.5">#${idx + 1}</span>
`;
// 拖拽事件
div.addEventListener('dragstart', handleDragStart);
div.addEventListener('dragover', handleDragOver);
div.addEventListener('drop', handleDrop);
div.addEventListener('dragend', handleDragEnd);
container.appendChild(div);
});
}
// 渲染尺寸设置列表
function renderMergeSizeList() {
const container = document.getElementById('mergeSizeList');
container.innerHTML = '';
state.mergeImageOrder.forEach((item, idx) => {
const originalIndex = item.index;
const size = state.mergeImageSizes[originalIndex] || { width: item.width, height: item.height };
const div = document.createElement('div');
div.className = 'flex items-center gap-2 text-sm';
div.innerHTML = `
<span class="truncate w-100">${item.name}</span>
<input type="number" value="${size.width}"
onchange="updateMergeSize(${originalIndex}, 'width', this.value)"
class="border rounded px-2 py-1 w-16" placeholder="宽">
<input type="number" value="${size.height}"
onchange="updateMergeSize(${originalIndex}, 'height', this.value)"
class="border rounded px-2 py-1 w-16" placeholder="高">
`;
container.appendChild(div);
});
}
// 更新合并尺寸
function updateMergeSize(index, dimension, value) {
state.mergeImageSizes[index][dimension] = parseInt(value) || 0;
}
// 拖拽处理
let draggedItem = null;
function handleDragStart(e) {
draggedItem = this;
this.style.opacity = '0.5';
}
function handleDragOver(e) {
e.preventDefault();
}
function handleDrop(e) {
e.preventDefault();
if (draggedItem !== this) {
const fromIdx = parseInt(draggedItem.dataset.index);
const toIdx = parseInt(this.dataset.index);
// 交换顺序
const temp = state.mergeImageOrder[fromIdx];
state.mergeImageOrder[fromIdx] = state.mergeImageOrder[toIdx];
state.mergeImageOrder[toIdx] = temp;
renderMergeImageOrder();
renderMergeSizeList();
}
}
function handleDragEnd(e) {
this.style.opacity = '1';
draggedItem = null;
}
// 监听统一尺寸选项
document.getElementById('mergeUniformSize').addEventListener('change', (e) => {
document.getElementById('uniformSizeSettings').classList.toggle('hidden', !e.target.checked);
document.getElementById('mergeSizeList').classList.toggle('hidden', e.target.checked);
});
// 监听合并方式变化
document.getElementById('mergeDirection').addEventListener('change', (e) => {
document.getElementById('gridSettings').classList.toggle('hidden', e.target.value !== 'grid');
});
function applyMerge() {
const direction = document.getElementById('mergeDirection').value;
const gap = parseInt(document.getElementById('mergeGap').value) || 0;
const bgColor = document.getElementById('mergeBgColor').value;
const uniformSize = document.getElementById('mergeUniformSize').checked;
const uniformWidth = parseInt(document.getElementById('mergeUniformWidth').value) || 0;
const uniformHeight = parseInt(document.getElementById('mergeUniformHeight').value) || 0;
let cols = 1, rows = state.mergeImageOrder.length;
if (direction === 'horizontal') {
cols = state.mergeImageOrder.length;
rows = 1;
} else if (direction === 'grid') {
cols = parseInt(document.getElementById('gridCols').value) || 2;
rows = parseInt(document.getElementById('gridRows').value) || 2;
}
// 获取每张图片的实际尺寸
const getActualSize = (item) => {
if (uniformSize && uniformWidth && uniformHeight) {
return { width: uniformWidth, height: uniformHeight };
}
if (uniformSize) {
// 使用最大尺寸
const maxWidth = Math.max(...state.mergeImageOrder.map(i => i.width));
const maxHeight = Math.max(...state.mergeImageOrder.map(i => i.height));
return { width: uniformWidth || maxWidth, height: uniformHeight || maxHeight };
}
const size = state.mergeImageSizes[item.index] || { width: item.width, height: item.height };
return {
width: size.width || item.width,
height: size.height || item.height
};
};
// 计算最大单元格尺寸
let maxWidth = 0, maxHeight = 0;
state.mergeImageOrder.forEach(item => {
const size = getActualSize(item);
maxWidth = Math.max(maxWidth, size.width);
maxHeight = Math.max(maxHeight, size.height);
});
const totalWidth = cols * maxWidth + (cols - 1) * gap;
const totalHeight = rows * maxHeight + (rows - 1) * gap;
// 创建新画布
canvas.width = totalWidth;
canvas.height = totalHeight;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, totalWidth, totalHeight);
// 按顺序绘制图片
state.mergeImageOrder.forEach((orderItem, index) => {
if (index >= cols * rows) return; // 超出网格容量
const col = index % cols;
const row = Math.floor(index / cols);
const x = col * (maxWidth + gap);
const y = row * (maxHeight + gap);
const size = getActualSize(orderItem);
// 居中绘制
const offsetX = (maxWidth - size.width) / 2;
const offsetY = (maxHeight - size.height) / 2;
const originalImg = state.images.find(img => img.name === orderItem.name);
if (originalImg) {
ctx.drawImage(originalImg.img, x + offsetX, y + offsetY, size.width, size.height);
}
});
// 清空图片列表(合并后为单张)
const mergedData = canvas.toDataURL('image/png');
state.images = [];
loadImage(mergedData, 'merged_' + new Date().getTime());
closeModal('mergeModal');
saveState('合并图片 (' + direction + ')');
}
// 分割图片
function showSplitModal() {
if (state.images.length === 0) {
alert('请先上传图片');
return;
}
showModal('splitModal');
const img = state.images[0];
document.getElementById('cropWidth').value = img.width;
document.getElementById('cropHeight').value = img.height;
}
function applySplit() {
const cols = parseInt(document.getElementById('splitCols').value) || 2;
const rows = parseInt(document.getElementById('splitRows').value) || 2;
const img = state.images[0];
const pieceWidth = img.width / cols;
const pieceHeight = img.height / rows;
const resultsDiv = document.getElementById('splitImages');
resultsDiv.innerHTML = '';
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const pieceCanvas = document.createElement('canvas');
pieceCanvas.width = pieceWidth;
pieceCanvas.height = pieceHeight;
const pieceCtx = pieceCanvas.getContext('2d');
pieceCtx.drawImage(
img.img,
c * pieceWidth, r * pieceHeight, pieceWidth, pieceHeight,
0, 0, pieceWidth, pieceHeight
);
const dataUrl = pieceCanvas.toDataURL('image/png');
const div = document.createElement('div');
div.className = 'relative';
div.innerHTML = `
<img src="${dataUrl}" class="w-full rounded border">
<button onclick="downloadSplitPiece('${dataUrl}', 'split_${r}_${c}.png')"
class="absolute bottom-2 right-2 bg-blue-500 text-white px-2 py-1 rounded text-xs">
<i class="ri-download-line"></i>
</button>
`;
resultsDiv.appendChild(div);
}
}
document.getElementById('splitResults').classList.remove('hidden');
closeModal('splitModal');
saveState(`分割图片 (${cols}x${rows})`);
}
function downloadSplitPiece(dataUrl, filename) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
link.click();
}
// 挖孔填充
function showHoleModal() {
if (state.images.length === 0) {
alert('请先上传图片');
return;
}
showModal('holeModal');
}
document.getElementById('holeFill').addEventListener('change', (e) => {
document.getElementById('holeColorPicker').classList.toggle('hidden', e.target.value !== 'color');
document.getElementById('holeBlurSetting').classList.toggle('hidden', e.target.value !== 'blur');
});
function startHoleSelection() {
closeModal('holeModal');
state.isSelecting = true;
state.currentTool = 'hole';
setupSelectionListeners();
alert('在画布上拖动选择要挖孔的区域');
}
function setupSelectionListeners() {
let startX, startY;
const mouseDown = (e) => {
if (!state.isSelecting) return;
const rect = canvas.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
state.selection = { x: startX, y: startY, width: 0, height: 0 };
};
const mouseMove = (e) => {
if (!state.isSelecting || !state.selection) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
state.selection.width = currentX - startX;
state.selection.height = currentY - startY;
drawCanvas();
};
const mouseUp = () => {
if (!state.isSelecting) return;
state.isSelecting = false;
if (state.currentTool === 'hole') {
applyHole();
} else if (state.currentTool === 'crop') {
document.getElementById('cropX').value = Math.round(state.selection.x);
document.getElementById('cropY').value = Math.round(state.selection.y);
document.getElementById('cropWidth').value = Math.round(Math.abs(state.selection.width));
document.getElementById('cropHeight').value = Math.round(Math.abs(state.selection.height));
showModal('cropModal');
}
state.selection = null;
state.currentTool = null;
drawCanvas();
canvas.removeEventListener('mousedown', mouseDown);
canvas.removeEventListener('mousemove', mouseMove);
canvas.removeEventListener('mouseup', mouseUp);
};
canvas.addEventListener('mousedown', mouseDown);
canvas.addEventListener('mousemove', mouseMove);
canvas.addEventListener('mouseup', mouseUp);
}
function applyHole() {
const shape = document.getElementById('holeShape').value;
const fill = document.getElementById('holeFill').value;
const color = document.getElementById('holeColor').value;
const blurRadius = parseInt(document.getElementById('holeBlurRadius').value) || 10;
const sel = state.selection;
const x = sel.width < 0 ? sel.x + sel.width : sel.x;
const y = sel.height < 0 ? sel.y + sel.height : sel.y;
const w = Math.abs(sel.width);
const h = Math.abs(sel.height);
if (fill === 'color') {
ctx.fillStyle = color;
if (shape === 'rectangle') {
ctx.fillRect(x, y, w, h);
} else if (shape === 'circle') {
ctx.beginPath();
ctx.arc(x + w/2, y + h/2, Math.min(w, h)/2, 0, Math.PI * 2);
ctx.fill();
} else if (shape === 'ellipse') {
ctx.beginPath();
ctx.ellipse(x + w/2, y + h/2, w/2, h/2, 0, 0, Math.PI * 2);
ctx.fill();
}
} else if (fill === 'transparent') {
ctx.clearRect(x, y, w, h);
} else if (fill === 'blur') {
const tempCanvas = document.createElement('canvas');
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(canvas, x, y, w, h, 0, 0, w, h);
ctx.filter = `blur(${blurRadius}px)`;
ctx.drawImage(tempCanvas, x, y);
ctx.filter = 'none';
} else if (fill === 'content-aware') {
const edgeColor = getEdgeColor(x, y, w, h);
ctx.fillStyle = edgeColor;
if (shape === 'rectangle') {
ctx.fillRect(x, y, w, h);
} else {
ctx.beginPath();
ctx.arc(x + w/2, y + h/2, Math.min(w, h)/2, 0, Math.PI * 2);
ctx.fill();
}
}
saveState('挖孔填充 (' + shape + ')');
}
function getEdgeColor(x, y, w, h) {
const pixel = ctx.getImageData(Math.max(0, x - 1), Math.max(0, y - 1), 1, 1).data;
return `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
}
// 圆形切图
function showCircleModal() {
if (state.images.length === 0) {
alert('请先上传图片');
return;
}
showModal('circleModal');
}
document.getElementById('circleEdge').addEventListener('change', (e) => {
document.getElementById('circleBorderSetting').classList.toggle('hidden', e.target.value !== 'border');
});
function applyCircleCut() {
const diameter = document.getElementById('circleDiameter').value;
const edge = document.getElementById('circleEdge').value;
const borderColor = document.getElementById('circleBorderColor').value;
const borderWidth = parseInt(document.getElementById('circleBorderWidth').value) || 3;
const img = state.images[0];
const size = diameter ? parseInt(diameter) : Math.min(img.width, img.height);
const circleCanvas = document.createElement('canvas');
circleCanvas.width = size;
circleCanvas.height = size;
const circleCtx = circleCanvas.getContext('2d');
circleCtx.beginPath();
circleCtx.arc(size/2, size/2, size/2 - (edge === 'border' ? borderWidth : 0), 0, Math.PI * 2);
circleCtx.closePath();
if (edge === 'smooth') {
circleCtx.globalCompositeOperation = 'source-over';
const gradient = circleCtx.createRadialGradient(size/2, size/2, size/2 - 10, size/2, size/2, size/2);
gradient.addColorStop(0, 'rgba(255,255,255,1)');
gradient.addColorStop(1, 'rgba(255,255,255,0)');
circleCtx.fillStyle = gradient;
circleCtx.fill();
}
circleCtx.clip();
const offsetX = (img.width - size) / 2;
const offsetY = (img.height - size) / 2;
circleCtx.drawImage(img.img, -offsetX, -offsetY);
if (edge === 'border') {
circleCtx.strokeStyle = borderColor;
circleCtx.lineWidth = borderWidth;
circleCtx.beginPath();
circleCtx.arc(size/2, size/2, size/2 - borderWidth/2, 0, Math.PI * 2);
circleCtx.stroke();
}
canvas.width = size;
canvas.height = size;
ctx.drawImage(circleCanvas, 0, 0);
const circleData = canvas.toDataURL('image/png');
state.images = [];
loadImage(circleData, 'circle_' + new Date().getTime());
closeModal('circleModal');
saveState('圆形切图 (直径: ' + size + ')');
}
// 文字图片
function showTextModal() {
showModal('textModal');
}
function toggleStyle(style) {
state.textStyles[style] = !state.textStyles[style];
const btn = document.getElementById('text' + style.charAt(0).toUpperCase() + style.slice(1));
btn.classList.toggle('bg-blue-500', state.textStyles[style]);
btn.classList.toggle('text-white', state.textStyles[style]);
}
function createTextImage() {
const content = document.getElementById('textContent').value;
if (!content.trim()) {
alert('请输入文字内容');
return;
}
const font = document.getElementById('textFont').value;
const size = parseInt(document.getElementById('textSize').value) || 48;
const color = document.getElementById('textColor').value;
const transparentBg = document.getElementById('textTransparentBg').checked;
const bgColor = document.getElementById('textBgColor').value;
const align = document.getElementById('textAlign').value;
const fontStyle = (state.textStyles.bold ? 'bold ' : '') +
(state.textStyles.italic ? 'italic ' : '') +
size + 'px ' + font;
const tempCanvas = document.createElement('canvas');
const tempCtx = tempCanvas.getContext('2d');
tempCtx.font = fontStyle;
const lines = content.split('\n');
let maxWidth = 0;
lines.forEach(line => {
maxWidth = Math.max(maxWidth, tempCtx.measureText(line).width);
});
const padding = 20;
const lineHeight = size * 1.2;
const totalHeight = lines.length * lineHeight + padding * 2;
const totalWidth = maxWidth + padding * 2;
canvas.width = totalWidth;
canvas.height = totalHeight;
if (!transparentBg) {
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, totalWidth, totalHeight);
} else {
ctx.clearRect(0, 0, totalWidth, totalHeight);
}
ctx.font = fontStyle;
ctx.fillStyle = color;
ctx.textBaseline = 'top';
const textAligns = { left: padding, center: totalWidth/2, right: totalWidth - padding };
ctx.textAlign = align;
lines.forEach((line, index) => {
const x = textAligns[align];
const y = padding + index * lineHeight;
ctx.fillText(line, x, y);
});
const textData = canvas.toDataURL('image/png');
state.images = [];
loadImage(textData, 'text_' + new Date().getTime());
closeModal('textModal');
saveState('创建文字图片');
}
// 调整大小
function showResizeModal() {
if (state.images.length === 0) {
alert('请先上传图片');
return;
}
const img = state.images[0];
document.getElementById('newWidth').value = img.width;
document.getElementById('newHeight').value = img.height;
showModal('resizeModal');
}
function applyResize() {
const newWidth = parseInt(document.getElementById('newWidth').value);
const newHeight = parseInt(document.getElementById('newHeight').value);
if (!newWidth || !newHeight) {
alert('请输入有效的尺寸');
return;
}
const img = state.images[0];
canvas.width = newWidth;
canvas.height = newHeight;
ctx.drawImage(img.img, 0, 0, newWidth, newHeight);
const resizedData = canvas.toDataURL('image/png');
state.images = [];
loadImage(resizedData, 'resized_' + new Date().getTime());
closeModal('resizeModal');
saveState(`调整大小 (${newWidth}x${newHeight})`);
}
// 裁剪
function showCropModal() {
if (state.images.length === 0) {
alert('请先上传图片');
return;
}
showModal('cropModal');
}
function startCropSelection() {
closeModal('cropModal');
state.isSelecting = true;
state.currentTool = 'crop';
setupSelectionListeners();
alert('在画布上拖动选择裁剪区域');
}
function applyCrop() {
const x = parseInt(document.getElementById('cropX').value) || 0;
const y = parseInt(document.getElementById('cropY').value) || 0;
const w = parseInt(document.getElementById('cropWidth').value);
const h = parseInt(document.getElementById('cropHeight').value);
if (!w || !h) {
alert('请输入有效的裁剪尺寸');
return;
}
const img = state.images[0];
canvas.width = w;
canvas.height = h;
ctx.drawImage(img.img, x, y, w, h, 0, 0, w, h);
const croppedData = canvas.toDataURL('image/png');
state.images = [];
loadImage(croppedData, 'cropped_' + new Date().getTime());
closeModal('cropModal');
saveState(`裁剪 (${w}x${h})`);
}
// 保存图片
function saveImage() {
const dataUrl = canvas.toDataURL('image/png');
const filename = 'edited_' + new Date().getTime() + '.png';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: dataUrl, filename: filename })
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('保存成功: ' + data.filename);
saveState('保存图片: ' + data.filename);
} else {
alert('保存失败');
}
});
}
// 下载图片
function downloadImage() {
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.href = dataUrl;
link.download = 'edited_image_' + new Date().getTime() + '.png';
link.click();
saveState('下载图片');
}
// 清空画布
function clearCanvas() {
state.images = [];
state.historyStack = [];
state.redoStack = [];
setupCanvas();
updateImageList();
updateUndoRedoButtons();
document.getElementById('historyList').innerHTML = '<span class="text-gray-400 text-sm">暂无操作</span>';
showEmptyHint();
document.getElementById('splitResults').classList.add('hidden');
}
// 初始化
init();
</script>
</body>
</html>