Files
perler-beads/src/app/page.tsx

2685 lines
126 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import React, { useState, useRef, ChangeEvent, DragEvent, useEffect, useMemo, useCallback } from 'react';
import Script from 'next/script';
import InstallPWA from '../components/InstallPWA';
// 导入像素化工具和类型
import {
PixelationMode,
calculatePixelGrid,
RgbColor,
PaletteColor,
MappedPixel,
hexToRgb,
colorDistance,
findClosestPaletteColor
} from '../utils/pixelation';
// 导入新的类型和组件
import { GridDownloadOptions } from '../types/downloadTypes';
import DownloadSettingsModal, { gridLineColorOptions } from '../components/DownloadSettingsModal';
import { downloadImage, importCsvData } from '../utils/imageDownloader';
import {
colorSystemOptions,
convertPaletteToColorSystem,
getColorKeyByHex,
getMardToHexMapping,
sortColorsByHue,
ColorSystem
} from '../utils/colorSystemUtils';
// 添加自定义动画样式
const floatAnimation = `
@keyframes float {
0% { transform: translateY(0px); }
50% { transform: translateY(-5px); }
100% { transform: translateY(0px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
`;
// Helper function for sorting color keys - 保留原有实现因为未在utils中导出
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);
}
// --- Define available palette key sets ---
// 从colorSystemMapping.json获取所有MARD色号
const mardToHexMapping = getMardToHexMapping();
// Pre-process the FULL palette data once - 使用colorSystemMapping而不是beadPaletteData
const fullBeadPalette: PaletteColor[] = Object.entries(mardToHexMapping)
.map(([mardKey, hex]) => {
const rgb = hexToRgb(hex);
if (!rgb) {
console.warn(`Invalid hex code "${hex}" for MARD key "${mardKey}". Skipping.`);
return null;
}
// 使用hex值作为key符合新的架构设计
return { key: hex, hex, rgb };
})
.filter((color): color is PaletteColor => color !== null);
// ++ Add definition for background color keys ++
// 1. 导入新组件
import PixelatedPreviewCanvas from '../components/PixelatedPreviewCanvas';
import GridTooltip from '../components/GridTooltip';
import CustomPaletteEditor from '../components/CustomPaletteEditor';
import FloatingColorPalette from '../components/FloatingColorPalette';
import FloatingToolbar from '../components/FloatingToolbar';
import MagnifierTool from '../components/MagnifierTool';
import MagnifierSelectionOverlay from '../components/MagnifierSelectionOverlay';
import { loadPaletteSelections, savePaletteSelections, presetToSelections, PaletteSelections } from '../utils/localStorageUtils';
import { TRANSPARENT_KEY, transparentColorData } from '../utils/pixelEditingUtils';
// 1. 导入新的 DonationModal 组件
import DonationModal from '../components/DonationModal';
import FocusModePreDownloadModal from '../components/FocusModePreDownloadModal';
export default function Home() {
const [originalImageSrc, setOriginalImageSrc] = useState<string | null>(null);
const [granularity, setGranularity] = useState<number>(50);
const [granularityInput, setGranularityInput] = useState<string>("50");
const [similarityThreshold, setSimilarityThreshold] = useState<number>(30);
const [similarityThresholdInput, setSimilarityThresholdInput] = useState<string>("30");
// 添加像素化模式状态
const [pixelationMode, setPixelationMode] = useState<PixelationMode>(PixelationMode.Dominant); // 默认为卡通模式
// 新增:色号系统选择状态
const [selectedColorSystem, setSelectedColorSystem] = useState<ColorSystem>('MARD');
const [activeBeadPalette, setActiveBeadPalette] = useState<PaletteColor[]>(() => {
return fullBeadPalette; // 默认使用全部颜色
});
// 状态变量存储被排除的颜色hex值
const [excludedColorKeys, setExcludedColorKeys] = useState<Set<string>>(new Set());
const [showExcludedColors, setShowExcludedColors] = useState<boolean>(false);
// 用于记录初始网格颜色hex值用于显示排除功能
const [initialGridColorKeys, setInitialGridColorKeys] = useState<Set<string>>(new Set());
const [mappedPixelData, setMappedPixelData] = useState<MappedPixel[][] | null>(null);
const [gridDimensions, setGridDimensions] = useState<{ N: number; M: number } | null>(null);
const [colorCounts, setColorCounts] = useState<{ [key: string]: { count: number; color: string } } | null>(null);
const [totalBeadCount, setTotalBeadCount] = useState<number>(0);
const [tooltipData, setTooltipData] = useState<{ x: number, y: number, key: string, color: string } | null>(null);
const [remapTrigger, setRemapTrigger] = useState<number>(0);
const [isManualColoringMode, setIsManualColoringMode] = useState<boolean>(false);
const [selectedColor, setSelectedColor] = useState<MappedPixel | null>(null);
// 新增:一键擦除模式状态
const [isEraseMode, setIsEraseMode] = useState<boolean>(false);
// 新增状态变量:控制打赏弹窗
const [isDonationModalOpen, setIsDonationModalOpen] = useState<boolean>(false);
const [customPaletteSelections, setCustomPaletteSelections] = useState<PaletteSelections>({});
const [isCustomPaletteEditorOpen, setIsCustomPaletteEditorOpen] = useState<boolean>(false);
const [isCustomPalette, setIsCustomPalette] = useState<boolean>(false);
// ++ 新增:下载设置相关状态 ++
const [isDownloadSettingsOpen, setIsDownloadSettingsOpen] = useState<boolean>(false);
const [downloadOptions, setDownloadOptions] = useState<GridDownloadOptions>({
showGrid: true,
gridInterval: 10,
showCoordinates: true,
showCellNumbers: true,
gridLineColor: gridLineColorOptions[0].value,
includeStats: true, // 默认包含统计信息
exportCsv: false // 默认不导出CSV
});
// 新增:高亮相关状态
const [highlightColorKey, setHighlightColorKey] = useState<string | null>(null);
// 新增:完整色板切换状态
const [showFullPalette, setShowFullPalette] = useState<boolean>(false);
// 新增:颜色替换相关状态
const [colorReplaceState, setColorReplaceState] = useState<{
isActive: boolean;
step: 'select-source' | 'select-target';
sourceColor?: { key: string; color: string };
}>({
isActive: false,
step: 'select-source'
});
// 新增:组件挂载状态
const [isMounted, setIsMounted] = useState<boolean>(false);
// 新增:悬浮调色盘状态
const [isFloatingPaletteOpen, setIsFloatingPaletteOpen] = useState<boolean>(true);
// 新增:放大镜状态
const [isMagnifierActive, setIsMagnifierActive] = useState<boolean>(false);
const [magnifierSelectionArea, setMagnifierSelectionArea] = useState<{
startRow: number;
startCol: number;
endRow: number;
endCol: number;
} | null>(null);
// 新增:活跃工具层级管理
const [activeFloatingTool, setActiveFloatingTool] = useState<'palette' | 'magnifier' | null>(null);
// 新增:专心拼豆模式进入前下载提醒弹窗
const [isFocusModePreDownloadModalOpen, setIsFocusModePreDownloadModalOpen] = useState<boolean>(false);
// 放大镜切换处理函数
const handleToggleMagnifier = () => {
const newActiveState = !isMagnifierActive;
setIsMagnifierActive(newActiveState);
// 如果关闭放大镜,清除选择区域,重新开始
if (!newActiveState) {
setMagnifierSelectionArea(null);
}
};
// 激活工具处理函数
const handleActivatePalette = () => {
setActiveFloatingTool('palette');
};
const handleActivateMagnifier = () => {
setActiveFloatingTool('magnifier');
};
// 放大镜像素编辑处理函数
const handleMagnifierPixelEdit = (row: number, col: number, colorData: { key: string; color: string }) => {
if (!mappedPixelData) return;
// 创建新的像素数据
const newMappedPixelData = mappedPixelData.map((rowData, r) =>
rowData.map((pixel, c) => {
if (r === row && c === col) {
return {
key: colorData.key,
color: colorData.color
} as MappedPixel;
}
return pixel;
})
);
setMappedPixelData(newMappedPixelData);
// 更新颜色统计
if (colorCounts) {
const newColorCounts = { ...colorCounts };
// 减少原颜色的计数
const oldPixel = mappedPixelData[row][col];
if (newColorCounts[oldPixel.key]) {
newColorCounts[oldPixel.key].count--;
if (newColorCounts[oldPixel.key].count === 0) {
delete newColorCounts[oldPixel.key];
}
}
// 增加新颜色的计数
if (newColorCounts[colorData.key]) {
newColorCounts[colorData.key].count++;
} else {
newColorCounts[colorData.key] = {
count: 1,
color: colorData.color
};
}
setColorCounts(newColorCounts);
// 更新总计数
const newTotal = Object.values(newColorCounts).reduce((sum, item) => sum + item.count, 0);
setTotalBeadCount(newTotal);
}
};
const originalCanvasRef = useRef<HTMLCanvasElement>(null);
const pixelatedCanvasRef = useRef<HTMLCanvasElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ++ 添加: Ref for import file input ++
const importPaletteInputRef = useRef<HTMLInputElement>(null);
//const longPressTimerRef = useRef<NodeJS.Timeout | null>(null);
// ++ Re-add touch refs needed for tooltip logic ++
//const touchStartPosRef = useRef<{ x: number; y: number; pageX: number; pageY: number } | null>(null);
//const touchMovedRef = useRef<boolean>(false);
// ++ Add a ref for the main element ++
const mainRef = useRef<HTMLElement>(null);
// --- Derived State ---
// Update active palette based on selection and exclusions
useEffect(() => {
const newActiveBeadPalette = fullBeadPalette.filter(color => {
const normalizedHex = color.hex.toUpperCase();
const isSelectedInCustomPalette = customPaletteSelections[normalizedHex];
const isNotExcluded = !excludedColorKeys.has(normalizedHex);
return isSelectedInCustomPalette && isNotExcluded;
});
// 根据选择的色号系统转换调色板
const convertedPalette = convertPaletteToColorSystem(newActiveBeadPalette, selectedColorSystem);
setActiveBeadPalette(convertedPalette);
}, [customPaletteSelections, excludedColorKeys, remapTrigger, selectedColorSystem]);
// ++ 添加:当状态变化时同步更新输入框的值 ++
useEffect(() => {
setGranularityInput(granularity.toString());
setSimilarityThresholdInput(similarityThreshold.toString());
}, [granularity, similarityThreshold]);
// ++ Calculate unique colors currently on the grid for the palette ++
const currentGridColors = useMemo(() => {
if (!mappedPixelData) return [];
// 使用hex值进行去重避免多个MARD色号对应同一个目标色号系统值时产生重复key
const uniqueColorsMap = new Map<string, MappedPixel>();
mappedPixelData.flat().forEach(cell => {
if (cell && cell.color && !cell.isExternal) {
const hexKey = cell.color.toUpperCase();
if (!uniqueColorsMap.has(hexKey)) {
// 存储hex值作为key保持颜色信息
uniqueColorsMap.set(hexKey, { key: cell.key, color: cell.color });
}
}
});
// 转换为数组并为每个hex值生成对应的色号系统显示
const originalColors = Array.from(uniqueColorsMap.values());
const colorData = originalColors.map(color => {
const displayKey = getColorKeyByHex(color.color.toUpperCase(), selectedColorSystem);
return {
key: displayKey,
color: color.color
};
});
// 使用色相排序而不是色号排序
return sortColorsByHue(colorData);
}, [mappedPixelData, selectedColorSystem]);
// 初始化时从本地存储加载自定义色板选择
useEffect(() => {
// 尝试从localStorage加载
const savedSelections = loadPaletteSelections();
if (savedSelections && Object.keys(savedSelections).length > 0) {
console.log('从localStorage加载的数据键数量:', Object.keys(savedSelections).length);
// 验证加载的数据是否都是有效的hex值
const allHexValues = fullBeadPalette.map(color => color.hex.toUpperCase());
const validSelections: PaletteSelections = {};
let hasValidData = false;
let validCount = 0;
let invalidCount = 0;
Object.entries(savedSelections).forEach(([key, value]) => {
// 严格验证键必须是有效的hex格式并且存在于调色板中
if (/^#[0-9A-F]{6}$/i.test(key) && allHexValues.includes(key.toUpperCase())) {
validSelections[key.toUpperCase()] = value;
hasValidData = true;
validCount++;
} else {
invalidCount++;
}
});
console.log(`验证结果: 有效键 ${validCount} 个, 无效键 ${invalidCount}`);
if (hasValidData) {
setCustomPaletteSelections(validSelections);
setIsCustomPalette(true);
} else {
console.log('所有数据都无效清除localStorage并重新初始化');
// 如果本地数据无效清除localStorage并默认选择所有颜色
localStorage.removeItem('customPerlerPaletteSelections');
const allHexValues = fullBeadPalette.map(color => color.hex.toUpperCase());
const initialSelections = presetToSelections(allHexValues, allHexValues);
setCustomPaletteSelections(initialSelections);
setIsCustomPalette(false);
}
} else {
console.log('没有localStorage数据默认选择所有颜色');
// 如果没有保存的选择,默认选择所有颜色
const allHexValues = fullBeadPalette.map(color => color.hex.toUpperCase());
const initialSelections = presetToSelections(allHexValues, allHexValues);
setCustomPaletteSelections(initialSelections);
setIsCustomPalette(false);
}
}, []); // 只在组件首次加载时执行
// 更新 activeBeadPalette 基于自定义选择和排除列表
useEffect(() => {
const newActiveBeadPalette = fullBeadPalette.filter(color => {
const normalizedHex = color.hex.toUpperCase();
const isSelectedInCustomPalette = customPaletteSelections[normalizedHex];
// 使用hex值进行排除检查
const isNotExcluded = !excludedColorKeys.has(normalizedHex);
return isSelectedInCustomPalette && isNotExcluded;
});
// 不进行色号系统转换保持原始的MARD色号和hex值
setActiveBeadPalette(newActiveBeadPalette);
}, [customPaletteSelections, excludedColorKeys, remapTrigger]);
// --- Event Handlers ---
// 专心拼豆模式相关处理函数
const handleEnterFocusMode = () => {
setIsFocusModePreDownloadModalOpen(true);
};
const handleProceedToFocusMode = () => {
// 保存数据到localStorage供专心拼豆模式使用
localStorage.setItem('focusMode_pixelData', JSON.stringify(mappedPixelData));
localStorage.setItem('focusMode_gridDimensions', JSON.stringify(gridDimensions));
localStorage.setItem('focusMode_colorCounts', JSON.stringify(colorCounts));
localStorage.setItem('focusMode_selectedColorSystem', selectedColorSystem);
// 跳转到专心拼豆页面
window.location.href = '/focus';
};
// 添加一个安全的文件输入触发函数
const triggerFileInput = useCallback(() => {
// 检查组件是否已挂载
if (!isMounted) {
console.warn("组件尚未完全挂载,延迟触发文件选择");
setTimeout(() => triggerFileInput(), 200);
return;
}
// 检查 ref 是否存在
if (fileInputRef.current) {
try {
fileInputRef.current.click();
} catch (error) {
console.error("触发文件选择失败:", error);
// 如果直接点击失败,尝试延迟执行
setTimeout(() => {
try {
fileInputRef.current?.click();
} catch (retryError) {
console.error("重试触发文件选择失败:", retryError);
}
}, 100);
}
} else {
// 如果 ref 不存在,延迟重试
console.warn("文件输入引用不存在将在100ms后重试");
setTimeout(() => {
if (fileInputRef.current) {
try {
fileInputRef.current.click();
} catch (error) {
console.error("延迟触发文件选择失败:", error);
}
}
}, 100);
}
}, [isMounted]);
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
// 检查文件类型是否支持
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
// 支持的图片类型
const supportedImageTypes = ['image/jpeg', 'image/jpg', 'image/png'];
// 支持的CSV MIME类型不同浏览器可能返回不同的MIME类型
const supportedCsvTypes = ['text/csv', 'application/csv', 'text/plain'];
const isImageFile = supportedImageTypes.includes(fileType) || fileType.startsWith('image/');
const isCsvFile = supportedCsvTypes.includes(fileType) || fileName.endsWith('.csv');
if (isImageFile || isCsvFile) {
setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++
processFile(file);
} else {
alert(`不支持的文件类型: ${file.type || '未知'}。请选择 JPG、PNG 格式的图片文件,或 CSV 数据文件。\n文件名: ${file.name}`);
console.warn(`Unsupported file type: ${file.type}, file name: ${file.name}`);
}
}
// 重置文件输入框的值,这样用户可以重新选择同一个文件
if (event.target) {
event.target.value = '';
}
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
try {
if (event.dataTransfer.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0];
// 使用与handleFileChange相同的文件类型检查逻辑
const fileName = file.name.toLowerCase();
const fileType = file.type.toLowerCase();
// 支持的图片类型
const supportedImageTypes = ['image/jpeg', 'image/jpg', 'image/png'];
// 支持的CSV MIME类型不同浏览器可能返回不同的MIME类型
const supportedCsvTypes = ['text/csv', 'application/csv', 'text/plain'];
const isImageFile = supportedImageTypes.includes(fileType) || fileType.startsWith('image/');
const isCsvFile = supportedCsvTypes.includes(fileType) || fileName.endsWith('.csv');
if (isImageFile || isCsvFile) {
setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++
processFile(file);
} else {
alert(`不支持的文件类型: ${file.type || '未知'}。请拖放 JPG、PNG 格式的图片文件,或 CSV 数据文件。\n文件名: ${file.name}`);
console.warn(`Unsupported file type: ${file.type}, file name: ${file.name}`);
}
}
} catch (error) {
console.error("处理拖拽文件时发生错误:", error);
alert("处理文件时发生错误,请重试。");
}
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
};
// 根据mappedPixelData生成合成的originalImageSrc
const generateSyntheticImageFromPixelData = (pixelData: MappedPixel[][], dimensions: { N: number; M: number }): string => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
console.error('无法创建canvas上下文');
return '';
}
// 设置画布尺寸每个像素用8x8像素来表示以确保清晰度
const pixelSize = 8;
canvas.width = dimensions.N * pixelSize;
canvas.height = dimensions.M * pixelSize;
// 绘制每个像素
pixelData.forEach((row, rowIndex) => {
row.forEach((cell, colIndex) => {
if (cell) {
// 使用颜色,外部单元格用白色
const color = cell.isExternal ? '#FFFFFF' : cell.color;
ctx.fillStyle = color;
ctx.fillRect(
colIndex * pixelSize,
rowIndex * pixelSize,
pixelSize,
pixelSize
);
}
});
});
// 转换为dataURL
return canvas.toDataURL('image/png');
};
const processFile = (file: File) => {
// 检查文件类型
const fileExtension = file.name.split('.').pop()?.toLowerCase();
if (fileExtension === 'csv') {
// 处理CSV文件
console.log('正在导入CSV文件...');
importCsvData(file)
.then(({ mappedPixelData, gridDimensions }) => {
console.log(`成功导入CSV文件: ${gridDimensions.N}x${gridDimensions.M}`);
// 设置导入的数据
setMappedPixelData(mappedPixelData);
setGridDimensions(gridDimensions);
setOriginalImageSrc(null); // CSV导入时没有原始图片
// 计算颜色统计
const colorCountsMap: { [key: string]: { count: number; color: string } } = {};
let totalCount = 0;
mappedPixelData.forEach(row => {
row.forEach(cell => {
if (cell && !cell.isExternal) {
const colorKey = cell.color.toUpperCase();
if (colorCountsMap[colorKey]) {
colorCountsMap[colorKey].count++;
} else {
colorCountsMap[colorKey] = {
count: 1,
color: cell.color
};
}
totalCount++;
}
});
});
setColorCounts(colorCountsMap);
setTotalBeadCount(totalCount);
setInitialGridColorKeys(new Set(Object.keys(colorCountsMap)));
// 根据mappedPixelData生成合成的originalImageSrc
const syntheticImageSrc = generateSyntheticImageFromPixelData(mappedPixelData, gridDimensions);
setOriginalImageSrc(syntheticImageSrc);
// 重置状态
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
// 设置格子数量为导入的尺寸,避免重新映射时尺寸被修改
setGranularity(gridDimensions.N);
setGranularityInput(gridDimensions.N.toString());
alert(`成功导入CSV文件图纸尺寸${gridDimensions.N}x${gridDimensions.M},共使用${Object.keys(colorCountsMap).length}种颜色。`);
})
.catch(error => {
console.error('CSV导入失败:', error);
alert(`CSV导入失败${error.message}`);
});
} else {
// 处理图片文件
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
setOriginalImageSrc(result);
setMappedPixelData(null);
setGridDimensions(null);
setColorCounts(null);
setTotalBeadCount(0);
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
// ++ 重置横轴格子数量为默认值 ++
const defaultGranularity = 100;
setGranularity(defaultGranularity);
setGranularityInput(defaultGranularity.toString());
setRemapTrigger(prev => prev + 1); // Trigger full remap for new image
};
reader.onerror = () => {
console.error("文件读取失败");
alert("无法读取文件。");
setInitialGridColorKeys(new Set()); // ++ 重置初始键 ++
}
reader.readAsDataURL(file);
// ++ Reset manual coloring mode when a new file is processed ++
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
}
};
// 处理一键擦除模式切换
const handleEraseToggle = () => {
// 确保在手动上色模式下才能使用擦除功能
if (!isManualColoringMode) {
return;
}
// 如果当前在颜色替换模式,先退出替换模式
if (colorReplaceState.isActive) {
setColorReplaceState({
isActive: false,
step: 'select-source'
});
setHighlightColorKey(null);
}
setIsEraseMode(!isEraseMode);
// 如果开启擦除模式,取消选中的颜色
if (!isEraseMode) {
setSelectedColor(null);
}
};
// ++ 新增:处理输入框变化的函数 ++
const handleGranularityInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setGranularityInput(event.target.value);
};
// ++ 添加:处理相似度输入框变化的函数 ++
const handleSimilarityThresholdInputChange = (event: ChangeEvent<HTMLInputElement>) => {
setSimilarityThresholdInput(event.target.value);
};
// ++ 修改:处理确认按钮点击的函数,同时处理两个参数 ++
const handleConfirmParameters = () => {
// 处理格子数
const minGranularity = 10;
const maxGranularity = 300;
let newGranularity = parseInt(granularityInput, 10);
if (isNaN(newGranularity) || newGranularity < minGranularity) {
newGranularity = minGranularity;
} else if (newGranularity > maxGranularity) {
newGranularity = maxGranularity;
}
// 处理相似度阈值
const minSimilarity = 0;
const maxSimilarity = 100;
let newSimilarity = parseInt(similarityThresholdInput, 10);
if (isNaN(newSimilarity) || newSimilarity < minSimilarity) {
newSimilarity = minSimilarity;
} else if (newSimilarity > maxSimilarity) {
newSimilarity = maxSimilarity;
}
// 检查值是否有变化
const granularityChanged = newGranularity !== granularity;
const similarityChanged = newSimilarity !== similarityThreshold;
if (granularityChanged) {
console.log(`Confirming new granularity: ${newGranularity}`);
setGranularity(newGranularity);
}
if (similarityChanged) {
console.log(`Confirming new similarity threshold: ${newSimilarity}`);
setSimilarityThreshold(newSimilarity);
}
// 只有在有值变化时才触发重映射
if (granularityChanged || similarityChanged) {
setRemapTrigger(prev => prev + 1);
// 退出手动上色模式
setIsManualColoringMode(false);
setSelectedColor(null);
}
// 始终同步输入框的值
setGranularityInput(newGranularity.toString());
setSimilarityThresholdInput(newSimilarity.toString());
};
// 添加像素化模式切换处理函数
const handlePixelationModeChange = (event: ChangeEvent<HTMLSelectElement>) => {
const newMode = event.target.value as PixelationMode;
if (Object.values(PixelationMode).includes(newMode)) {
setPixelationMode(newMode);
setRemapTrigger(prev => prev + 1); // 触发重新映射
setIsManualColoringMode(false); // 退出手动模式
setSelectedColor(null);
} else {
console.warn(`无效的像素化模式: ${newMode}`);
}
};
// 修改pixelateImage函数接收模式参数
const pixelateImage = (imageSrc: string, detailLevel: number, threshold: number, currentPalette: PaletteColor[], mode: PixelationMode) => {
console.log(`Attempting to pixelate with detail: ${detailLevel}, threshold: ${threshold}, mode: ${mode}`);
const originalCanvas = originalCanvasRef.current;
const pixelatedCanvas = pixelatedCanvasRef.current;
if (!originalCanvas || !pixelatedCanvas) { console.error("Canvas ref(s) not available."); return; }
const originalCtx = originalCanvas.getContext('2d', { willReadFrequently: true });
const pixelatedCtx = pixelatedCanvas.getContext('2d');
if (!originalCtx || !pixelatedCtx) { console.error("Canvas context(s) not found."); return; }
console.log("Canvas contexts obtained.");
if (currentPalette.length === 0) {
console.error("Cannot pixelate: The selected color palette is empty (likely due to exclusions).");
alert("错误:当前可用颜色板为空(可能所有颜色都被排除了),无法处理图像。请尝试恢复部分颜色。");
// Clear previous results visually
pixelatedCtx.clearRect(0, 0, pixelatedCanvas.width, pixelatedCanvas.height);
setMappedPixelData(null);
setGridDimensions(null);
// Keep colorCounts potentially showing the last valid counts? Or clear them too?
// setColorCounts(null); // Decide if clearing counts is desired when palette is empty
// setTotalBeadCount(0);
return; // Stop processing
}
const t1FallbackColor = currentPalette.find(p => p.key === 'T1')
|| currentPalette.find(p => p.hex.toUpperCase() === '#FFFFFF')
|| currentPalette[0]; // 使用第一个可用颜色作为备用
console.log("Using fallback color for empty cells:", t1FallbackColor);
const img = new window.Image();
img.onerror = (error: Event | string) => {
console.error("Image loading failed:", error);
alert("无法加载图片。");
setOriginalImageSrc(null);
setMappedPixelData(null);
setGridDimensions(null);
setColorCounts(null);
setInitialGridColorKeys(new Set());
};
img.onload = () => {
console.log("Image loaded successfully.");
const aspectRatio = img.height / img.width;
const N = detailLevel;
const M = Math.max(1, Math.round(N * aspectRatio));
if (N <= 0 || M <= 0) { console.error("Invalid grid dimensions:", { N, M }); return; }
console.log(`Grid size: ${N}x${M}`);
// 动态调整画布尺寸当格子数量大于100时增加画布尺寸以保持每个格子的可见性
const baseWidth = 500;
const minCellSize = 4; // 每个格子的最小尺寸(像素)
const recommendedCellSize = 6; // 推荐的格子尺寸(像素)
let outputWidth = baseWidth;
// 如果格子数量大于100计算需要的画布宽度
if (N > 100) {
const requiredWidthForMinSize = N * minCellSize;
const requiredWidthForRecommendedSize = N * recommendedCellSize;
// 使用推荐尺寸但不超过屏幕宽度的90%最大1200px
const maxWidth = Math.min(1200, window.innerWidth * 0.9);
outputWidth = Math.min(maxWidth, Math.max(baseWidth, requiredWidthForRecommendedSize));
// 确保不小于最小要求
outputWidth = Math.max(outputWidth, requiredWidthForMinSize);
console.log(`Large grid detected (${N} columns). Adjusted canvas width from ${baseWidth} to ${outputWidth}px (cell size: ${Math.round(outputWidth / N)}px)`);
}
const outputHeight = Math.round(outputWidth * aspectRatio);
// 在控制台提示用户画布尺寸变化
if (N > 100) {
console.log(`💡 由于格子数量较多 (${N}x${M}),画布已自动放大以保持清晰度。可以使用水平滚动查看完整图像。`);
}
originalCanvas.width = img.width; originalCanvas.height = img.height;
pixelatedCanvas.width = outputWidth; pixelatedCanvas.height = outputHeight;
console.log(`Canvas dimensions: Original ${img.width}x${img.height}, Output ${outputWidth}x${outputHeight}`);
originalCtx.drawImage(img, 0, 0, img.width, img.height);
console.log("Original image drawn.");
// 1. 使用calculatePixelGrid进行初始颜色映射
console.log("Starting initial color mapping using calculatePixelGrid...");
const initialMappedData = calculatePixelGrid(
originalCtx,
img.width,
img.height,
N,
M,
currentPalette,
mode,
t1FallbackColor
);
console.log(`Initial data mapping complete using mode ${mode}. Starting global color merging...`);
// --- 新的全局颜色合并逻辑 ---
const keyToRgbMap = new Map<string, RgbColor>();
const keyToColorDataMap = new Map<string, PaletteColor>();
currentPalette.forEach(p => {
keyToRgbMap.set(p.key, p.rgb);
keyToColorDataMap.set(p.key, p);
});
// 2. 统计初始颜色数量
const initialColorCounts: { [key: string]: number } = {};
initialMappedData.flat().forEach(cell => {
if (cell && cell.key && !cell.isExternal && cell.key !== TRANSPARENT_KEY) {
initialColorCounts[cell.key] = (initialColorCounts[cell.key] || 0) + 1;
}
});
console.log("Initial color counts:", initialColorCounts);
// 3. 创建一个颜色排序列表,按出现频率从高到低排序
const colorsByFrequency = Object.entries(initialColorCounts)
.sort((a, b) => b[1] - a[1]) // 按频率降序排序
.map(entry => entry[0]); // 只保留颜色键
if (colorsByFrequency.length === 0) {
console.log("No non-background colors found! Skipping merging.");
}
console.log("Colors sorted by frequency:", colorsByFrequency);
// 4. 复制初始数据,准备合并
const mergedData: MappedPixel[][] = initialMappedData.map(row =>
row.map(cell => ({ ...cell, isExternal: cell.isExternal ?? false }))
);
// 5. 处理相似颜色合并
const similarityThresholdValue = threshold;
// 已被合并(替换)的颜色集合
const replacedColors = new Set<string>();
// 对每个颜色按频率从高到低处理
for (let i = 0; i < colorsByFrequency.length; i++) {
const currentKey = colorsByFrequency[i];
// 如果当前颜色已经被合并到更频繁的颜色中,跳过
if (replacedColors.has(currentKey)) continue;
const currentRgb = keyToRgbMap.get(currentKey);
if (!currentRgb) {
console.warn(`RGB not found for key ${currentKey}. Skipping.`);
continue;
}
// 检查剩余的低频颜色
for (let j = i + 1; j < colorsByFrequency.length; j++) {
const lowerFreqKey = colorsByFrequency[j];
// 如果低频颜色已被替换,跳过
if (replacedColors.has(lowerFreqKey)) continue;
const lowerFreqRgb = keyToRgbMap.get(lowerFreqKey);
if (!lowerFreqRgb) {
console.warn(`RGB not found for key ${lowerFreqKey}. Skipping.`);
continue;
}
// 计算颜色距离
const dist = colorDistance(currentRgb, lowerFreqRgb);
// 如果距离小于阈值,将低频颜色替换为高频颜色
if (dist < similarityThresholdValue) {
console.log(`Merging color ${lowerFreqKey} into ${currentKey} (Distance: ${dist.toFixed(2)})`);
// 标记这个颜色已被替换
replacedColors.add(lowerFreqKey);
// 替换所有使用这个低频颜色的单元格
for (let r = 0; r < M; r++) {
for (let c = 0; c < N; c++) {
if (mergedData[r][c].key === lowerFreqKey) {
const colorData = keyToColorDataMap.get(currentKey);
if (colorData) {
mergedData[r][c] = {
key: currentKey,
color: colorData.hex,
isExternal: false
};
}
}
}
}
}
}
}
if (replacedColors.size > 0) {
console.log(`Merged ${replacedColors.size} less frequent similar colors into more frequent ones.`);
} else {
console.log("No colors were similar enough to merge.");
}
// --- 结束新的全局颜色合并逻辑 ---
// --- 绘制和状态更新 ---
if (pixelatedCanvasRef.current) {
setMappedPixelData(mergedData);
setGridDimensions({ N, M });
const counts: { [key: string]: { count: number; color: string } } = {};
let totalCount = 0;
mergedData.flat().forEach(cell => {
if (cell && cell.key && !cell.isExternal) {
// 使用hex值作为统计键值而不是色号
const hexKey = cell.color;
if (!counts[hexKey]) {
counts[hexKey] = { count: 0, color: cell.color };
}
counts[hexKey].count++;
totalCount++;
}
});
setColorCounts(counts);
setTotalBeadCount(totalCount);
setInitialGridColorKeys(new Set(Object.keys(counts)));
console.log("Color counts updated based on merged data (after merging):", counts);
console.log("Total bead count (total beads):", totalCount);
console.log("Stored initial grid color keys:", Object.keys(counts));
} else {
console.error("Pixelated canvas ref is null, skipping draw call in pixelateImage.");
}
}; // 正确闭合 img.onload 函数
console.log("Setting image source...");
img.src = imageSrc;
setIsManualColoringMode(false);
setSelectedColor(null);
}; // 正确闭合 pixelateImage 函数
// 修改useEffect中的pixelateImage调用加入模式参数
useEffect(() => {
if (originalImageSrc && activeBeadPalette.length > 0) {
const timeoutId = setTimeout(() => {
if (originalImageSrc && originalCanvasRef.current && pixelatedCanvasRef.current && activeBeadPalette.length > 0) {
console.log("useEffect triggered: Processing image due to src, granularity, threshold, palette selection, mode or remap trigger.");
pixelateImage(originalImageSrc, granularity, similarityThreshold, activeBeadPalette, pixelationMode);
} else {
console.warn("useEffect check failed inside timeout: Refs or active palette not ready/empty.");
}
}, 50);
return () => clearTimeout(timeoutId);
} else if (originalImageSrc && activeBeadPalette.length === 0) {
console.warn("Image selected, but the active palette is empty after exclusions. Cannot process. Clearing preview.");
const pixelatedCanvas = pixelatedCanvasRef.current;
const pixelatedCtx = pixelatedCanvas?.getContext('2d');
if (pixelatedCtx && pixelatedCanvas) {
pixelatedCtx.clearRect(0, 0, pixelatedCanvas.width, pixelatedCanvas.height);
// Draw a message on the canvas?
pixelatedCtx.fillStyle = '#6b7280'; // gray-500
pixelatedCtx.font = '16px sans-serif';
pixelatedCtx.textAlign = 'center';
pixelatedCtx.fillText('无可用颜色,请恢复部分排除的颜色', pixelatedCanvas.width / 2, pixelatedCanvas.height / 2);
}
setMappedPixelData(null);
setGridDimensions(null);
// Keep colorCounts to allow user to un-exclude colors
// setColorCounts(null);
// setTotalBeadCount(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originalImageSrc, granularity, similarityThreshold, customPaletteSelections, pixelationMode, remapTrigger]);
// 确保文件输入框引用在组件挂载后正确设置
useEffect(() => {
// 延迟执行确保DOM完全渲染
const timer = setTimeout(() => {
if (!fileInputRef.current) {
console.warn("文件输入框引用在组件挂载后仍为null这可能会导致上传功能异常");
}
}, 100);
return () => clearTimeout(timer);
}, []);
// 设置组件挂载状态
useEffect(() => {
setIsMounted(true);
}, []);
// 添加URL重定向检查
useEffect(() => {
// 检查是否在浏览器环境中
if (typeof window !== 'undefined') {
const currentUrl = window.location.href;
const currentHostname = window.location.hostname;
const targetDomain = 'https://perlerbeads.zippland.com/';
// 排除localhost和127.0.0.1等本地开发环境
const isLocalhost = currentHostname === 'localhost' ||
currentHostname === '127.0.0.1' ||
currentHostname.startsWith('192.168.') ||
currentHostname.startsWith('10.') ||
currentHostname.endsWith('.local');
// 检查当前URL是否不是目标域名且不是本地开发环境
if (!currentUrl.startsWith(targetDomain) && !isLocalhost) {
console.log(`当前URL: ${currentUrl}`);
console.log(`目标URL: ${targetDomain}`);
console.log('正在重定向到官方域名...');
// 保留当前路径和查询参数
const currentPath = window.location.pathname;
const currentSearch = window.location.search;
const currentHash = window.location.hash;
// 构建完整的目标URL
let redirectUrl = targetDomain;
// 如果不是根路径,添加路径
if (currentPath && currentPath !== '/') {
redirectUrl = redirectUrl.replace(/\/$/, '') + currentPath;
}
// 添加查询参数和哈希
redirectUrl += currentSearch + currentHash;
// 执行重定向
window.location.replace(redirectUrl);
} else if (isLocalhost) {
console.log(`检测到本地开发环境 (${currentHostname}),跳过重定向`);
}
}
}, []); // 只在组件首次挂载时执行
// --- Download function (ensure filename includes palette) ---
const handleDownloadRequest = (options?: GridDownloadOptions) => {
// 调用移动到utils/imageDownloader.ts中的downloadImage函数
downloadImage({
mappedPixelData,
gridDimensions,
colorCounts,
totalBeadCount,
options: options || downloadOptions,
activeBeadPalette,
selectedColorSystem
});
};
// --- Handler to toggle color exclusion ---
const handleToggleExcludeColor = (hexKey: string) => {
const currentExcluded = excludedColorKeys;
const isExcluding = !currentExcluded.has(hexKey);
if (isExcluding) {
console.log(`---------\nAttempting to EXCLUDE color: ${hexKey}`);
// --- 确保初始颜色键已记录 ---
if (initialGridColorKeys.size === 0) {
console.error("Cannot exclude color: Initial grid color keys not yet calculated.");
alert("无法排除颜色,初始颜色数据尚未准备好,请稍候。");
return;
}
console.log("Initial Grid Hex Keys:", Array.from(initialGridColorKeys));
console.log("Currently Excluded Hex Keys (before this op):", Array.from(currentExcluded));
const nextExcludedKeys = new Set(currentExcluded);
nextExcludedKeys.add(hexKey);
// --- 使用初始颜色键进行重映射目标逻辑 ---
// 1. 从初始网格颜色集合开始hex值
const potentialRemapHexKeys = new Set(initialGridColorKeys);
console.log("Step 1: Potential Hex Keys (from initial):", Array.from(potentialRemapHexKeys));
// 2. 移除当前要排除的hex键
potentialRemapHexKeys.delete(hexKey);
console.log(`Step 2: Potential Hex Keys (after removing ${hexKey}):`, Array.from(potentialRemapHexKeys));
// 3. 移除任何*其他*当前也被排除的hex键
currentExcluded.forEach(excludedHexKey => {
potentialRemapHexKeys.delete(excludedHexKey);
});
console.log("Step 3: Potential Hex Keys (after removing other current exclusions):", Array.from(potentialRemapHexKeys));
// 4. 基于剩余的hex值创建重映射调色板
const remapTargetPalette = fullBeadPalette.filter(color => potentialRemapHexKeys.has(color.hex.toUpperCase()));
const remapTargetHexKeys = remapTargetPalette.map(p => p.hex.toUpperCase());
console.log("Step 4: Remap Target Palette Hex Keys:", remapTargetHexKeys);
// 5. *** 关键检查 ***:如果在考虑所有排除项后,没有*初始*颜色可供映射,则阻止此次排除
if (remapTargetPalette.length === 0) {
console.warn(`Cannot exclude color '${hexKey}'. No other valid colors from the initial grid remain after considering all current exclusions.`);
alert(`无法排除颜色 ${hexKey},因为图中最初存在的其他可用颜色也已被排除。请先恢复部分其他颜色。`);
console.log("---------");
return; // 停止排除过程
}
console.log(`Remapping target palette (based on initial grid colors minus all exclusions) contains ${remapTargetPalette.length} colors.`);
// 查找被排除颜色的RGB值用于重映射
const excludedColorData = fullBeadPalette.find(p => p.hex.toUpperCase() === hexKey);
// 检查排除颜色的数据是否存在
if (!excludedColorData || !mappedPixelData || !gridDimensions) {
console.error("Cannot exclude color: Missing data for remapping.");
alert("无法排除颜色,缺少必要数据。");
console.log("---------");
return;
}
console.log(`Remapping cells currently using excluded color: ${hexKey}`);
// 仅在需要重映射时创建深拷贝
const newMappedData = mappedPixelData.map(row => row.map(cell => ({...cell})));
let remappedCount = 0;
const { N, M } = gridDimensions;
let firstReplacementHex: string | null = null;
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const cell = newMappedData[j]?.[i];
// 此条件正确地仅针对具有排除hex值的单元格
if (cell && !cell.isExternal && cell.color.toUpperCase() === hexKey) {
// *** 使用派生的 remapTargetPalette 查找最接近的颜色 ***
const replacementColor = findClosestPaletteColor(excludedColorData.rgb, remapTargetPalette);
if (!firstReplacementHex) firstReplacementHex = replacementColor.hex;
newMappedData[j][i] = {
...cell,
key: replacementColor.key,
color: replacementColor.hex
};
remappedCount++;
}
}
}
console.log(`Remapped ${remappedCount} cells. First replacement hex found was: ${firstReplacementHex || 'N/A'}`);
// 同时更新状态
setExcludedColorKeys(nextExcludedKeys); // 应用此颜色的排除
setMappedPixelData(newMappedData); // 使用重映射的数据更新
// 基于*新*映射数据重新计算计数以hex为键
const newCounts: { [hexKey: string]: { count: number; color: string } } = {};
let newTotalCount = 0;
newMappedData.flat().forEach(cell => {
if (cell && cell.color && !cell.isExternal) {
const cellHex = cell.color.toUpperCase();
if (!newCounts[cellHex]) {
newCounts[cellHex] = { count: 0, color: cellHex };
}
newCounts[cellHex].count++;
newTotalCount++;
}
});
setColorCounts(newCounts);
setTotalBeadCount(newTotalCount);
console.log("State updated after exclusion and local remap based on initial grid colors.");
console.log("---------");
// ++ 在更新状态后,重新绘制 Canvas ++
if (pixelatedCanvasRef.current && gridDimensions) {
setMappedPixelData(newMappedData);
// 不要调用 setGridDimensions因为颜色排除不需要改变网格尺寸
} else {
console.error("Canvas ref or grid dimensions missing, skipping draw call in handleToggleExcludeColor.");
}
} else {
// --- Re-including ---
console.log(`---------\nAttempting to RE-INCLUDE color: ${hexKey}`);
console.log(`Re-including color: ${hexKey}. Triggering full remap.`);
const nextExcludedKeys = new Set(currentExcluded);
nextExcludedKeys.delete(hexKey);
setExcludedColorKeys(nextExcludedKeys);
// 此处无需重置 initialGridColorKeys完全重映射会通过 pixelateImage 重新计算它
setRemapTrigger(prev => prev + 1); // *** KEPT setRemapTrigger here for re-inclusion ***
console.log("---------");
}
// ++ Exit manual mode if colors are excluded/included ++
setIsManualColoringMode(false);
setSelectedColor(null);
};
// 一键去背景:识别边缘主色并洪水填充去除
const handleAutoRemoveBackground = () => {
if (!mappedPixelData || !gridDimensions) {
alert('请先生成图纸后再使用一键去背景。');
return;
}
const { N, M } = gridDimensions;
const borderCounts = new Map<string, number>();
const countBorderCell = (row: number, col: number) => {
const cell = mappedPixelData[row]?.[col];
if (!cell || cell.isExternal || cell.key === TRANSPARENT_KEY) return;
borderCounts.set(cell.key, (borderCounts.get(cell.key) || 0) + 1);
};
for (let col = 0; col < N; col++) {
countBorderCell(0, col);
if (M > 1) countBorderCell(M - 1, col);
}
for (let row = 1; row < M - 1; row++) {
countBorderCell(row, 0);
if (N > 1) countBorderCell(row, N - 1);
}
if (borderCounts.size === 0) {
alert('边缘没有可识别的背景颜色。');
return;
}
let targetKey = '';
let maxCount = -1;
borderCounts.forEach((count, key) => {
if (count > maxCount) {
maxCount = count;
targetKey = key;
}
});
const newPixelData = mappedPixelData.map(row => row.map(cell => ({ ...cell })));
const visited = Array(M).fill(null).map(() => Array(N).fill(false));
const stack: { row: number; col: number }[] = [];
const pushIfTarget = (row: number, col: number) => {
if (row < 0 || row >= M || col < 0 || col >= N || visited[row][col]) {
return;
}
const cell = newPixelData[row][col];
if (!cell || cell.isExternal || cell.key !== targetKey) return;
visited[row][col] = true;
stack.push({ row, col });
};
for (let col = 0; col < N; col++) {
pushIfTarget(0, col);
if (M > 1) pushIfTarget(M - 1, col);
}
for (let row = 1; row < M - 1; row++) {
pushIfTarget(row, 0);
if (N > 1) pushIfTarget(row, N - 1);
}
if (stack.length === 0) {
alert('未找到可去除的背景区域。');
return;
}
while (stack.length > 0) {
const { row, col } = stack.pop()!;
newPixelData[row][col] = { ...transparentColorData };
pushIfTarget(row - 1, col);
pushIfTarget(row + 1, col);
pushIfTarget(row, col - 1);
pushIfTarget(row, col + 1);
}
setMappedPixelData(newPixelData);
const newColorCounts: { [hexKey: string]: { count: number; color: string } } = {};
let newTotalCount = 0;
newPixelData.flat().forEach(cell => {
if (cell && !cell.isExternal && cell.key !== TRANSPARENT_KEY) {
const cellHex = cell.color.toUpperCase();
if (!newColorCounts[cellHex]) {
newColorCounts[cellHex] = {
count: 0,
color: cellHex
};
}
newColorCounts[cellHex].count++;
newTotalCount++;
}
});
setColorCounts(newColorCounts);
setTotalBeadCount(newTotalCount);
setInitialGridColorKeys(new Set(Object.keys(newColorCounts)));
};
// --- Tooltip Logic ---
// --- Canvas Interaction ---
// 洪水填充擦除函数
const floodFillErase = (startRow: number, startCol: number, targetKey: string) => {
if (!mappedPixelData || !gridDimensions) return;
const { N, M } = gridDimensions;
const newPixelData = mappedPixelData.map(row => row.map(cell => ({ ...cell })));
const visited = Array(M).fill(null).map(() => Array(N).fill(false));
// 使用栈实现非递归洪水填充
const stack = [{ row: startRow, col: startCol }];
while (stack.length > 0) {
const { row, col } = stack.pop()!;
// 检查边界
if (row < 0 || row >= M || col < 0 || col >= N || visited[row][col]) {
continue;
}
const currentCell = newPixelData[row][col];
// 检查是否是目标颜色且不是外部区域
if (!currentCell || currentCell.isExternal || currentCell.key !== targetKey) {
continue;
}
// 标记为已访问
visited[row][col] = true;
// 擦除当前像素(设为透明)
newPixelData[row][col] = { ...transparentColorData };
// 添加相邻像素到栈中
stack.push(
{ row: row - 1, col }, // 上
{ row: row + 1, col }, // 下
{ row, col: col - 1 }, // 左
{ row, col: col + 1 } // 右
);
}
// 更新状态
setMappedPixelData(newPixelData);
// 重新计算颜色统计
if (colorCounts) {
const newColorCounts: { [hexKey: string]: { count: number; color: string } } = {};
let newTotalCount = 0;
newPixelData.flat().forEach(cell => {
if (cell && !cell.isExternal && cell.key !== TRANSPARENT_KEY) {
const cellHex = cell.color.toUpperCase();
if (!newColorCounts[cellHex]) {
newColorCounts[cellHex] = {
count: 0,
color: cellHex
};
}
newColorCounts[cellHex].count++;
newTotalCount++;
}
});
setColorCounts(newColorCounts);
setTotalBeadCount(newTotalCount);
}
};
// ++ Re-introduce the combined interaction handler ++
const handleCanvasInteraction = (
clientX: number,
clientY: number,
pageX: number,
pageY: number,
isClick: boolean = false,
isTouchEnd: boolean = false
) => {
// 如果是触摸结束或鼠标离开事件,隐藏提示
if (isTouchEnd) {
setTooltipData(null);
return;
}
const canvas = pixelatedCanvasRef.current;
if (!canvas || !mappedPixelData || !gridDimensions) {
setTooltipData(null);
return;
}
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const canvasX = (clientX - rect.left) * scaleX;
const canvasY = (clientY - rect.top) * scaleY;
const { N, M } = gridDimensions;
const cellWidthOutput = canvas.width / N;
const cellHeightOutput = canvas.height / M;
const i = Math.floor(canvasX / cellWidthOutput);
const j = Math.floor(canvasY / cellHeightOutput);
if (i >= 0 && i < N && j >= 0 && j < M) {
const cellData = mappedPixelData[j][i];
// 颜色替换模式逻辑 - 选择源颜色
if (isClick && colorReplaceState.isActive && colorReplaceState.step === 'select-source') {
if (cellData && !cellData.isExternal && cellData.key && cellData.key !== TRANSPARENT_KEY) {
// 执行选择源颜色
handleCanvasColorSelect({
key: cellData.key,
color: cellData.color
});
setTooltipData(null);
}
return;
}
// 一键擦除模式逻辑
if (isClick && isEraseMode) {
if (cellData && !cellData.isExternal && cellData.key && cellData.key !== TRANSPARENT_KEY) {
// 执行洪水填充擦除
floodFillErase(j, i, cellData.key);
setIsEraseMode(false); // 擦除完成后退出擦除模式
setTooltipData(null);
}
return;
}
// Manual Coloring Logic - 保持原有的上色逻辑
if (isClick && isManualColoringMode && selectedColor) {
// 手动上色模式逻辑保持不变
// ...现有代码...
const newPixelData = mappedPixelData.map(row => row.map(cell => ({ ...cell })));
const currentCell = newPixelData[j]?.[i];
if (!currentCell) return;
const previousKey = currentCell.key;
const wasExternal = currentCell.isExternal;
let newCellData: MappedPixel;
if (selectedColor.key === TRANSPARENT_KEY) {
newCellData = { ...transparentColorData };
} else {
newCellData = { ...selectedColor, isExternal: false };
}
// Only update if state changes
if (newCellData.key !== previousKey || newCellData.isExternal !== wasExternal) {
newPixelData[j][i] = newCellData;
setMappedPixelData(newPixelData);
// Update color counts
if (colorCounts) {
const newColorCounts = { ...colorCounts };
let newTotalCount = totalBeadCount;
// 处理之前颜色的减少使用hex值
if (!wasExternal && previousKey !== TRANSPARENT_KEY) {
const previousCell = mappedPixelData[j][i];
const previousHex = previousCell?.color?.toUpperCase();
if (previousHex && newColorCounts[previousHex]) {
newColorCounts[previousHex].count--;
if (newColorCounts[previousHex].count <= 0) {
delete newColorCounts[previousHex];
}
newTotalCount--;
}
}
// 处理新颜色的增加使用hex值
if (!newCellData.isExternal && newCellData.key !== TRANSPARENT_KEY) {
const newHex = newCellData.color.toUpperCase();
if (!newColorCounts[newHex]) {
newColorCounts[newHex] = {
count: 0,
color: newHex
};
}
newColorCounts[newHex].count++;
newTotalCount++;
}
setColorCounts(newColorCounts);
setTotalBeadCount(newTotalCount);
}
}
// 上色操作后隐藏提示
setTooltipData(null);
}
// Tooltip Logic (非手动上色模式点击或悬停)
else if (!isManualColoringMode) {
// 只有单元格实际有内容(非背景/外部区域)才会显示提示
if (cellData && !cellData.isExternal && cellData.key) {
// 检查是否已经显示了提示框,并且是否点击的是同一个位置
// 对于移动设备,位置可能有细微偏差,所以我们检查单元格索引而不是具体坐标
if (tooltipData) {
// 如果已经有提示框,计算当前提示框对应的格子的索引
const tooltipRect = canvas.getBoundingClientRect();
// 还原提示框位置为相对于canvas的坐标
const prevX = tooltipData.x; // 页面X坐标
const prevY = tooltipData.y; // 页面Y坐标
// 转换为相对于canvas的坐标
const prevCanvasX = (prevX - tooltipRect.left) * scaleX;
const prevCanvasY = (prevY - tooltipRect.top) * scaleY;
// 计算之前显示提示框位置对应的网格索引
const prevCellI = Math.floor(prevCanvasX / cellWidthOutput);
const prevCellJ = Math.floor(prevCanvasY / cellHeightOutput);
// 如果点击的是同一个格子则切换tooltip的显示/隐藏状态
if (i === prevCellI && j === prevCellJ) {
setTooltipData(null); // 隐藏提示
return;
}
}
// 计算相对于main元素的位置
const mainElement = mainRef.current;
if (mainElement) {
const mainRect = mainElement.getBoundingClientRect();
// 计算相对于main元素的坐标
const relativeX = pageX - mainRect.left - window.scrollX;
const relativeY = pageY - mainRect.top - window.scrollY;
// 如果是移动/悬停到一个新的有效格子,或者点击了不同的格子,则显示提示
setTooltipData({
x: relativeX,
y: relativeY,
key: cellData.key,
color: cellData.color,
});
} else {
// 如果没有找到main元素使用原始坐标
setTooltipData({
x: pageX,
y: pageY,
key: cellData.key,
color: cellData.color,
});
}
} else {
// 如果点击/悬停在外部区域或背景上,隐藏提示
setTooltipData(null);
}
}
} else {
// 如果点击/悬停在画布外部,隐藏提示
setTooltipData(null);
}
};
// 处理自定义色板中单个颜色的选择变化
const handleSelectionChange = (hexValue: string, isSelected: boolean) => {
const normalizedHex = hexValue.toUpperCase();
setCustomPaletteSelections(prev => ({
...prev,
[normalizedHex]: isSelected
}));
setIsCustomPalette(true);
};
// 保存自定义色板并应用
const handleSaveCustomPalette = () => {
savePaletteSelections(customPaletteSelections);
setIsCustomPalette(true);
setIsCustomPaletteEditorOpen(false);
// 触发图像重新处理
setRemapTrigger(prev => prev + 1);
// 退出手动上色模式
setIsManualColoringMode(false);
setSelectedColor(null);
setIsEraseMode(false);
};
// ++ 新增:导出自定义色板配置 ++
const handleExportCustomPalette = () => {
const selectedHexValues = Object.entries(customPaletteSelections)
.filter(([, isSelected]) => isSelected)
.map(([hexValue]) => hexValue);
if (selectedHexValues.length === 0) {
alert("当前没有选中的颜色,无法导出。");
return;
}
// 导出格式仅基于hex值
const exportData = {
version: "3.0", // 新版本号
selectedHexValues: selectedHexValues,
exportDate: new Date().toISOString(),
totalColors: selectedHexValues.length
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'custom-perler-palette.json';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
// ++ 新增:处理导入的色板文件 ++
const handleImportPaletteFile = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const data = JSON.parse(content);
// 检查文件格式
if (!Array.isArray(data.selectedHexValues)) {
throw new Error("无效的文件格式:文件必须包含 'selectedHexValues' 数组。");
}
console.log("检测到基于hex值的色板文件");
const importedHexValues = data.selectedHexValues as string[];
const validHexValues: string[] = [];
const invalidHexValues: string[] = [];
// 验证hex值
importedHexValues.forEach(hex => {
const normalizedHex = hex.toUpperCase();
const colorData = fullBeadPalette.find(color => color.hex.toUpperCase() === normalizedHex);
if (colorData) {
validHexValues.push(normalizedHex);
} else {
invalidHexValues.push(hex);
}
});
if (invalidHexValues.length > 0) {
console.warn("导入时发现无效的hex值:", invalidHexValues);
alert(`导入完成,但以下颜色无效已被忽略:\n${invalidHexValues.join(', ')}`);
}
if (validHexValues.length === 0) {
alert("导入的文件中不包含任何有效的颜色。");
return;
}
console.log(`成功验证 ${validHexValues.length} 个有效的hex值`);
// 基于有效的hex值创建新的selections对象
const allHexValues = fullBeadPalette.map(color => color.hex.toUpperCase());
const newSelections = presetToSelections(allHexValues, validHexValues);
setCustomPaletteSelections(newSelections);
setIsCustomPalette(true); // 标记为自定义
alert(`成功导入 ${validHexValues.length} 个颜色!`);
} catch (error) {
console.error("导入色板配置失败:", error);
alert(`导入失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
// 重置文件输入,以便可以再次导入相同的文件
if (event.target) {
event.target.value = '';
}
}
};
reader.onerror = () => {
alert("读取文件失败。");
// 重置文件输入
if (event.target) {
event.target.value = '';
}
};
reader.readAsText(file);
};
// ++ 新增:触发导入文件选择 ++
const triggerImportPalette = () => {
importPaletteInputRef.current?.click();
};
// 新增:处理颜色高亮
const handleHighlightColor = (colorHex: string) => {
setHighlightColorKey(colorHex);
};
// 新增:高亮完成回调
const handleHighlightComplete = () => {
setHighlightColorKey(null);
};
// 新增:切换完整色板显示
const handleToggleFullPalette = () => {
setShowFullPalette(!showFullPalette);
};
// 新增:处理颜色选择,同时管理模式切换
const handleColorSelect = (colorData: { key: string; color: string; isExternal?: boolean }) => {
// 如果选择的是橡皮擦(透明色)且当前在颜色替换模式,退出替换模式
if (colorData.key === TRANSPARENT_KEY && colorReplaceState.isActive) {
setColorReplaceState({
isActive: false,
step: 'select-source'
});
setHighlightColorKey(null);
}
// 选择任何颜色(包括橡皮擦)时,都应该退出一键擦除模式
if (isEraseMode) {
setIsEraseMode(false);
}
// 设置选中的颜色
setSelectedColor(colorData);
};
// 新增:颜色替换相关处理函数
const handleColorReplaceToggle = () => {
setColorReplaceState(prev => {
if (prev.isActive) {
// 退出替换模式
return {
isActive: false,
step: 'select-source'
};
} else {
// 进入替换模式
// 只退出冲突的模式,但保持在手动上色模式下
setIsEraseMode(false);
setSelectedColor(null);
return {
isActive: true,
step: 'select-source'
};
}
});
};
// 新增:处理从画布选择源颜色
const handleCanvasColorSelect = (colorData: { key: string; color: string }) => {
if (colorReplaceState.isActive && colorReplaceState.step === 'select-source') {
// 高亮显示选中的颜色
setHighlightColorKey(colorData.color);
// 进入第二步:选择目标颜色
setColorReplaceState({
isActive: true,
step: 'select-target',
sourceColor: colorData
});
}
};
// 新增:执行颜色替换
const handleColorReplace = (sourceColor: { key: string; color: string }, targetColor: { key: string; color: string }) => {
if (!mappedPixelData || !gridDimensions) return;
const { N, M } = gridDimensions;
const newPixelData = mappedPixelData.map(row => row.map(cell => ({ ...cell })));
let replaceCount = 0;
// 遍历所有像素,替换匹配的颜色
for (let j = 0; j < M; j++) {
for (let i = 0; i < N; i++) {
const currentCell = newPixelData[j][i];
if (currentCell && !currentCell.isExternal &&
currentCell.color.toUpperCase() === sourceColor.color.toUpperCase()) {
// 替换颜色
newPixelData[j][i] = {
key: targetColor.key,
color: targetColor.color,
isExternal: false
};
replaceCount++;
}
}
}
if (replaceCount > 0) {
// 更新像素数据
setMappedPixelData(newPixelData);
// 重新计算颜色统计
if (colorCounts) {
const newColorCounts: { [hexKey: string]: { count: number; color: string } } = {};
let newTotalCount = 0;
newPixelData.flat().forEach(cell => {
if (cell && !cell.isExternal && cell.key !== TRANSPARENT_KEY) {
const cellHex = cell.color.toUpperCase();
if (!newColorCounts[cellHex]) {
newColorCounts[cellHex] = {
count: 0,
color: cellHex
};
}
newColorCounts[cellHex].count++;
newTotalCount++;
}
});
setColorCounts(newColorCounts);
setTotalBeadCount(newTotalCount);
}
console.log(`颜色替换完成:将 ${replaceCount}${sourceColor.key} 替换为 ${targetColor.key}`);
}
// 退出替换模式
setColorReplaceState({
isActive: false,
step: 'select-source'
});
// 清除高亮
setHighlightColorKey(null);
};
// 生成完整色板数据(用户自定义色板中选中的所有颜色)
const fullPaletteColors = useMemo(() => {
const selectedColors: { key: string; color: string }[] = [];
Object.entries(customPaletteSelections).forEach(([hexValue, isSelected]) => {
if (isSelected) {
// 根据选择的色号系统获取显示的色号
const displayKey = getColorKeyByHex(hexValue, selectedColorSystem);
selectedColors.push({
key: displayKey,
color: hexValue
});
}
});
// 使用色相排序而不是色号排序
return sortColorsByHue(selectedColors);
}, [customPaletteSelections, selectedColorSystem]);
return (
<>
{/* 添加自定义动画样式 */}
<style dangerouslySetInnerHTML={{ __html: floatAnimation }} />
{/* PWA 安装按钮 */}
<InstallPWA />
{/* ++ 修改:添加 onLoad 回调函数 ++ */}
<Script
async
src="//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js"
strategy="lazyOnload"
onLoad={() => {
const basePV = 378536; // ++ 预设 PV 基数 ++
const baseUV = 257864; // ++ 预设 UV 基数 ++
const updateCount = (spanId: string, baseValue: number) => {
const targetNode = document.getElementById(spanId);
if (!targetNode) return;
const observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
const currentValueText = targetNode.textContent?.trim() || '0';
if (currentValueText !== '...') {
const currentValue = parseInt(currentValueText.replace(/,/g, ''), 10) || 0;
targetNode.textContent = (currentValue + baseValue).toLocaleString();
observer.disconnect(); // ++ 更新后停止观察 ++
// console.log(`Updated ${spanId} from ${currentValueText} to ${targetNode.textContent}`);
break; // 处理完第一个有效更新即可
}
}
}
});
observer.observe(targetNode, { childList: true, characterData: true, subtree: true });
// ++ 处理初始值已经是数字的情况 (如果脚本加载很快) ++
const initialValueText = targetNode.textContent?.trim() || '0';
if (initialValueText !== '...') {
const initialValue = parseInt(initialValueText.replace(/,/g, ''), 10) || 0;
targetNode.textContent = (initialValue + baseValue).toLocaleString();
observer.disconnect(); // 已更新,无需再观察
}
};
updateCount('busuanzi_value_site_pv', basePV);
updateCount('busuanzi_value_site_uv', baseUV);
}}
/>
{/* Apply dark mode styles to the main container */}
<div className="min-h-screen p-4 sm:p-6 flex flex-col items-center bg-gradient-to-b from-gray-50 to-white dark:from-gray-800 dark:to-gray-900 font-[family-name:var(--font-geist-sans)] overflow-x-hidden">
{/* Apply dark mode styles to the header */}
<header className="w-full md:max-w-4xl text-center mt-6 mb-8 sm:mt-8 sm:mb-10 relative overflow-hidden">
{/* Adjust decorative background colors for dark mode */}
<div className="absolute top-0 left-0 w-48 h-48 bg-blue-100 dark:bg-blue-900 rounded-full opacity-30 dark:opacity-20 blur-3xl"></div>
<div className="absolute bottom-0 right-0 w-48 h-48 bg-pink-100 dark:bg-pink-900 rounded-full opacity-30 dark:opacity-20 blur-3xl"></div>
{/* Adjust decorative dots color */}
<div className="absolute top-0 right-0 grid grid-cols-5 gap-1 opacity-20 dark:opacity-10">
{[...Array(25)].map((_, i) => (
<div key={i} className="w-1.5 h-1.5 rounded-full bg-gray-400 dark:bg-gray-600"></div>
))}
</div>
<div className="absolute bottom-0 left-0 grid grid-cols-5 gap-1 opacity-20 dark:opacity-10">
{[...Array(25)].map((_, i) => (
<div key={i} className="w-1.5 h-1.5 rounded-full bg-gray-400 dark:bg-gray-600"></div>
))}
</div>
{/* Header content - Ultra fancy integrated logo and titles */}
<div className="relative z-10 py-8">
{/* Integrated super fancy logo and title container */}
<div className="relative flex flex-col items-center">
{/* Ultra cute hyper-detailed 16-bead icon */}
<div className="relative mb-6 animate-float">
<div className="relative grid grid-cols-4 gap-2 p-4 bg-white/95 dark:bg-gray-800/95 rounded-3xl shadow-2xl border-4 border-gradient-to-r from-pink-300 via-purple-300 to-blue-300 dark:border-gray-600">
{['bg-red-400', 'bg-blue-400', 'bg-yellow-400', 'bg-green-400',
'bg-purple-400', 'bg-pink-400', 'bg-orange-400', 'bg-teal-400',
'bg-indigo-400', 'bg-cyan-400', 'bg-lime-400', 'bg-amber-400',
'bg-rose-400', 'bg-sky-400', 'bg-emerald-400', 'bg-violet-400'].map((color, i) => (
<div key={i} className="relative">
<div
className={`w-5 h-5 rounded-full ${color} transition-all duration-500 hover:scale-150 shadow-xl hover:shadow-2xl relative z-10`}
style={{
animation: `float ${2 + (i % 3)}s ease-in-out infinite ${i * 0.1}s`,
boxShadow: `0 0 20px ${color.includes('red') ? '#f87171' : color.includes('blue') ? '#60a5fa' : color.includes('yellow') ? '#fbbf24' : color.includes('green') ? '#4ade80' : color.includes('purple') ? '#a855f7' : color.includes('pink') ? '#f472b6' : color.includes('orange') ? '#fb923c' : color.includes('teal') ? '#2dd4bf' : color.includes('indigo') ? '#818cf8' : color.includes('cyan') ? '#22d3ee' : color.includes('lime') ? '#84cc16' : color.includes('amber') ? '#f59e0b' : color.includes('rose') ? '#fb7185' : color.includes('sky') ? '#0ea5e9' : color.includes('emerald') ? '#10b981' : '#8b5cf6'}70`
}}
></div>
{/* Mini decorations around each bead */}
{i % 4 === 0 && <div className="absolute -top-0.5 -right-0.5 w-1 h-1 bg-yellow-300 rounded-full animate-ping"></div>}
{i % 4 === 1 && <div className="absolute -bottom-0.5 -left-0.5 w-0.5 h-0.5 bg-pink-300 rounded-full animate-pulse"></div>}
{i % 4 === 2 && <div className="absolute -top-0.5 -left-0.5 w-0.5 h-0.5 bg-blue-300 rounded-full animate-bounce"></div>}
{i % 4 === 3 && <div className="absolute -bottom-0.5 -right-0.5 w-1 h-1 bg-purple-300 rounded-full animate-spin"></div>}
</div>
))}
</div>
{/* Super cute decorations around the icon */}
<div className="absolute -top-3 -right-4 w-3 h-3 bg-gradient-to-br from-yellow-400 to-pink-500 rounded-full animate-ping transform rotate-12"></div>
<div className="absolute -top-1 -right-2 w-2 h-2 bg-gradient-to-br from-pink-400 to-purple-500 rotate-45 animate-spin"></div>
<div className="absolute -bottom-3 -left-4 w-2.5 h-2.5 bg-gradient-to-br from-blue-400 to-cyan-500 rounded-full animate-bounce"></div>
<div className="absolute -bottom-1 -left-2 w-1.5 h-1.5 bg-gradient-to-br from-green-400 to-teal-500 rotate-45 animate-pulse"></div>
<div className="absolute top-0 -right-1 w-1 h-1 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full animate-pulse delay-100"></div>
<div className="absolute -top-2 left-2 w-1 h-1 bg-gradient-to-br from-orange-400 to-red-500 rounded-full animate-bounce delay-200"></div>
<div className="absolute bottom-1 -right-3 w-1.5 h-1.5 bg-gradient-to-br from-indigo-400 to-purple-500 rotate-45 animate-spin delay-300"></div>
<div className="absolute -bottom-2 right-1 w-0.5 h-0.5 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full animate-ping delay-400"></div>
{/* Extra tiny sparkles */}
<div className="absolute -top-4 left-1 w-0.5 h-0.5 bg-yellow-300 rounded-full animate-pulse delay-500"></div>
<div className="absolute top-2 -left-4 w-0.5 h-0.5 bg-pink-300 rounded-full animate-bounce delay-600"></div>
<div className="absolute -bottom-4 right-2 w-0.5 h-0.5 bg-blue-300 rounded-full animate-ping delay-700"></div>
<div className="absolute bottom-2 -right-5 w-0.5 h-0.5 bg-purple-300 rounded-full animate-pulse delay-800"></div>
</div>
{/* Ultra fancy brand name and tool name with hyper cute decorations */}
<div className="relative flex flex-col items-center space-y-3">
{/* Brand name - 七卡瓦 with ultra fancy effects */}
<div className="relative">
<h1 className="relative text-4xl sm:text-6xl font-black text-transparent bg-clip-text bg-gradient-to-r from-pink-500 via-purple-500 via-blue-500 to-cyan-400 tracking-wider drop-shadow-2xl transform hover:scale-105 transition-transform duration-300 animate-bounce">
</h1>
{/* Super fancy geometric decorations */}
<div className="absolute -top-4 -right-5 w-4 h-4 bg-gradient-to-br from-yellow-400 to-pink-500 rounded-full animate-spin transform rotate-12"></div>
<div className="absolute -top-2 -right-2 w-2.5 h-2.5 bg-gradient-to-br from-pink-400 to-purple-500 rounded-full animate-ping"></div>
<div className="absolute -top-1 -right-0.5 w-1.5 h-1.5 bg-gradient-to-br from-purple-400 to-blue-500 rotate-45 animate-pulse delay-100"></div>
<div className="absolute -bottom-3 -left-5 w-4 h-4 bg-gradient-to-br from-blue-400 to-purple-500 rotate-45 animate-bounce delay-200"></div>
<div className="absolute -bottom-1 -left-2 w-2 h-2 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full animate-spin delay-300"></div>
<div className="absolute top-0 left-1/2 w-1.5 h-1.5 bg-gradient-to-br from-purple-400 to-pink-500 rounded-full animate-pulse delay-400"></div>
<div className="absolute -bottom-4 -right-3 w-3 h-3 bg-gradient-to-br from-cyan-400 to-teal-500 rounded-full animate-bounce delay-500"></div>
<div className="absolute top-1 -left-4 w-2 h-2 bg-gradient-to-br from-pink-400 to-red-500 rotate-45 animate-ping delay-600"></div>
{/* Extra tiny sparkles around brand name */}
<div className="absolute -top-3 left-0 w-1 h-1 bg-yellow-300 rounded-full animate-pulse delay-700"></div>
<div className="absolute -top-2 right-3 w-0.5 h-0.5 bg-pink-300 rounded-full animate-bounce delay-800"></div>
<div className="absolute bottom-0 -left-1 w-0.5 h-0.5 bg-blue-300 rounded-full animate-ping delay-900"></div>
<div className="absolute bottom-1 right-0 w-1 h-1 bg-purple-300 rounded-full animate-pulse delay-1000"></div>
</div>
{/* Tool name - 拼豆底稿生成器 with hyper cute style */}
<div className="relative">
<h2 className="relative text-xl sm:text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-500 via-teal-500 via-green-500 to-emerald-400 tracking-widest transform hover:scale-102 transition-all duration-300">
稿
</h2>
{/* Super cute geometric shapes */}
<div className="absolute -top-3 -left-6 w-3.5 h-3.5 bg-gradient-to-br from-blue-400 to-teal-500 rounded-full animate-bounce delay-75"></div>
<div className="absolute -top-1 -left-3 w-2 h-2 bg-gradient-to-br from-teal-400 to-green-500 rounded-full animate-ping delay-150"></div>
<div className="absolute -top-0.5 -left-1 w-1 h-1 bg-gradient-to-br from-green-400 to-emerald-500 rotate-45 animate-pulse delay-225"></div>
<div className="absolute -top-3 -right-6 w-3 h-3 bg-gradient-to-br from-green-400 to-emerald-500 rotate-45 animate-spin delay-300"></div>
<div className="absolute -top-1 -right-3 w-1.5 h-1.5 bg-gradient-to-br from-emerald-400 to-cyan-500 rounded-full animate-bounce delay-375"></div>
<div className="absolute -bottom-2 -right-3 w-2.5 h-2.5 bg-gradient-to-br from-emerald-400 to-teal-500 rounded-full animate-pulse delay-450"></div>
<div className="absolute -bottom-1 -left-1 w-2 h-2 bg-gradient-to-br from-teal-400 to-blue-500 rotate-45 animate-spin delay-525"></div>
{/* Mini sparkles around tool name */}
<div className="absolute -top-2 left-2 w-0.5 h-0.5 bg-blue-300 rounded-full animate-ping delay-600"></div>
<div className="absolute -top-1 right-2 w-1 h-1 bg-teal-300 rounded-full animate-pulse delay-675"></div>
<div className="absolute bottom-0 left-4 w-0.5 h-0.5 bg-green-300 rounded-full animate-bounce delay-750"></div>
<div className="absolute bottom-1 right-4 w-0.5 h-0.5 bg-emerald-300 rounded-full animate-pulse delay-825"></div>
<div className="absolute top-2 -left-2 w-0.5 h-0.5 bg-cyan-300 rounded-full animate-ping delay-900"></div>
<div className="absolute top-2 -right-2 w-1 h-1 bg-teal-300 rounded-full animate-bounce delay-975"></div>
</div>
</div>
{/* Ultra cute floating elements constellation around the entire group */}
<div className="absolute -top-10 -left-10 w-3 h-3 bg-gradient-to-br from-pink-400 to-purple-500 rounded-full animate-float"></div>
<div className="absolute -top-8 -left-6 w-1.5 h-1.5 bg-gradient-to-br from-purple-400 to-pink-500 rotate-45 animate-spin delay-100"></div>
<div className="absolute -top-6 -left-12 w-2 h-2 bg-gradient-to-br from-pink-400 to-red-500 rounded-full animate-bounce delay-200"></div>
<div className="absolute -top-10 -right-10 w-2.5 h-2.5 bg-gradient-to-br from-blue-400 to-cyan-500 rounded-full animate-ping delay-300"></div>
<div className="absolute -top-6 -right-14 w-1 h-1 bg-gradient-to-br from-cyan-400 to-blue-500 rotate-45 animate-pulse delay-400"></div>
<div className="absolute -top-4 -right-8 w-3 h-3 bg-gradient-to-br from-blue-400 to-purple-500 rounded-full animate-bounce delay-500"></div>
<div className="absolute -bottom-10 -left-10 w-2 h-2 bg-gradient-to-br from-yellow-400 to-orange-500 rounded-full animate-pulse delay-600"></div>
<div className="absolute -bottom-8 -left-14 w-1.5 h-1.5 bg-gradient-to-br from-orange-400 to-red-500 rotate-45 animate-spin delay-700"></div>
<div className="absolute -bottom-6 -left-6 w-2.5 h-2.5 bg-gradient-to-br from-yellow-400 to-pink-500 rounded-full animate-float delay-800"></div>
<div className="absolute -bottom-10 -right-10 w-3 h-3 bg-gradient-to-br from-green-400 to-teal-500 rotate-45 animate-bounce delay-900"></div>
<div className="absolute -bottom-8 -right-6 w-1 h-1 bg-gradient-to-br from-teal-400 to-cyan-500 rounded-full animate-ping delay-1000"></div>
<div className="absolute -bottom-6 -right-14 w-2 h-2 bg-gradient-to-br from-emerald-400 to-green-500 rounded-full animate-pulse delay-1100"></div>
{/* Extra tiny magical sparkles */}
<div className="absolute -top-12 left-0 w-0.5 h-0.5 bg-yellow-300 rounded-full animate-ping delay-1200"></div>
<div className="absolute -top-2 -left-16 w-1 h-1 bg-pink-300 rounded-full animate-bounce delay-1300"></div>
<div className="absolute top-2 -right-18 w-0.5 h-0.5 bg-blue-300 rounded-full animate-pulse delay-1400"></div>
<div className="absolute -bottom-12 right-0 w-1 h-1 bg-purple-300 rounded-full animate-float delay-1500"></div>
<div className="absolute -bottom-2 -right-16 w-0.5 h-0.5 bg-green-300 rounded-full animate-ping delay-1600"></div>
<div className="absolute bottom-2 -left-18 w-1 h-1 bg-teal-300 rounded-full animate-bounce delay-1700"></div>
</div>
{/* Separator gradient remains the same */}
<div className="h-1 w-24 mx-auto my-3 bg-gradient-to-r from-blue-500 to-pink-500 rounded-full"></div>
{/* Slogan with clean typography */}
<p className="mt-4 text-base sm:text-lg font-light text-gray-600 dark:text-gray-300 max-w-lg mx-auto text-center tracking-[0.1em] leading-relaxed">
</p>
<div className="mt-5 flex items-center justify-center">
<a
href="https://perlerbeadsnew.zippland.com/"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full border border-blue-200 dark:border-blue-700 bg-white/90 dark:bg-gray-800/90 px-4 py-2 text-sm font-semibold text-blue-600 dark:text-blue-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200"
>
<span></span>
<span className="text-[10px] uppercase tracking-widest px-1.5 py-0.5 rounded-full bg-blue-600 text-white">
beta
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h9.586L11.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414-1.414L13.586 11H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
</a>
</div>
{/* 添加小红书交流群链接 */}
<div className="mt-6 flex flex-col items-center justify-center space-y-2">
<p className="inline-flex items-center gap-1 px-2 py-1 rounded-md border border-amber-200 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 text-xs text-gray-600 dark:text-gray-200 font-semibold">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-3.5 h-3.5 text-amber-500 dark:text-amber-300">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.707-11.707a1 1 0 00-1.414 0l-3 3a1 1 0 101.414 1.414L9 9.414V13a1 1 0 102 0V9.414l1.293 1.293a1 1 0 001.414-1.414l-3-3z" clipRule="evenodd" />
</svg>
</p>
<div className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<span></span>
<a href="https://www.xiaohongshu.com/user/profile/623e8b080000000010007721" target="_blank" rel="noopener noreferrer" className="inline-flex items-center text-rose-500 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors duration-200 hover:underline font-medium">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 1024 1024" fill="currentColor" className="mr-0.5">
<path d="M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64z m238.8 360.2l-57.7 93.3c-10.1 16.3-31.5 21.3-47.8 11.2l-112.4-69.5c-16.3-10.1-21.3-31.5-11.2-47.8l57.7-93.3c10.1-16.3 31.5-21.3 47.8-11.2l112.4 69.5c16.3 10.1 21.3 31.5 11.2 47.8zM448 496l-57.7 93.3c-10.1 16.3-31.5 21.3-47.8 11.2l-112.4-69.5c-16.3-10.1-21.3-31.5-11.2-47.8l57.7-93.3c10.1-16.3 31.5-21.3 47.8-11.2l112.4 69.5c16.3 10.1 21.3 31.5 11.2 47.8z m248.9 43.2l-57.7 93.3c-10.1 16.3-31.5 21.3-47.8 11.2l-112.4-69.5c-16.3-10.1-21.3-31.5-11.2-47.8l57.7-93.3c10.1-16.3 31.5-21.3 47.8-11.2l112.4 69.5c16.3 10.1 21.3 31.5 11.2 47.8z"/>
</svg>
</a>
<span></span>
</div>
<div className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
<span></span>
<a
href="https://github.com/Zippland/perler-beads"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors duration-200 hover:underline font-medium"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="currentColor"
className="mr-0.5"
>
<path
fillRule="evenodd"
d="M12 0C5.37 0 0 5.48 0 12.25c0 5.42 3.44 10.01 8.2 11.63.6.12.82-.27.82-.6 0-.3-.01-1.08-.02-2.13-3.34.74-4.04-1.65-4.04-1.65-.55-1.44-1.35-1.83-1.35-1.83-1.1-.78.08-.77.08-.77 1.21.09 1.85 1.26 1.85 1.26 1.08 1.9 2.83 1.35 3.52 1.03.11-.81.42-1.35.77-1.66-2.66-.31-5.46-1.36-5.46-6.06 0-1.34.46-2.43 1.22-3.29-.12-.31-.53-1.55.12-3.23 0 0 1-.33 3.29 1.25a10.96 10.96 0 0 1 5.98 0c2.29-1.58 3.29-1.25 3.29-1.25.65 1.68.24 2.92.12 3.23.76.86 1.22 1.95 1.22 3.29 0 4.71-2.81 5.74-5.49 6.05.43.38.81 1.13.81 2.28 0 1.65-.02 2.98-.02 3.39 0 .33.22.72.83.59C20.56 22.25 24 17.67 24 12.25 24 5.48 18.63 0 12 0Z"
/>
</svg>
GitHub
</a>
<span>🌟 </span>
</div>
</div>
</div>
</header>
{/* Apply dark mode styles to the main section */}
<main ref={mainRef} className="w-full md:max-w-4xl flex flex-col items-center space-y-5 sm:space-y-6 relative overflow-hidden">
{/* Apply dark mode styles to the Drop Zone */}
<div
onDrop={handleDrop} onDragOver={handleDragOver} onDragEnter={handleDragOver}
onClick={isMounted ? triggerFileInput : undefined}
className={`border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 sm:p-8 text-center ${isMounted ? 'cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-gray-800' : 'cursor-wait'} transition-all duration-300 w-full md:max-w-md flex flex-col justify-center items-center shadow-sm hover:shadow-md`}
style={{ minHeight: '130px' }}
>
{/* Icon color */}
<svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10 sm:h-12 sm:w-12 text-gray-400 dark:text-gray-500 mb-2 sm:mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1}>
<path strokeLinecap="round" strokeLinejoin="round" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
{/* Text color */}
<p className="text-xs sm:text-sm text-gray-500 dark:text-gray-400"><span className="font-medium text-blue-600 dark:text-blue-400"></span></p>
{/* Text color */}
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1"> JPG, PNG CSV </p>
</div>
{/* Apply dark mode styles to the Tip Box */}
{!originalImageSrc && (
<div className="w-full md:max-w-md bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-800 dark:to-gray-700 p-3 rounded-lg border border-blue-100 dark:border-gray-600 shadow-sm">
{/* Icon color */}
<p className="text-xs text-indigo-700 dark:text-indigo-300 flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1.5 flex-shrink-0 text-blue-500 dark:text-blue-400 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{/* Text color */}
<span className="text-indigo-700 dark:text-indigo-300">使线</span>
</p>
</div>
)}
<input type="file" accept="image/jpeg, image/png, .csv, text/csv, application/csv, text/plain" onChange={handleFileChange} ref={fileInputRef} className="hidden" />
{/* Controls and Output Area */}
{originalImageSrc && (
<div className="w-full flex flex-col items-center space-y-5 sm:space-y-6">
{/* ++ HIDE Control Row in manual mode ++ */}
{!isManualColoringMode && (
/* 修改控制面板网格布局 */
<div className="w-full md:max-w-2xl grid grid-cols-1 sm:grid-cols-2 gap-4 bg-white dark:bg-gray-800 p-4 sm:p-5 rounded-xl shadow-md border border-gray-100 dark:border-gray-700">
{/* Granularity Input */}
<div className="flex-1">
{/* Label color */}
<label htmlFor="granularityInput" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">
(10-300):
</label>
<div className="flex items-center gap-2">
{/* Input field styles */}
<input
type="number"
id="granularityInput"
value={granularityInput}
onChange={handleGranularityInputChange}
className="w-full p-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9 shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500"
min="10"
max="300"
/>
</div>
</div>
{/* Similarity Threshold Input */}
<div className="flex-1">
{/* Label color */}
<label htmlFor="similarityThresholdInput" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">
(0-100):
</label>
<div className="flex items-center gap-2">
{/* Input field styles */}
<input
type="number"
id="similarityThresholdInput"
value={similarityThresholdInput}
onChange={handleSimilarityThresholdInputChange}
className="w-full p-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9 shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500"
min="0"
max="100"
/>
</div>
</div>
{/* 快捷按钮 */}
<div className="sm:col-span-2 flex flex-wrap items-center gap-2">
<button
onClick={handleConfirmParameters}
className="h-9 bg-blue-500 hover:bg-blue-600 text-white text-sm px-3 rounded-md whitespace-nowrap transition-colors duration-200 shadow-sm"
>
</button>
<button
onClick={handleAutoRemoveBackground}
disabled={!mappedPixelData || !gridDimensions}
className="inline-flex items-center justify-center h-9 px-3 text-sm rounded-md border border-blue-200 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200 hover:bg-blue-100 dark:hover:bg-blue-800/40 transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
</button>
</div>
{/* Pixelation Mode Selector */}
<div className="sm:col-span-2">
{/* Label color */}
<label htmlFor="pixelationModeSelect" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">:</label>
<div className="flex items-center gap-2">
{/* Select field styles */}
<select
id="pixelationModeSelect"
value={pixelationMode}
onChange={handlePixelationModeChange}
className="w-full p-1.5 border border-gray-300 dark:border-gray-600 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500 h-9 shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200"
>
<option value={PixelationMode.Dominant} className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200"> ()</option>
<option value={PixelationMode.Average} className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200"> ()</option>
</select>
</div>
</div>
{/* 色号系统选择器 */}
<div className="sm:col-span-2">
<label className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">:</label>
<div className="flex flex-wrap gap-2">
{colorSystemOptions.map(option => (
<button
key={option.key}
onClick={() => setSelectedColorSystem(option.key as ColorSystem)}
className={`px-3 py-2 text-sm rounded-lg border transition-all duration-200 flex-shrink-0 ${
selectedColorSystem === option.key
? 'bg-blue-500 text-white border-blue-500 shadow-md transform scale-105'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:border-blue-400 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-gray-600'
}`}
>
{option.name}
</button>
))}
</div>
</div>
{/* 自定义色板按钮 */}
<div className="sm:col-span-2 mt-3">
<button
onClick={() => setIsCustomPaletteEditorOpen(true)}
className="w-full py-2.5 px-3 flex items-center justify-center gap-2 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-medium rounded-lg shadow-sm transition-all duration-200 hover:shadow-md hover:from-blue-600 hover:to-purple-600"
>
<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 2a2 2 0 00-2 2v11a3 3 0 106 0V4a2 2 0 00-2-2H4zm1 14a1 1 0 100-2 1 1 0 000 2zm5-1.757l4.9-4.9a2 2 0 000-2.828L13.485 5.1a2 2 0 00-2.828 0L10 5.757v8.486zM16 18H9.071l6-6H16a2 2 0 012 2v2a2 2 0 01-2 2z" clipRule="evenodd" />
</svg>
({Object.values(customPaletteSelections).filter(Boolean).length} )
</button>
{isCustomPalette && (
<p className="text-xs text-center text-blue-500 dark:text-blue-400 mt-1.5">使</p>
)}
</div>
</div>
)}
{/* 自定义色板编辑器弹窗 - 这是新增的部分 */}
{isCustomPaletteEditorOpen && (
<div className="fixed inset-0 bg-black bg-opacity-50 backdrop-blur-sm z-50 flex justify-center items-center p-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* 添加隐藏的文件输入框 */}
<input
type="file"
accept=".json"
ref={importPaletteInputRef}
onChange={handleImportPaletteFile}
className="hidden"
/>
<div className="p-4 sm:p-6 flex-1 overflow-y-auto"> {/* 让内容区域可滚动 */}
<CustomPaletteEditor
allColors={fullBeadPalette}
currentSelections={customPaletteSelections}
onSelectionChange={handleSelectionChange}
onSaveCustomPalette={handleSaveCustomPalette}
onClose={() => setIsCustomPaletteEditorOpen(false)}
onExportCustomPalette={handleExportCustomPalette}
onImportCustomPalette={triggerImportPalette}
selectedColorSystem={selectedColorSystem}
/>
</div>
</div>
</div>
)}
{/* Output Section */}
<div className="w-full md:max-w-2xl">
<canvas ref={originalCanvasRef} className="hidden"></canvas>
{/* ++ 手动编辑模式提示信息 ++ */}
{isManualColoringMode && mappedPixelData && gridDimensions && (
<div className="w-full mb-4 p-3 bg-blue-50 dark:bg-gray-800 rounded-lg shadow-sm border border-blue-100 dark:border-gray-700">
<div className="flex justify-center">
<div className="bg-blue-50 dark:bg-gray-700 border border-blue-100 dark:border-gray-600 rounded-lg p-2 flex flex-col sm:flex-row items-start sm:items-center gap-2 sm:gap-3 text-xs text-gray-600 dark:text-gray-300 w-full sm:w-auto">
<div className="flex items-center gap-1 w-full sm:w-auto">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
<span>使</span>
</div>
<span className="hidden sm:inline text-gray-300 dark:text-gray-500">|</span>
<div className="flex items-center gap-1 w-full sm:w-auto">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<span></span>
</div>
</div>
</div>
</div>
)}
{/* Canvas Preview Container */}
{/* Apply dark mode styles */}
<div className="bg-white dark:bg-gray-800 p-4 rounded-xl shadow-md border border-gray-100 dark:border-gray-700">
{/* 大画布提示信息 */}
{gridDimensions && gridDimensions.N > 100 && (
<div className="mb-3 p-2 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg text-xs text-blue-700 dark:text-blue-300 text-center">
<div className="flex items-center justify-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 text-blue-500 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span> ({gridDimensions.N}×{gridDimensions.M}) - </span>
</div>
</div>
)}
{/* Inner container background - 允许水平滚动以适应大画布 */}
<div className="flex justify-center mb-3 sm:mb-4 bg-gray-100 dark:bg-gray-700 p-2 rounded-lg overflow-x-auto overflow-y-hidden"
style={{ minHeight: '150px' }}>
{/* PixelatedPreviewCanvas component needs internal changes for dark mode drawing */}
<PixelatedPreviewCanvas
canvasRef={pixelatedCanvasRef}
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
isManualColoringMode={isManualColoringMode}
onInteraction={handleCanvasInteraction}
highlightColorKey={highlightColorKey}
onHighlightComplete={handleHighlightComplete}
/>
</div>
</div>
</div>
</div> // This closes the main div started after originalImageSrc check
)}
{/* ++ HIDE Color Counts in manual mode ++ */}
{!isManualColoringMode && originalImageSrc && colorCounts && Object.keys(colorCounts).length > 0 && (
// Apply dark mode styles to color counts container
<div className="w-full md:max-w-2xl mt-6 bg-white dark:bg-gray-800 p-4 rounded-lg shadow border border-gray-100 dark:border-gray-700 color-stats-panel">
{/* Title color */}
<h3 className="text-lg font-semibold mb-1 text-gray-700 dark:text-gray-200 text-center">
</h3>
{/* Subtitle color */}
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mb-3">: {totalBeadCount} </p>
<ul className="space-y-1 max-h-60 overflow-y-auto pr-2 text-sm">
{Object.keys(colorCounts)
.sort(sortColorKeys)
.map((hexKey) => {
// 现在key是hex值需要通过hex获取对应色号系统的色号
const displayColorKey = getColorKeyByHex(hexKey, selectedColorSystem);
const isExcluded = excludedColorKeys.has(hexKey);
const count = colorCounts[hexKey].count;
const colorHex = colorCounts[hexKey].color;
return (
<li
key={hexKey}
onClick={() => handleToggleExcludeColor(hexKey)}
// Apply dark mode styles for list items (normal and excluded)
className={`flex items-center justify-between p-1.5 rounded cursor-pointer transition-colors ${
isExcluded
? 'bg-red-100 dark:bg-red-900/50 hover:bg-red-200 dark:hover:bg-red-800/60 opacity-60 dark:opacity-70' // Darker red background for excluded
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={isExcluded ? `点击恢复 ${displayColorKey}` : `点击排除 ${displayColorKey}`}
>
<div className={`flex items-center space-x-2 ${isExcluded ? 'line-through' : ''}`}>
{/* Adjust color swatch border */}
<span
className="inline-block w-4 h-4 rounded border border-gray-400 dark:border-gray-500 flex-shrink-0"
style={{ backgroundColor: isExcluded ? '#666' : colorHex }} // Darker gray for excluded swatch
></span>
{/* Adjust text color for key (normal and excluded) */}
<span className={`font-mono font-medium ${isExcluded ? 'text-red-700 dark:text-red-400' : 'text-gray-800 dark:text-gray-200'}`}>{displayColorKey}</span>
</div>
{/* Adjust text color for count (normal and excluded) */}
<span className={`text-xs ${isExcluded ? 'text-red-600 dark:text-red-400 line-through' : 'text-gray-600 dark:text-gray-300'}`}>{count} </span>
</li>
);
})}
</ul>
{excludedColorKeys.size > 0 && (
<div className="mt-3">
<button
onClick={() => setShowExcludedColors(prev => !prev)}
className="w-full text-xs py-1.5 px-2 bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-500 transition-colors flex items-center justify-between"
>
<span> ({excludedColorKeys.size})</span>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transform transition-transform ${showExcludedColors ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showExcludedColors && (
<div className="mt-2 border border-gray-200 dark:border-gray-700 rounded-md p-2 bg-gray-100 dark:bg-gray-800">
<div className="max-h-40 overflow-y-auto">
{Array.from(excludedColorKeys).length > 0 ? (
<ul className="space-y-1">
{Array.from(excludedColorKeys).sort(sortColorKeys).map(hexKey => {
const colorData = fullBeadPalette.find(color => color.hex.toUpperCase() === hexKey.toUpperCase());
return (
<li key={hexKey} className="flex justify-between items-center p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded">
<div className="flex items-center space-x-2">
<span
className="inline-block w-4 h-4 rounded border border-gray-400 dark:border-gray-500 flex-shrink-0"
style={{ backgroundColor: colorData?.hex || hexKey }}
></span>
<span className="font-mono text-xs text-gray-800 dark:text-gray-200">{getColorKeyByHex(hexKey, selectedColorSystem)}</span>
</div>
<button
onClick={() => {
// 实现恢复单个颜色的逻辑
const newExcludedKeys = new Set(excludedColorKeys);
newExcludedKeys.delete(hexKey);
setExcludedColorKeys(newExcludedKeys);
setRemapTrigger(prev => prev + 1);
setIsManualColoringMode(false);
setSelectedColor(null);
console.log(`Restored color: ${hexKey}`);
}}
className="text-xs py-0.5 px-2 bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800/40"
>
</button>
</li>
);
})}
</ul>
) : (
<p className="text-xs text-center text-gray-500 dark:text-gray-400 py-2">
</p>
)}
</div>
<button
onClick={() => {
// 恢复所有颜色的逻辑
setExcludedColorKeys(new Set());
setRemapTrigger(prev => prev + 1);
setIsManualColoringMode(false);
setSelectedColor(null);
console.log("Restored all excluded colors");
}}
className="mt-2 w-full text-xs py-1 px-2 bg-blue-500 hover:bg-blue-600 text-white rounded transition-colors"
>
</button>
</div>
)}
</div>
)}
</div>
)} {/* ++ End of HIDE Color Counts ++ */}
{/* Message if palette becomes empty (Also hide in manual mode) */}
{!isManualColoringMode && originalImageSrc && activeBeadPalette.length === 0 && excludedColorKeys.size > 0 && (
// Apply dark mode styles to the warning box
<div className="w-full md:max-w-2xl mt-6 bg-yellow-100 dark:bg-yellow-900/50 p-4 rounded-lg shadow border border-yellow-200 dark:border-yellow-800/60 text-center text-sm text-yellow-800 dark:text-yellow-300">
{excludedColorKeys.size > 0 && (
// Apply dark mode styles to the inline "restore all" button
<button
onClick={() => {
setShowExcludedColors(true); // 展开排除颜色列表
// 滚动到颜色列表处
setTimeout(() => {
const listElement = document.querySelector('.color-stats-panel');
if (listElement) {
listElement.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}}
className="mt-2 ml-2 text-xs py-1 px-2 bg-yellow-200 dark:bg-yellow-700/60 text-yellow-900 dark:text-yellow-200 rounded hover:bg-yellow-300 dark:hover:bg-yellow-600/70 transition-colors"
>
({excludedColorKeys.size})
</button>
)}
</div>
)}
{/* ++ RENDER Enter Manual Mode Button ONLY when NOT in manual mode (before downloads) ++ */}
{!isManualColoringMode && originalImageSrc && mappedPixelData && gridDimensions && (
<div className="w-full md:max-w-2xl mt-4 space-y-3"> {/* Wrapper div */}
{/* Manual Edit Mode Button */}
<button
onClick={() => {
setIsManualColoringMode(true); // Enter mode
setSelectedColor(null);
setTooltipData(null);
}}
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-300 flex items-center justify-center gap-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg hover:translate-y-[-1px]`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" /> </svg>
</button>
{/* Focus Mode Button */}
<button
onClick={handleEnterFocusMode}
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-300 flex items-center justify-center gap-2 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white shadow-md hover:shadow-lg hover:translate-y-[-1px]`}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
AplhaTest
</button>
</div>
)} {/* ++ End of RENDER Enter Manual Mode Button ++ */}
{/* ++ HIDE Download Buttons in manual mode ++ */}
{!isManualColoringMode && originalImageSrc && mappedPixelData && (
<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="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>
</div>
)} {/* ++ End of HIDE Download Buttons ++ */}
{/* Tooltip Display (Needs update in GridTooltip.tsx) */}
{tooltipData && (
<GridTooltip tooltipData={tooltipData} selectedColorSystem={selectedColorSystem} />
)}
</main>
{/* 悬浮工具栏 */}
<FloatingToolbar
isManualColoringMode={isManualColoringMode}
isPaletteOpen={isFloatingPaletteOpen}
onTogglePalette={() => setIsFloatingPaletteOpen(!isFloatingPaletteOpen)}
onExitManualMode={() => {
setIsManualColoringMode(false);
setSelectedColor(null);
setTooltipData(null);
setIsEraseMode(false);
setColorReplaceState({
isActive: false,
step: 'select-source'
});
setHighlightColorKey(null);
setIsMagnifierActive(false);
setMagnifierSelectionArea(null);
}}
onToggleMagnifier={handleToggleMagnifier}
isMagnifierActive={isMagnifierActive}
/>
{/* 悬浮调色盘 */}
{isManualColoringMode && (
<FloatingColorPalette
colors={currentGridColors}
selectedColor={selectedColor}
onColorSelect={handleColorSelect}
selectedColorSystem={selectedColorSystem}
isEraseMode={isEraseMode}
onEraseToggle={handleEraseToggle}
fullPaletteColors={fullPaletteColors}
showFullPalette={showFullPalette}
onToggleFullPalette={handleToggleFullPalette}
colorReplaceState={colorReplaceState}
onColorReplaceToggle={handleColorReplaceToggle}
onColorReplace={handleColorReplace}
onHighlightColor={handleHighlightColor}
isOpen={isFloatingPaletteOpen}
onToggleOpen={() => setIsFloatingPaletteOpen(!isFloatingPaletteOpen)}
isActive={activeFloatingTool === 'palette'}
onActivate={handleActivatePalette}
/>
)}
{/* 放大镜工具 */}
{isManualColoringMode && (
<>
<MagnifierTool
isActive={isMagnifierActive}
onToggle={handleToggleMagnifier}
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
selectedColor={selectedColor}
selectedColorSystem={selectedColorSystem}
onPixelEdit={handleMagnifierPixelEdit}
cellSize={gridDimensions ? Math.min(6, Math.max(4, 500 / Math.max(gridDimensions.N, gridDimensions.M))) : 6}
selectionArea={magnifierSelectionArea}
onClearSelection={() => setMagnifierSelectionArea(null)}
isFloatingActive={activeFloatingTool === 'magnifier'}
onActivateFloating={handleActivateMagnifier}
highlightColorKey={highlightColorKey}
/>
{/* 放大镜选择覆盖层 */}
<MagnifierSelectionOverlay
isActive={isMagnifierActive && !magnifierSelectionArea}
canvasRef={pixelatedCanvasRef}
gridDimensions={gridDimensions}
cellSize={gridDimensions ? Math.min(6, Math.max(4, 500 / Math.max(gridDimensions.N, gridDimensions.M))) : 6}
onSelectionComplete={setMagnifierSelectionArea}
/>
</>
)}
{/* Apply dark mode styles to the Footer */}
<footer className="w-full md:max-w-4xl mt-10 mb-6 py-6 text-center text-xs sm:text-sm text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 bg-gradient-to-b from-white to-gray-50 dark:from-gray-900 dark:to-gray-800/50 rounded-lg shadow-inner">
{/* Donation button styles are likely fine */}
<button
onClick={() => setIsDonationModalOpen(true)}
className="mb-5 px-6 py-2.5 bg-gradient-to-r from-pink-500 to-rose-500 text-white rounded-full shadow-lg transition-all duration-300 hover:shadow-xl hover:translate-y-[-2px] flex items-center justify-center mx-auto"
>
{/* SVG and Text inside button */}
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 8h1a2 2 0 0 1 2 2v1c0 1.1-.9 2-2 2h-1" fill="#f9a8d4" />
<path d="M6 8h12v9a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3V8z" fill="#f9a8d4" />
<path d="M6 8V7a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v1" fill="#f472b6" />
<path d="M12 16v-4" stroke="#7d2a5a" />
<path d="M9.5 14.5L9 16" stroke="#7d2a5a" />
<path d="M14.5 14.5L15 16" stroke="#7d2a5a" />
</svg>
<span></span>
</button>
{/* Copyright text color */}
<p className="font-medium text-gray-600 dark:text-gray-300">
稿 &copy; {new Date().getFullYear()}
</p>
</footer>
{/* Donation Modal - 现在使用新的组件 */}
<DonationModal isOpen={isDonationModalOpen} onClose={() => setIsDonationModalOpen(false)} />
{/* 使用导入的下载设置弹窗组件 */}
<DownloadSettingsModal
isOpen={isDownloadSettingsOpen}
onClose={() => setIsDownloadSettingsOpen(false)}
options={downloadOptions}
onOptionsChange={setDownloadOptions}
onDownload={handleDownloadRequest}
/>
{/* 专心拼豆模式进入前下载提醒弹窗 */}
<FocusModePreDownloadModal
isOpen={isFocusModePreDownloadModalOpen}
onClose={() => setIsFocusModePreDownloadModalOpen(false)}
onProceedWithoutDownload={handleProceedToFocusMode}
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
selectedColorSystem={selectedColorSystem}
/>
</div>
</>
);
}