1264 lines
61 KiB
HTML
1264 lines
61 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.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> |