1491 lines
62 KiB
HTML
1491 lines
62 KiB
HTML
<!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> |