重构下载功能,整合下载设置弹窗,优化下载逻辑以支持新选项,提升用户体验和代码可读性。

This commit is contained in:
zihanjian
2025-05-09 14:28:30 +08:00
parent 180de87942
commit 824f118589
5 changed files with 770 additions and 325 deletions

BIN
public/website_qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -16,6 +16,11 @@ import {
findClosestPaletteColor
} from '../utils/pixelation';
// 导入新的类型和组件
import { GridDownloadOptions } from '../types/downloadTypes';
import DownloadSettingsModal, { gridLineColorOptions } from '../components/DownloadSettingsModal';
import { downloadImage } from '../utils/imageDownloader';
import beadPaletteData from './beadPaletteData.json';
// 添加自定义动画样式
@@ -121,24 +126,6 @@ import GridTooltip from '../components/GridTooltip';
import CustomPaletteEditor from '../components/CustomPaletteEditor';
import { loadPaletteSelections, savePaletteSelections, presetToSelections, PaletteSelections } from '../utils/localStorageUtils';
// ++ 添加/定义网格下载选项类型 ++
type GridDownloadOptions = {
showGrid: boolean;
gridInterval: number;
showCoordinates: boolean;
gridLineColor: string; // 新增网格线颜色字段
};
// ++ 定义可选的网格线颜色 ++
const gridLineColorOptions = [
{ name: '深灰色', value: '#555555' },
{ name: '红色', value: '#FF0000' },
{ name: '蓝色', value: '#0000FF' },
{ name: '绿色', value: '#008000' },
{ name: '紫色', value: '#800080' },
{ name: '橙色', value: '#FFA500' },
];
export default function Home() {
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [granularity, setGranularity] = useState<number>(50);
@@ -178,7 +165,8 @@ export default function Home() {
showGrid: true,
gridInterval: 10,
showCoordinates: true,
gridLineColor: gridLineColorOptions[0].value, // 默认使用第一个颜色
gridLineColor: gridLineColorOptions[0].value,
includeStats: true // 默认包含统计信息
});
const originalCanvasRef = useRef<HTMLCanvasElement>(null);
@@ -684,136 +672,17 @@ export default function Home() {
}, [originalImageSrc, granularity, similarityThreshold, customPaletteSelections, pixelationMode, remapTrigger]);
// --- Download function (ensure filename includes palette) ---
const handleDownloadImage = (options?: GridDownloadOptions) => {
if (!mappedPixelData || !gridDimensions || gridDimensions.N === 0 || gridDimensions.M === 0 || activeBeadPalette.length === 0) {
console.error("下载失败: 映射数据或尺寸无效。"); alert("无法下载图纸,数据未生成或无效。"); return;
}
const { N, M } = gridDimensions;
const downloadCellSize = 30;
// 使用传入的options或者当前的downloadOptions
const currentOptions = options || downloadOptions;
// 根据当前下载选项确定是否需要绘制网格和坐标
const { showGrid, gridInterval, showCoordinates, gridLineColor } = currentOptions;
// 设置边距空间用于坐标轴标注(如果需要)
const axisLabelSize = showCoordinates ? 20 : 0;
// const gridLineColor = '#555555'; // 深色网格线 - 由 downloadOptions.gridLineColor 替代
// 调整画布大小
const downloadWidth = N * downloadCellSize + axisLabelSize;
const downloadHeight = M * downloadCellSize + axisLabelSize;
const downloadCanvas = document.createElement('canvas');
downloadCanvas.width = downloadWidth;
downloadCanvas.height = downloadHeight;
const ctx = downloadCanvas.getContext('2d');
if (!ctx) { console.error("下载失败: 无法创建临时 Canvas Context。"); alert("无法下载图纸。"); return; }
ctx.imageSmoothingEnabled = false;
// 设置背景色
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, downloadWidth, downloadHeight);
console.log(`Generating download grid image: ${downloadWidth}x${downloadHeight}`);
const fontSize = Math.max(8, Math.floor(downloadCellSize * 0.4));
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 如果需要,绘制坐标轴数字
if (showCoordinates) {
ctx.fillStyle = '#333333'; // 坐标数字颜色
const axisFontSize = Math.max(10, Math.floor(axisLabelSize * 0.6));
ctx.font = `${axisFontSize}px sans-serif`;
// X轴顶部数字
for (let i = 0; i < N; i++) {
if ((i + 1) % gridInterval === 0 || i === 0 || i === N - 1) { // 在间隔处、起始处和结束处标注
ctx.fillText((i + 1).toString(), axisLabelSize + (i * downloadCellSize) + (downloadCellSize / 2), axisLabelSize / 2);
}
}
// Y轴左侧数字
for (let j = 0; j < M; j++) {
if ((j + 1) % gridInterval === 0 || j === 0 || j === M - 1) { // 在间隔处、起始处和结束处标注
ctx.fillText((j + 1).toString(), axisLabelSize / 2, axisLabelSize + (j * downloadCellSize) + (downloadCellSize / 2));
}
}
// 重设字体以绘制单元格内的内容
ctx.font = `bold ${fontSize}px sans-serif`;
}
// 绘制所有单元格
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const cellData = mappedPixelData[j][i];
// 由于坐标轴的存在,需要偏移绘制位置
const drawX = i * downloadCellSize + axisLabelSize;
const drawY = j * downloadCellSize + axisLabelSize;
// 根据是否是外部背景确定填充颜色
if (cellData && !cellData.isExternal) {
// 内部单元格:使用珠子颜色填充并绘制文本
const cellColor = cellData.color || '#FFFFFF';
const cellKey = cellData.key || '?';
ctx.fillStyle = cellColor;
ctx.fillRect(drawX, drawY, downloadCellSize, downloadCellSize);
ctx.fillStyle = getContrastColor(cellColor);
ctx.fillText(cellKey, drawX + downloadCellSize / 2, drawY + downloadCellSize / 2);
} else {
// 外部背景:填充白色
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(drawX, drawY, downloadCellSize, downloadCellSize);
}
// 绘制所有单元格的边框
ctx.strokeStyle = '#DDDDDD'; // 浅色线条作为基础网格
ctx.lineWidth = 0.5;
ctx.strokeRect(drawX + 0.5, drawY + 0.5, downloadCellSize, downloadCellSize);
}
}
// 如果需要,绘制分隔网格线
if (showGrid) {
ctx.strokeStyle = gridLineColor; // 使用用户选择的颜色
ctx.lineWidth = 1.5;
// 绘制垂直分隔线 - 在单元格之间而不是边框上
for (let i = gridInterval; i < N; i += gridInterval) {
const lineX = i * downloadCellSize + axisLabelSize;
ctx.beginPath();
ctx.moveTo(lineX, axisLabelSize);
ctx.lineTo(lineX, axisLabelSize + M * downloadCellSize);
ctx.stroke();
}
// 绘制水平分隔线 - 在单元格之间而不是边框上
for (let j = gridInterval; j < M; j += gridInterval) {
const lineY = j * downloadCellSize + axisLabelSize;
ctx.beginPath();
ctx.moveTo(axisLabelSize, lineY);
ctx.lineTo(axisLabelSize + N * downloadCellSize, lineY);
ctx.stroke();
}
}
// 绘制整个网格区域的主边框
ctx.strokeStyle = '#000000'; // 黑色边框
ctx.lineWidth = 1.5;
ctx.strokeRect(axisLabelSize + 0.5, axisLabelSize + 0.5, N * downloadCellSize, M * downloadCellSize);
try {
const dataURL = downloadCanvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = `bead-grid-${N}x${M}-keys-palette_${selectedPaletteKeySet}.png`; // 文件名包含调色板
link.href = dataURL;
document.body.appendChild(link); link.click(); document.body.removeChild(link);
console.log("Grid image download initiated.");
} catch (e) { console.error("下载图纸失败:", e); alert("无法生成图纸下载链接。"); }
const handleDownloadRequest = (options?: GridDownloadOptions) => {
// 调用移动到utils/imageDownloader.ts中的downloadImage函数
downloadImage({
mappedPixelData,
gridDimensions,
colorCounts,
totalBeadCount,
options: options || downloadOptions,
activeBeadPalette,
selectedPaletteKeySet
});
};
// --- Download Stats Image function (ensure filename includes palette) ---
@@ -1256,167 +1125,6 @@ export default function Home() {
importPaletteInputRef.current?.click();
};
// ++ 添加下载设置弹窗组件 ++
const DownloadSettingsModal = ({
isOpen,
onClose,
options,
onOptionsChange,
onDownload
}: {
isOpen: boolean,
onClose: () => void,
options: GridDownloadOptions,
onOptionsChange: (options: GridDownloadOptions) => void,
onDownload: (opts?: GridDownloadOptions) => void // 修改onDownload类型以接受可选参数
}) => {
// 将useState移到顶层不管isOpen是什么值
const [tempOptions, setTempOptions] = useState<GridDownloadOptions>({...options});
// 如果不是打开状态仍然可以返回null
if (!isOpen) return null;
// 处理选项变更 - 使用更具体的类型而不是any
const handleOptionChange = (key: keyof GridDownloadOptions, value: string | number | boolean) => {
setTempOptions(prev => ({
...prev,
[key]: value
}));
};
// 保存选项并立即使用新设置下载
const handleSave = () => {
// 更新父组件中的设置状态(虽然下载时不依赖这个更新)
onOptionsChange(tempOptions);
// 直接使用当前临时设置下载,不依赖状态更新
onDownload(tempOptions);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden w-full max-w-md">
<div className="p-5">
<div className="flex justify-between items-center border-b dark:border-gray-700 pb-3 mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
<button
onClick={onClose}
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* 显示网格线选项 */}
<div className="flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
线
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.showGrid}
onChange={(e) => handleOptionChange('showGrid', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{/* 网格线设置 (仅当显示网格线时) */}
{tempOptions.showGrid && (
<div className="space-y-4 pl-2 border-l-2 border-gray-200 dark:border-gray-700 ml-1 pt-2 pb-1">
{/* 网格线间隔选项 */}
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
线 ( N 线)
</label>
<div className="flex items-center justify-between space-x-3">
<input
type="range"
min="5"
max="20"
step="1"
value={tempOptions.gridInterval}
onChange={(e) => handleOptionChange('gridInterval', parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="flex items-center justify-center min-w-[40px] text-sm font-medium text-gray-900 dark:text-gray-100">
{tempOptions.gridInterval}
</span>
</div>
</div>
{/* 网格线颜色选择 */}
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
线
</label>
<div className="flex flex-wrap gap-2">
{gridLineColorOptions.map(colorOpt => (
<button
key={colorOpt.value}
type="button"
onClick={() => handleOptionChange('gridLineColor', colorOpt.value)}
className={`w-8 h-8 rounded-full border-2 transition-all duration-150 flex items-center justify-center
${tempOptions.gridLineColor === colorOpt.value
? 'border-blue-500 ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-800'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'}`}
title={colorOpt.name}
>
<span
className="block w-6 h-6 rounded-full"
style={{ backgroundColor: colorOpt.value }}
></span>
</button>
))}
</div>
</div>
</div>
)}
{/* 显示坐标选项 */}
<div className="flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.showCoordinates}
onChange={(e) => handleOptionChange('showCoordinates', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex justify-end mt-6 space-x-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
};
return (
<>
{/* 添加自定义动画样式 */}
@@ -1948,24 +1656,15 @@ export default function Home() {
{/* ++ HIDE Download Buttons in manual mode ++ */}
{!isManualColoringMode && originalImageSrc && mappedPixelData && (
<div className="w-full md:max-w-2xl mt-4 flex flex-col sm:flex-row gap-2 sm:gap-3">
{/* Download Grid Button - 现在打开设置弹窗而不是直接下载 */}
<div className="w-full md:max-w-2xl mt-4">
{/* 使用一个大按钮,现在所有的下载设置都通过弹窗控制 */}
<button
onClick={() => setIsDownloadSettingsOpen(true)}
disabled={!mappedPixelData || !gridDimensions || gridDimensions.N === 0 || gridDimensions.M === 0 || activeBeadPalette.length === 0}
className="flex-1 py-2.5 px-4 bg-gradient-to-r from-green-500 to-green-600 text-white text-sm sm:text-base rounded-lg hover:from-green-600 hover:to-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:translate-y-[-1px] disabled:hover:translate-y-0 disabled:hover:shadow-md"
className="w-full py-2.5 px-4 bg-gradient-to-r from-green-500 to-green-600 text-white text-sm sm:text-base rounded-lg hover:from-green-600 hover:to-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:translate-y-[-1px] disabled:hover:translate-y-0 disabled:hover:shadow-md"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
()
</button>
{/* Download Stats Button - Keeping styles bright */}
<button
onClick={handleDownloadStatsImage}
disabled={!colorCounts || totalBeadCount === 0 || activeBeadPalette.length === 0}
className="flex-1 py-2.5 px-4 bg-gradient-to-r from-purple-500 to-purple-600 text-white text-sm sm:text-base rounded-lg hover:from-purple-600 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 transition-all duration-300 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed shadow-md hover:shadow-lg hover:translate-y-[-1px] disabled:hover:translate-y-0 disabled:hover:shadow-md"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}><path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
(PNG)
</button>
</div>
)} {/* ++ End of HIDE Download Buttons ++ */}
@@ -2072,13 +1771,13 @@ export default function Home() {
</div>
)}
{/* 添加下载设置弹窗 */}
{/* 使用导入的下载设置弹窗组件 */}
<DownloadSettingsModal
isOpen={isDownloadSettingsOpen}
onClose={() => setIsDownloadSettingsOpen(false)}
options={downloadOptions}
onOptionsChange={setDownloadOptions}
onDownload={handleDownloadImage}
onDownload={handleDownloadRequest}
/>
</div>
</>

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import { GridDownloadOptions } from '../types/downloadTypes';
// 定义可选的网格线颜色
const gridLineColorOptions = [
{ name: '深灰色', value: '#555555' },
{ name: '红色', value: '#FF0000' },
{ name: '蓝色', value: '#0000FF' },
{ name: '绿色', value: '#008000' },
{ name: '紫色', value: '#800080' },
{ name: '橙色', value: '#FFA500' },
];
interface DownloadSettingsModalProps {
isOpen: boolean;
onClose: () => void;
options: GridDownloadOptions;
onOptionsChange: (options: GridDownloadOptions) => void;
onDownload: (opts?: GridDownloadOptions) => void;
}
const DownloadSettingsModal: React.FC<DownloadSettingsModalProps> = ({
isOpen,
onClose,
options,
onOptionsChange,
onDownload
}) => {
// 将useState移到顶层不管isOpen是什么值
const [tempOptions, setTempOptions] = useState<GridDownloadOptions>({...options});
// 如果不是打开状态仍然可以返回null
if (!isOpen) return null;
// 处理选项变更 - 使用更具体的类型而不是any
const handleOptionChange = (key: keyof GridDownloadOptions, value: string | number | boolean) => {
setTempOptions((prev: GridDownloadOptions) => ({
...prev,
[key]: value
}));
};
// 保存选项并立即使用新设置下载
const handleSave = () => {
// 更新父组件中的设置状态(虽然下载时不依赖这个更新)
onOptionsChange(tempOptions);
// 直接使用当前临时设置下载,不依赖状态更新
onDownload(tempOptions);
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden w-full max-w-md">
<div className="p-5">
<div className="flex justify-between items-center border-b dark:border-gray-700 pb-3 mb-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h3>
<button
onClick={onClose}
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* 显示网格线选项 */}
<div className="flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
线
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.showGrid}
onChange={(e) => handleOptionChange('showGrid', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{/* 网格线设置 (仅当显示网格线时) */}
{tempOptions.showGrid && (
<div className="space-y-4 pl-2 border-l-2 border-gray-200 dark:border-gray-700 ml-1 pt-2 pb-1">
{/* 网格线间隔选项 */}
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
线 ( N 线)
</label>
<div className="flex items-center justify-between space-x-3">
<input
type="range"
min="5"
max="20"
step="1"
value={tempOptions.gridInterval}
onChange={(e) => handleOptionChange('gridInterval', parseInt(e.target.value))}
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
/>
<span className="flex items-center justify-center min-w-[40px] text-sm font-medium text-gray-900 dark:text-gray-100">
{tempOptions.gridInterval}
</span>
</div>
</div>
{/* 网格线颜色选择 */}
<div className="flex flex-col space-y-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
线
</label>
<div className="flex flex-wrap gap-2">
{gridLineColorOptions.map(colorOpt => (
<button
key={colorOpt.value}
type="button"
onClick={() => handleOptionChange('gridLineColor', colorOpt.value)}
className={`w-8 h-8 rounded-full border-2 transition-all duration-150 flex items-center justify-center
${tempOptions.gridLineColor === colorOpt.value
? 'border-blue-500 ring-2 ring-blue-500 ring-offset-1 dark:ring-offset-gray-800'
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'}`}
title={colorOpt.name}
>
<span
className="block w-6 h-6 rounded-full"
style={{ backgroundColor: colorOpt.value }}
></span>
</button>
))}
</div>
</div>
</div>
)}
{/* 显示坐标选项 */}
<div className="flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.showCoordinates}
onChange={(e) => handleOptionChange('showCoordinates', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{/* 添加: 包含色号统计选项 */}
<div className="flex items-center justify-between">
<label className="flex items-center text-sm font-medium text-gray-700 dark:text-gray-300">
</label>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={tempOptions.includeStats}
onChange={(e) => handleOptionChange('includeStats', e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
</div>
<div className="flex justify-end mt-6 space-x-3">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
};
export default DownloadSettingsModal;
export { gridLineColorOptions };

View File

@@ -0,0 +1,8 @@
// 下载网格的选项类型定义
export type GridDownloadOptions = {
showGrid: boolean;
gridInterval: number;
showCoordinates: boolean;
gridLineColor: string;
includeStats: boolean;
};

View File

@@ -0,0 +1,545 @@
import { GridDownloadOptions } from '../types/downloadTypes';
import { MappedPixel, PaletteColor } from './pixelation';
// 用于获取对比色的工具函数 - 从page.tsx复制
function getContrastColor(hex: string): string {
const rgb = hexToRgb(hex);
if (!rgb) return '#000000'; // Default to black
// Simple brightness check (Luma formula Y = 0.2126 R + 0.7152 G + 0.0722 B)
const luma = (0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b) / 255;
return luma > 0.5 ? '#000000' : '#FFFFFF'; // Dark background -> white text, Light background -> black text
}
// 辅助函数将十六进制颜色转换为RGB
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i;
const formattedHex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(formattedHex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
// 用于排序颜色键的函数 - 从page.tsx复制
function sortColorKeys(a: string, b: string): number {
const regex = /^([A-Z]+)(\d+)$/;
const matchA = a.match(regex);
const matchB = b.match(regex);
if (matchA && matchB) {
const prefixA = matchA[1];
const numA = parseInt(matchA[2], 10);
const prefixB = matchB[1];
const numB = parseInt(matchB[2], 10);
if (prefixA !== prefixB) {
return prefixA.localeCompare(prefixB); // Sort by prefix first (A, B, C...)
}
return numA - numB; // Then sort by number (1, 2, 10...)
}
// Fallback for keys that don't match the standard pattern (e.g., T1, ZG1)
return a.localeCompare(b);
}
// 下载图片的主函数
export function downloadImage({
mappedPixelData,
gridDimensions,
colorCounts,
totalBeadCount,
options,
activeBeadPalette,
selectedPaletteKeySet
}: {
mappedPixelData: MappedPixel[][] | null;
gridDimensions: { N: number; M: number } | null;
colorCounts: { [key: string]: { count: number; color: string } } | null;
totalBeadCount: number;
options: GridDownloadOptions;
activeBeadPalette: PaletteColor[];
selectedPaletteKeySet: string;
}): void {
if (!mappedPixelData || !gridDimensions || gridDimensions.N === 0 || gridDimensions.M === 0 || activeBeadPalette.length === 0) {
console.error("下载失败: 映射数据或尺寸无效。");
alert("无法下载图纸,数据未生成或无效。");
return;
}
if (!colorCounts) {
console.error("下载失败: 色号统计数据无效。");
alert("无法下载图纸,色号统计数据未生成或无效。");
return;
}
// 加载二维码图片
const qrCodeImage = new Image();
qrCodeImage.src = '/website_qrcode.png'; // 使用public目录中的图片
// 主要下载处理函数
const processDownload = () => {
const { N, M } = gridDimensions; // 此时已确保gridDimensions不为null
const downloadCellSize = 30;
// 从下载选项中获取设置
const { showGrid, gridInterval, showCoordinates, gridLineColor, includeStats } = options;
// 设置边距空间用于坐标轴标注(如果需要)
const axisLabelSize = showCoordinates ? Math.max(30, Math.floor(downloadCellSize)) : 0;
// 定义统计区域的基本参数
const statsPadding = 20;
let statsHeight = 0;
// 预先计算用于字体大小的变量
const preCalcWidth = N * downloadCellSize + axisLabelSize;
const preCalcAvailableWidth = preCalcWidth - (statsPadding * 2);
// 计算字体大小 - 与颜色统计区域保持一致
const baseStatsFontSize = 13;
const widthFactor = Math.max(0, preCalcAvailableWidth - 350) / 600;
const statsFontSize = Math.floor(baseStatsFontSize + (widthFactor * 10));
// 计算额外边距,确保坐标数字完全显示
const extraLeftMargin = showCoordinates ? Math.max(20, statsFontSize * 2) : 0; // 左侧额外边距
const extraTopMargin = showCoordinates ? Math.max(15, statsFontSize) : 0; // 顶部额外边距
// 计算网格尺寸
const gridWidth = N * downloadCellSize;
const gridHeight = M * downloadCellSize;
// 计算标题栏高度(根据图片大小自动调整)
const baseTitleBarHeight = 80; // 增大基础高度
// 先计算一个初始下载宽度来确定缩放比例
const initialWidth = gridWidth + axisLabelSize + extraLeftMargin;
// 使用总宽度而不是单元格大小来计算比例,确保字体在大尺寸图片上也足够大
const titleBarScale = Math.max(1.0, Math.min(2.0, initialWidth / 1000)); // 更激进的缩放策略
const titleBarHeight = Math.floor(baseTitleBarHeight * titleBarScale);
// 计算标题文字大小 - 与总体宽度相关而不是单元格大小
const titleFontSize = Math.max(28, Math.floor(28 * titleBarScale)); // 最小28px确保可读性
// 计算二维码大小
const qrSize = Math.floor(titleBarHeight * 0.85); // 增大二维码比例
// 计算统计区域的大小
if (includeStats && colorCounts) {
const colorKeys = Object.keys(colorCounts);
// 统计区域顶部额外间距
const statsTopMargin = 24; // 与下方渲染时保持一致
// 根据可用宽度动态计算列数
let numColumns = Math.max(1, Math.min(4, Math.floor(preCalcAvailableWidth / 250)));
// 根据可用宽度动态计算样式参数,使用更积极的线性缩放
const baseSwatchSize = 18; // 略微增大基础大小
// baseStatsFontSize 和 statsFontSize 在前面已经计算了,这里不需要重复
const baseItemPadding = 10;
// 调整缩放公式,使大宽度更明显增大
// widthFactor 在前面已经计算了,这里不需要重复
const swatchSize = Math.floor(baseSwatchSize + (widthFactor * 20)); // 增大最大增量幅度
// statsFontSize 在前面已经计算了,这里不需要重复
const itemPadding = Math.floor(baseItemPadding + (widthFactor * 12)); // 增大最大增量幅度
// 计算实际需要的行数
const numRows = Math.ceil(colorKeys.length / numColumns);
// 计算单行高度 - 根据色块大小和内边距动态调整
const statsRowHeight = Math.max(swatchSize + 8, 25);
// 标题和页脚高度
const titleHeight = 40; // 标题和分隔线的总高度
const footerHeight = 40; // 总计部分的高度
// 计算统计区域的总高度 - 需要包含顶部间距
statsHeight = titleHeight + (numRows * statsRowHeight) + footerHeight + (statsPadding * 2) + statsTopMargin;
}
// 调整画布大小,包含标题栏、坐标轴和统计区域
const downloadWidth = gridWidth + axisLabelSize + extraLeftMargin;
let downloadHeight = titleBarHeight + gridHeight + axisLabelSize + statsHeight + extraTopMargin;
let downloadCanvas = document.createElement('canvas');
downloadCanvas.width = downloadWidth;
downloadCanvas.height = downloadHeight;
const context = downloadCanvas.getContext('2d');
if (!context) {
console.error("下载失败: 无法创建临时 Canvas Context。");
alert("无法下载图纸。");
return;
}
// 使用非空的context变量
let ctx = context;
ctx.imageSmoothingEnabled = false;
// 设置背景色
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, downloadWidth, downloadHeight);
// 绘制标题栏背景
const gradientHeight = titleBarHeight;
const gradient = ctx.createLinearGradient(0, 0, downloadWidth, 0); // 水平渐变更美观
gradient.addColorStop(0, '#4F46E5'); // 靛蓝色 (indigo-600)
gradient.addColorStop(0.5, '#7C3AED'); // 紫色 (violet-600)
gradient.addColorStop(1, '#C026D3'); // 洋红色 (fuchsia-600)
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, downloadWidth, titleBarHeight);
// 添加装饰元素 - 左侧圆点图案
const dotSize = titleBarHeight / 20;
const dotSpacing = titleBarHeight / 10;
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
for (let y = dotSpacing; y < titleBarHeight - dotSpacing; y += dotSpacing) {
for (let x = dotSpacing; x < titleBarHeight; x += dotSpacing) {
ctx.beginPath();
ctx.arc(x, y, dotSize, 0, Math.PI * 2);
ctx.fill();
}
}
// 添加右侧装饰线条
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.lineWidth = 2;
const lineOffset = downloadWidth / 3; // 线条从右三分之一处开始
for (let i = 0; i < 3; i++) {
const startX = lineOffset + (i * 60 * titleBarScale);
ctx.beginPath();
ctx.moveTo(startX, 0);
ctx.lineTo(startX + titleBarHeight, titleBarHeight);
ctx.stroke();
}
// 绘制标题文字
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${titleFontSize}px sans-serif`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
// 添加文字阴影效果
ctx.shadowColor = 'rgba(0, 0, 0, 0.3)';
ctx.shadowBlur = 5;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
// 居中绘制标题
ctx.fillText('七卡瓦 拼豆底稿生成器', titleBarHeight / 2, titleBarHeight / 2);
// 重置阴影
ctx.shadowColor = 'transparent';
ctx.shadowBlur = 0;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 0;
// 预留二维码位置
const qrX = downloadWidth - qrSize - titleBarHeight / 4;
const qrY = (titleBarHeight - qrSize) / 2;
// 绘制二维码背景
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(qrX, qrY, qrSize, qrSize);
// 绘制二维码图片或占位符
if (qrCodeImage.complete && qrCodeImage.naturalWidth !== 0) {
// 图片加载成功,绘制图片
ctx.drawImage(qrCodeImage, qrX, qrY, qrSize, qrSize);
} else {
// 图片加载失败,绘制占位文字
ctx.fillStyle = '#6D28D9'; // 紫色文字
const qrFontSize = Math.max(14, Math.floor(14 * titleBarScale));
ctx.font = `${qrFontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.fillText('扫码访问', qrX + qrSize / 2, qrY + qrSize / 2);
}
console.log(`Generating download grid image: ${downloadWidth}x${downloadHeight}`);
const fontSize = Math.max(8, Math.floor(downloadCellSize * 0.4));
// 如果需要,先绘制坐标轴和网格背景
if (showCoordinates) {
// 绘制坐标轴背景
ctx.fillStyle = '#F5F5F5'; // 浅灰色背景
// 横轴背景 (顶部)
ctx.fillRect(extraLeftMargin + axisLabelSize, titleBarHeight + extraTopMargin, gridWidth, axisLabelSize);
// 纵轴背景 (左侧)
ctx.fillRect(extraLeftMargin, titleBarHeight + extraTopMargin + axisLabelSize, axisLabelSize, gridHeight);
// 绘制坐标轴数字
ctx.fillStyle = '#333333'; // 坐标数字颜色
// 使用与颜色统计区域相同的字体大小,但不使用粗体
const axisFontSize = statsFontSize;
ctx.font = `${axisFontSize}px sans-serif`;
// X轴顶部数字
ctx.textAlign = 'center';
for (let i = 0; i < N; i++) {
if ((i + 1) % gridInterval === 0 || i === 0 || i === N - 1) { // 在间隔处、起始处和结束处标注
// 将数字放在轴线之上,考虑额外边距
const numX = extraLeftMargin + axisLabelSize + (i * downloadCellSize) + (downloadCellSize / 2);
const numY = titleBarHeight + extraTopMargin + (axisLabelSize / 2);
ctx.fillText((i + 1).toString(), numX, numY);
}
}
// Y轴左侧数字
ctx.textAlign = 'right';
for (let j = 0; j < M; j++) {
if ((j + 1) % gridInterval === 0 || j === 0 || j === M - 1) { // 在间隔处、起始处和结束处标注
// 将数字放在轴线之左,留出间距并考虑额外边距
const numX = extraLeftMargin + axisLabelSize - 8;
const numY = titleBarHeight + extraTopMargin + axisLabelSize + (j * downloadCellSize) + (downloadCellSize / 2);
ctx.fillText((j + 1).toString(), numX, numY);
}
}
// 绘制坐标轴边框
ctx.strokeStyle = '#AAAAAA';
ctx.lineWidth = 1;
// 横轴底边
ctx.beginPath();
ctx.moveTo(extraLeftMargin + axisLabelSize, titleBarHeight + extraTopMargin + axisLabelSize);
ctx.lineTo(extraLeftMargin + axisLabelSize + gridWidth, titleBarHeight + extraTopMargin + axisLabelSize);
ctx.stroke();
// 纵轴右侧边
ctx.beginPath();
ctx.moveTo(extraLeftMargin + axisLabelSize, titleBarHeight + extraTopMargin + axisLabelSize);
ctx.lineTo(extraLeftMargin + axisLabelSize, titleBarHeight + extraTopMargin + axisLabelSize + gridHeight);
ctx.stroke();
}
// 恢复默认文本对齐和基线,为后续绘制做准备
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 设置用于绘制单元格内容的字体
ctx.font = `bold ${fontSize}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// 绘制所有单元格
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const cellData = mappedPixelData[j][i];
// 计算绘制位置,考虑额外边距和标题栏高度
const drawX = extraLeftMargin + i * downloadCellSize + axisLabelSize;
const drawY = titleBarHeight + extraTopMargin + j * downloadCellSize + axisLabelSize;
// 根据是否是外部背景确定填充颜色
if (cellData && !cellData.isExternal) {
// 内部单元格:使用珠子颜色填充并绘制文本
const cellColor = cellData.color || '#FFFFFF';
const cellKey = cellData.key || '?';
ctx.fillStyle = cellColor;
ctx.fillRect(drawX, drawY, downloadCellSize, downloadCellSize);
ctx.fillStyle = getContrastColor(cellColor);
ctx.fillText(cellKey, drawX + downloadCellSize / 2, drawY + downloadCellSize / 2);
} else {
// 外部背景:填充白色
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(drawX, drawY, downloadCellSize, downloadCellSize);
}
// 绘制所有单元格的边框
ctx.strokeStyle = '#DDDDDD'; // 浅色线条作为基础网格
ctx.lineWidth = 0.5;
ctx.strokeRect(drawX + 0.5, drawY + 0.5, downloadCellSize, downloadCellSize);
}
}
// 如果需要,绘制分隔网格线
if (showGrid) {
ctx.strokeStyle = gridLineColor; // 使用用户选择的颜色
ctx.lineWidth = 1.5;
// 绘制垂直分隔线 - 在单元格之间而不是边框上
for (let i = gridInterval; i < N; i += gridInterval) {
const lineX = extraLeftMargin + i * downloadCellSize + axisLabelSize;
ctx.beginPath();
ctx.moveTo(lineX, titleBarHeight + extraTopMargin + axisLabelSize);
ctx.lineTo(lineX, titleBarHeight + extraTopMargin + axisLabelSize + M * downloadCellSize);
ctx.stroke();
}
// 绘制水平分隔线 - 在单元格之间而不是边框上
for (let j = gridInterval; j < M; j += gridInterval) {
const lineY = titleBarHeight + extraTopMargin + j * downloadCellSize + axisLabelSize;
ctx.beginPath();
ctx.moveTo(extraLeftMargin + axisLabelSize, lineY);
ctx.lineTo(extraLeftMargin + axisLabelSize + N * downloadCellSize, lineY);
ctx.stroke();
}
}
// 绘制整个网格区域的主边框
ctx.strokeStyle = '#000000'; // 黑色边框
ctx.lineWidth = 1.5;
ctx.strokeRect(
extraLeftMargin + axisLabelSize + 0.5,
titleBarHeight + extraTopMargin + axisLabelSize + 0.5,
N * downloadCellSize,
M * downloadCellSize
);
// 绘制统计信息
if (includeStats && colorCounts) {
const colorKeys = Object.keys(colorCounts).sort(sortColorKeys);
// 增加额外的间距,防止标题文字侵入画布
const statsTopMargin = 24; // 增加间距,防止文字侵入画布
const statsY = titleBarHeight + extraTopMargin + M * downloadCellSize + axisLabelSize + statsPadding + statsTopMargin;
// 计算统计区域的可用宽度
const availableStatsWidth = downloadWidth - (statsPadding * 2);
// 根据可用宽度动态计算列数 - 这里使用实际渲染时的宽度
const renderNumColumns = Math.max(1, Math.min(4, Math.floor(availableStatsWidth / 250)));
// 根据可用宽度动态计算样式参数,使用更积极的线性缩放
const baseSwatchSize = 18; // 略微增大基础大小
// baseStatsFontSize 和 statsFontSize 在前面已经计算了,这里不需要重复
const baseItemPadding = 10;
// 调整缩放公式,使大宽度更明显增大
// widthFactor 在前面已经计算了,这里不需要重复
const swatchSize = Math.floor(baseSwatchSize + (widthFactor * 20)); // 增大最大增量幅度
// statsFontSize 在前面已经计算了,这里不需要重复
const itemPadding = Math.floor(baseItemPadding + (widthFactor * 12)); // 增大最大增量幅度
// 计算每个项目所占的宽度
const itemWidth = Math.floor(availableStatsWidth / renderNumColumns);
// 绘制统计区域标题
ctx.fillStyle = '#333333';
ctx.font = `bold ${Math.max(16, statsFontSize)}px sans-serif`;
ctx.textAlign = 'left';
// 绘制分隔线
ctx.strokeStyle = '#DDDDDD';
ctx.beginPath();
ctx.moveTo(statsPadding, statsY + 20);
ctx.lineTo(downloadWidth - statsPadding, statsY + 20);
ctx.stroke();
const titleHeight = 30; // 标题和分隔线的总高度
// 根据色块大小动态调整行高
const statsRowHeight = Math.max(swatchSize + 8, 25); // 确保行高足够放下色块和文字
// 设置表格字体
ctx.font = `${statsFontSize}px sans-serif`;
// 绘制每行统计信息
colorKeys.forEach((key, index) => {
// 计算当前项目应该在哪一行和哪一列
const rowIndex = Math.floor(index / renderNumColumns);
const colIndex = index % renderNumColumns;
// 计算当前项目的X起始位置
const itemX = statsPadding + (colIndex * itemWidth);
// 计算当前行的Y位置
const rowY = statsY + titleHeight + (rowIndex * statsRowHeight) + (swatchSize / 2);
const cellData = colorCounts[key];
// 绘制色块
ctx.fillStyle = cellData.color;
ctx.strokeStyle = '#CCCCCC';
ctx.fillRect(itemX, rowY - (swatchSize / 2), swatchSize, swatchSize);
ctx.strokeRect(itemX + 0.5, rowY - (swatchSize / 2) + 0.5, swatchSize - 1, swatchSize - 1);
// 绘制色号
ctx.fillStyle = '#333333';
ctx.textAlign = 'left';
ctx.fillText(key, itemX + swatchSize + 5, rowY);
// 绘制数量 - 在每个项目的右侧
const countText = `${cellData.count}`;
ctx.textAlign = 'right';
// 根据列数计算数字的位置
// 如果只有一列,就靠右绘制
if (renderNumColumns === 1) {
ctx.fillText(countText, downloadWidth - statsPadding, rowY);
} else {
// 多列时,在每个单元格右侧偏内绘制
ctx.fillText(countText, itemX + itemWidth - itemPadding, rowY);
}
});
// 计算实际需要的行数
const numRows = Math.ceil(colorKeys.length / renderNumColumns);
// 绘制总量
const totalY = statsY + titleHeight + (numRows * statsRowHeight) + 10;
ctx.font = `bold ${statsFontSize}px sans-serif`;
ctx.textAlign = 'right';
ctx.fillText(`总计: ${totalBeadCount}`, downloadWidth - statsPadding, totalY);
// 更新统计区域高度的计算 - 需要包含新增的顶部间距
const footerHeight = 30; // 总计部分高度
statsHeight = titleHeight + (numRows * statsRowHeight) + footerHeight + (statsPadding * 2) + statsTopMargin;
}
// 重新计算画布高度并调整
if (includeStats && colorCounts) {
// 调整画布大小,包含计算后的统计区域
const newDownloadHeight = titleBarHeight + extraTopMargin + M * downloadCellSize + axisLabelSize + statsHeight;
if (downloadHeight !== newDownloadHeight) {
// 如果高度变化了,需要创建新的画布并复制当前内容
const newCanvas = document.createElement('canvas');
newCanvas.width = downloadWidth;
newCanvas.height = newDownloadHeight;
const newContext = newCanvas.getContext('2d');
if (newContext) {
// 复制原画布内容
newContext.drawImage(downloadCanvas, 0, 0);
// 更新画布和上下文引用
downloadCanvas = newCanvas;
ctx = newContext;
ctx.imageSmoothingEnabled = false;
// 更新高度
downloadHeight = newDownloadHeight;
}
}
}
try {
const dataURL = downloadCanvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = `bead-grid-${N}x${M}-keys-palette_${selectedPaletteKeySet}.png`; // 文件名包含调色板
link.href = dataURL;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
console.log("Grid image download initiated.");
} catch (e) {
console.error("下载图纸失败:", e);
alert("无法生成图纸下载链接。");
}
};
// 图片加载后处理,或在加载失败时使用占位符
if (qrCodeImage.complete) {
processDownload();
} else {
qrCodeImage.onload = processDownload;
qrCodeImage.onerror = () => {
console.warn("二维码图片加载失败,将使用占位符");
processDownload();
};
}
}