Files
image-editor/templates/index.html

1264 lines
61 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.2.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 !important;
color: white !important;
}
.right-panel {
transition: all 0.3s;
overflow-y: auto;
}
.right-panel.hidden-panel {
width: 0;
padding: 0;
overflow: hidden;
}
.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-full 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="setTool('upload')" id="tool-upload" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-upload-cloud-line mr-1"></i>上传图片
</button>
<button onclick="setTool('merge')" id="tool-merge" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-stack-line mr-1"></i>合并图片
</button>
<button onclick="setTool('split')" id="tool-split" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-layout-grid-line mr-1"></i>分割图片
</button>
<button onclick="setTool('hole')" id="tool-hole" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-contrast-drop-line mr-1"></i>挖孔填充
</button>
<button onclick="setTool('circle')" id="tool-circle" 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="setTool('text')" id="tool-text" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-font-size mr-1"></i>文字图片
</button>
<button onclick="setTool('resize')" id="tool-resize" class="tool-btn px-3 py-2 bg-gray-200 rounded-lg">
<i class="ri-expand-diagonal-line mr-1"></i>调整大小
</button>
<button onclick="setTool('crop')" id="tool-crop" 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="flex h-[calc(100vh-120px)]">
<!-- 左侧画布区域 -->
<div class="flex-1 p-4 overflow-auto">
<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="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 id="splitResults" class="mt-4 hidden">
<h3 class="text-sm font-medium text-gray-600 mb-2">分割结果</h3>
<div id="splitImages" class="grid grid-cols-4 gap-4"></div>
</div>
</div>
</div>
<!-- 右侧配置面板 -->
<div id="rightPanel" class="right-panel hidden-panel w-80 bg-white shadow-lg border-l">
<div class="p-4">
<div class="flex items-center justify-between mb-4">
<h2 id="panelTitle" class="font-bold text-gray-800">配置</h2>
<button onclick="closePanel()" class="text-gray-500 hover:text-gray-700">
<i class="ri-close-line text-xl"></i>
</button>
</div>
<!-- 配置内容区域 -->
<div id="panelContent"></div>
<!-- 操作按钮 -->
<div id="panelActions" class="mt-4 pt-4 border-t"></div>
</div>
</div>
</div>
<!-- 操作历史(底部) -->
<div class="fixed bottom-0 left-0 right-0 bg-white shadow-sm p-2">
<div class="max-w-full mx-auto px-4">
<h3 class="text-xs font-medium text-gray-600 mb-1">操作历史 <span id="historyInfo">(当前: 0/0)</span></h3>
<div id="historyList" class="flex gap-2 overflow-x-auto">
<span class="text-gray-400 text-xs">暂无操作</span>
</div>
</div>
</div>
<!-- 隐藏的文件输入 -->
<input type="file" id="fileInput" accept="image/*" multiple onchange="handleFileSelect(event)">
<script>
// 全局状态
const state = {
images: [],
historyStack: [],
redoStack: [],
currentTool: null,
isSelecting: false,
selection: null,
textStyles: { bold: false, italic: false },
mergeImageOrder: [],
mergeImageSizes: {}
};
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 setupDragDrop() {
container.addEventListener('dragover', (e) => {
e.preventDefault();
});
container.addEventListener('drop', (e) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
});
}
// 设置当前工具
function setTool(tool) {
// 移除所有工具的 active 状态
document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
// 设置当前工具 active
const btn = document.getElementById('tool-' + tool);
if (btn) btn.classList.add('active');
state.currentTool = tool;
// 显示右侧配置面板
showPanel(tool);
// 上传图片直接触发文件选择
if (tool === 'upload') {
document.getElementById('fileInput').click();
}
}
// 显示右侧面板
function showPanel(tool) {
const panel = document.getElementById('rightPanel');
panel.classList.remove('hidden-panel');
const configs = getPanelConfig(tool);
document.getElementById('panelTitle').textContent = configs.title;
document.getElementById('panelContent').innerHTML = configs.content;
document.getElementById('panelActions').innerHTML = configs.actions;
}
// 关闭面板
function closePanel() {
const panel = document.getElementById('rightPanel');
panel.classList.add('hidden-panel');
document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
state.currentTool = null;
}
// 获取各工具的配置面板内容
function getPanelConfig(tool) {
switch(tool) {
case 'upload':
return {
title: '上传图片',
content: `
<p class="text-sm text-gray-600 mb-4">点击按钮选择图片,或直接拖放图片到画布区域</p>
<button onclick="document.getElementById('fileInput').click()" class="w-full px-4 py-3 bg-blue-500 text-white rounded-lg">
<i class="ri-upload-cloud-line mr-2"></i>选择图片
</button>
`,
actions: ''
};
case 'merge':
if (state.images.length < 2) {
return {
title: '合并图片',
content: `<p class="text-sm text-red-500">请先上传至少2张图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
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 };
});
return {
title: '合并图片',
content: `
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">图片顺序(拖动调整)</label>
<div id="mergeImageOrderPanel" class="border rounded-lg p-2 min-h-100 max-h-200 overflow-y-auto"></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">
<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 class="border-t pt-3">
<div class="flex items-center gap-2 mb-2">
<input type="checkbox" id="mergeUniformSize" onchange="toggleUniformSize()" class="rounded">
<span class="text-sm">统一尺寸</span>
</div>
<div id="uniformSizeSettings" class="hidden 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 id="mergeSizeList"></div>
</div>
</div>
`,
actions: `
<button onclick="applyMerge()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">
<i class="ri-stack-line mr-1"></i>执行合并
</button>
`
};
case 'split':
if (state.images.length === 0) {
return {
title: '分割图片',
content: `<p class="text-sm text-red-500">请先上传图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
return {
title: '分割图片',
content: `
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">分割数量</label>
<div class="flex gap-2">
<div class="w-1/2">
<label class="block text-xs text-gray-500">列数</label>
<input type="number" id="splitCols" value="2" min="1" max="20" class="w-full border rounded-lg px-3 py-2">
</div>
<div class="w-1/2">
<label class="block text-xs text-gray-500">行数</label>
<input type="number" id="splitRows" value="2" min="1" max="20" class="w-full border rounded-lg px-3 py-2">
</div>
</div>
</div>
<p class="text-xs text-gray-500">分割后的图片将显示在画布下方,可单独下载</p>
</div>
`,
actions: `<button onclick="applySplit()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">执行分割</button>`
};
case 'hole':
if (state.images.length === 0) {
return {
title: '挖孔填充',
content: `<p class="text-sm text-red-500">请先上传图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
return {
title: '挖孔填充',
content: `
<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" onchange="toggleHoleFillSettings()" 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-xs text-gray-500">点击"开始选择"后,在画布上拖动选择区域</p>
</div>
`,
actions: `<button onclick="startHoleSelection()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">开始选择</button>`
};
case 'circle':
if (state.images.length === 0) {
return {
title: '圆形切图',
content: `<p class="text-sm text-red-500">请先上传图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
return {
title: '圆形切图',
content: `
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">圆形直径</label>
<input type="number" id="circleDiameter" placeholder="留空则自动" 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>
<select id="circleEdge" onchange="toggleCircleBorder()" 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 space-y-2">
<div>
<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">
</div>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">边框宽度</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>
`,
actions: `<button onclick="applyCircleCut()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">执行切图</button>`
};
case 'text':
return {
title: '文字图片',
content: `
<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>
</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 mb-1">
<input type="checkbox" id="textTransparentBg" class="rounded">
<span class="text-xs">透明背景</span>
</div>
<input type="color" id="textBgColor" value="#ffffff" 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 gap-2">
<button id="textBold" onclick="toggleTextStyle('bold')" class="px-3 py-1 border rounded text-sm font-bold">B</button>
<button id="textItalic" onclick="toggleTextStyle('italic')" class="px-3 py-1 border rounded text-sm italic">I</button>
</div>
</div>
</div>
`,
actions: `<button onclick="createTextImage()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">生成图片</button>`
};
case 'resize':
if (state.images.length === 0) {
return {
title: '调整大小',
content: `<p class="text-sm text-red-500">请先上传图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
const img = state.images[0];
return {
title: '调整大小',
content: `
<div class="space-y-4">
<p class="text-xs text-gray-500">当前尺寸: ${img.width} × ${img.height}</p>
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">新宽度</label>
<input type="number" id="newWidth" value="${img.width}" 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" value="${img.height}" 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-xs">保持宽高比例</span>
</div>
</div>
`,
actions: `<button onclick="applyResize()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">应用</button>`
};
case 'crop':
if (state.images.length === 0) {
return {
title: '裁剪',
content: `<p class="text-sm text-red-500">请先上传图片</p>`,
actions: `<button onclick="closePanel()" class="px-4 py-2 bg-gray-200 rounded-lg">关闭</button>`
};
}
return {
title: '裁剪',
content: `
<div class="space-y-4">
<p class="text-xs text-gray-500">输入裁剪坐标,或点击"开始选择"在画布上拖动</p>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500">起始 X</label>
<input type="number" id="cropX" value="0" class="w-full border rounded-lg px-2 py-1">
</div>
<div>
<label class="block text-xs text-gray-500">起始 Y</label>
<input type="number" id="cropY" value="0" class="w-full border rounded-lg px-2 py-1">
</div>
<div>
<label class="block text-xs text-gray-500">宽度</label>
<input type="number" id="cropWidth" class="w-full border rounded-lg px-2 py-1">
</div>
<div>
<label class="block text-xs text-gray-500">高度</label>
<input type="number" id="cropHeight" class="w-full border rounded-lg px-2 py-1">
</div>
</div>
</div>
`,
actions: `
<button onclick="startCropSelection()" class="px-4 py-2 bg-gray-200 rounded-lg w-full mb-2">开始选择</button>
<button onclick="applyCrop()" class="px-4 py-2 bg-blue-500 text-white rounded-lg w-full">应用裁剪</button>
`
};
default:
return { title: '配置', content: '', actions: '' };
}
}
// 初始化合并面板内容
function initMergePanelContent() {
const orderContainer = document.getElementById('mergeImageOrderPanel');
if (!orderContainer) return;
orderContainer.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-10 h-10 object-cover rounded">
<span class="text-xs flex-1 truncate">${item.name}</span>
<span class="text-xs bg-blue-500 text-white rounded px-1">#${idx + 1}</span>
`;
div.addEventListener('dragstart', handleDragStart);
div.addEventListener('dragover', handleDragOver);
div.addEventListener('drop', handleDrop);
div.addEventListener('dragend', handleDragEnd);
orderContainer.appendChild(div);
});
renderMergeSizeList();
}
// 监听合并方式变化
document.addEventListener('change', (e) => {
if (e.target.id === 'mergeDirection') {
const gridSettings = document.getElementById('gridSettings');
if (gridSettings) {
gridSettings.classList.toggle('hidden', e.target.value !== 'grid');
}
}
});
// 切换统一尺寸
function toggleUniformSize() {
const checkbox = document.getElementById('mergeUniformSize');
const uniformSettings = document.getElementById('uniformSizeSettings');
const sizeList = document.getElementById('mergeSizeList');
if (checkbox && checkbox.checked) {
if (uniformSettings) uniformSettings.classList.remove('hidden');
if (sizeList) sizeList.classList.add('hidden');
} else {
if (uniformSettings) uniformSettings.classList.add('hidden');
if (sizeList) sizeList.classList.remove('hidden');
renderMergeSizeList();
}
}
// 渲染单独尺寸设置列表
function renderMergeSizeList() {
const container = document.getElementById('mergeSizeList');
if (!container) return;
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-1 text-xs py-1';
div.innerHTML = `
<span class="truncate w-20">${item.name}</span>
<input type="number" value="${size.width}"
onchange="state.mergeImageSizes[${originalIndex}].width = parseInt(this.value) || ${item.width}"
class="border rounded px-1 py-0.5 w-14" placeholder="宽">
<input type="number" value="${size.height}"
onchange="state.mergeImageSizes[${originalIndex}].height = parseInt(this.value) || ${item.height}"
class="border rounded px-1 py-0.5 w-14" placeholder="高">
`;
container.appendChild(div);
});
}
// 拖拽处理
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;
initMergePanelContent();
}
}
function handleDragEnd(e) {
this.style.opacity = '1';
draggedItem = null;
}
// 挖孔填充设置切换
function toggleHoleFillSettings() {
const fill = document.getElementById('holeFill').value;
document.getElementById('holeColorPicker').classList.toggle('hidden', fill !== 'color');
document.getElementById('holeBlurSetting').classList.toggle('hidden', fill !== 'blur');
}
// 圆形边框设置切换
function toggleCircleBorder() {
const edge = document.getElementById('circleEdge').value;
document.getElementById('circleBorderSetting').classList.toggle('hidden', edge !== 'border');
}
// 文字样式切换
function toggleTextStyle(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');
btn.classList.toggle('text-white');
}
// 保存状态
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
});
state.redoStack = [];
updateUndoRedoButtons();
addHistory(actionName);
}
function undo() {
if (state.historyStack.length === 0) return;
state.redoStack.push({
imageData: canvas.toDataURL('image/png'),
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
});
restoreState(state.historyStack.pop());
updateUndoRedoButtons();
}
function redo() {
if (state.redoStack.length === 0) return;
state.historyStack.push({
imageData: canvas.toDataURL('image/png'),
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: '恢复'
});
restoreState(state.redoStack.pop());
updateUndoRedoButtons();
}
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 { ...item, img: i };
});
updateImageList();
state.images.length > 0 ? hideEmptyHint() : 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 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, name, x: 0, y: 0, width: img.width, height: img.height, 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';
div.innerHTML = `
<span class="text-sm truncate">${item.name}</span>
<span class="text-xs text-gray-500">${item.width}×${item.height}</span>
<button onclick="removeImage(${index})" class="text-red-500"><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');
if (list.querySelector('.text-gray-400')) list.innerHTML = '';
const span = document.createElement('span');
span.className = 'text-xs bg-blue-100 text-blue-700 rounded px-2 py-1 whitespace-nowrap';
span.textContent = action;
list.appendChild(span);
}
// === 执行各操作 ===
function applyMerge() {
if (!document.getElementById('mergeDirection')) return;
const direction = document.getElementById('mergeDirection').value;
const gap = parseInt(document.getElementById('mergeGap')?.value) || 0;
const bgColor = document.getElementById('mergeBgColor')?.value || '#ffffff';
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) {
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);
});
canvas.width = cols * maxWidth + (cols - 1) * gap;
canvas.height = rows * maxHeight + (rows - 1) * gap;
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
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');
closePanel();
}
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="downloadDataUrl('${dataUrl}', 'split_${r}_${c}.png')"
class="absolute bottom-1 right-1 bg-blue-500 text-white px-1 py-0.5 rounded text-xs">
<i class="ri-download-line"></i>
</button>
`;
resultsDiv.appendChild(div);
}
}
document.getElementById('splitResults').classList.remove('hidden');
closePanel();
saveState(`分割 (${cols}x${rows})`);
}
function startHoleSelection() {
state.isSelecting = true;
state.currentTool = 'hole';
setupSelectionListeners();
}
function startCropSelection() {
state.isSelecting = true;
state.currentTool = 'crop';
setupSelectionListeners();
}
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();
state.selection.width = e.clientX - rect.left - startX;
state.selection.height = e.clientY - rect.top - 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));
}
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() {
if (!document.getElementById('holeShape')) return;
const shape = document.getElementById('holeShape').value;
const fill = document.getElementById('holeFill').value;
const color = document.getElementById('holeColor')?.value || '#ffffff';
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 { 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 pixel = ctx.getImageData(Math.max(0, x - 1), Math.max(0, y - 1), 1, 1).data;
ctx.fillStyle = `rgb(${pixel[0]}, ${pixel[1]}, ${pixel[2]})`;
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('挖孔填充');
}
function applyCircleCut() {
if (!document.getElementById('circleEdge')) return;
const diameter = document.getElementById('circleDiameter')?.value;
const edge = document.getElementById('circleEdge').value;
const borderColor = document.getElementById('circleBorderColor')?.value || '#3b82f6';
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') {
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');
closePanel();
}
function createTextImage() {
if (!document.getElementById('textContent')) return;
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 || '#000000';
const transparentBg = document.getElementById('textTransparentBg')?.checked;
const bgColor = document.getElementById('textBgColor')?.value || '#ffffff';
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';
ctx.textAlign = 'center';
lines.forEach((line, index) => {
ctx.fillText(line, totalWidth/2, padding + index * lineHeight);
});
const textData = canvas.toDataURL('image/png');
state.images = [];
loadImage(textData, 'text');
closePanel();
}
function applyResize() {
if (!document.getElementById('newWidth')) return;
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');
closePanel();
}
function applyCrop() {
if (!document.getElementById('cropWidth')) return;
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');
closePanel();
}
function saveImage() {
const dataUrl = canvas.toDataURL('image/png');
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image: dataUrl, filename: 'edited_' + Date.now() + '.png' })
}).then(res => res.json()).then(data => {
if (data.success) {
alert('保存成功: ' + data.filename);
saveState('保存');
}
});
}
function downloadImage() {
downloadDataUrl(canvas.toDataURL('image/png'), 'edited_' + Date.now() + '.png');
saveState('下载');
}
function downloadDataUrl(dataUrl, filename) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
link.click();
}
function clearCanvas() {
state.images = [];
state.historyStack = [];
state.redoStack = [];
setupCanvas();
updateImageList();
updateUndoRedoButtons();
document.getElementById('historyList').innerHTML = '<span class="text-gray-400 text-xs">暂无操作</span>';
showEmptyHint();
document.getElementById('splitResults').classList.add('hidden');
closePanel();
}
// 监听合并面板渲染
document.addEventListener('DOMContentLoaded', () => {
init();
// 监听 panelContent 变化
const observer = new MutationObserver(() => {
if (state.currentTool === 'merge' && state.images.length >= 2) {
initMergePanelContent();
}
});
observer.observe(document.getElementById('panelContent'), { childList: true });
});
</script>
</body>
</html>