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

1725 lines
88 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 } from 'react';
import Script from 'next/script';
import ColorPalette from '../components/ColorPalette';
// 导入像素化工具和类型
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 } from '../utils/imageDownloader';
import beadPaletteData from './beadPaletteData.json';
import {
colorSystemOptions,
convertPaletteToColorSystem,
getDisplayColorKey,
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 ---
const allPaletteKeys = Object.keys(beadPaletteData);
// 144 Color Palette Keys
const palette144Keys = ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "H1", "M1", "A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2", "M2", "A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3", "M3", "A4", "B4", "C4", "D5", "E4", "F4", "G4", "H4", "M4", "A5", "B5", "C5", "D6", "E5", "F5", "G5", "H5", "M5", "A6", "B6", "C6", "D7", "E6", "F6", "G6", "H6", "M6", "A7", "B7", "C7", "D8", "E7", "F7", "G7", "H7", "M7", "A8", "B8", "C8", "D9", "E8", "F8", "G8", "H8", "M8", "A9", "B10", "C9", "D11", "E9", "F9", "G9", "H9", "M9", "A10", "B11", "C10", "D12", "E10", "F10", "G10", "H10", "M10", "A11", "B12", "C11", "D13", "E11", "F11", "G11", "H11", "M11", "A12", "B13", "C13", "D14", "E12", "F12", "G12", "H12", "M12", "A13", "B14", "C14", "D15", "E13", "F13", "G13", "H13", "M13", "A14", "B15", "C15", "D16", "E14", "F14", "G14", "H14", "M14", "A15", "B16", "C16", "D17", "E15", "G15", "M15", "B17", "C17", "D18", "G16", "B18", "D19", "G17", "B19", "D20", "B20", "D21", "T1"]; // Ensure T1 is present
// 168 Color Palette Keys (from user table)
const palette168Keys = ["A1", "B1", "C1", "D1", "E1", "F1", "G1", "M1", "A2", "B2", "C2", "D2", "E2", "F2", "G2", "H2", "M2", "A3", "B3", "C3", "D3", "E3", "F3", "G3", "H3", "M3", "A4", "B4", "C4", "D5", "E4", "F4", "G4", "H4", "M4", "A5", "B5", "C5", "D6", "E5", "F5", "G5", "H5", "M5", "A6", "B6", "C6", "D7", "E6", "F6", "G6", "H6", "M6", "A7", "B7", "C7", "D8", "E7", "F7", "G7", "H7", "M7", "A8", "B8", "C8", "D9", "E8", "F8", "G8", "H8", "M8", "A9", "B10", "C9", "D11", "E9", "F9", "G9", "H9", "M9", "A10", "B11", "C10", "D12", "E10", "F10", "G10", "H10", "M10", "A11", "B12", "C11", "D13", "E11", "F11", "G11", "H11", "M11", "A12", "B13", "C13", "D14", "E12", "F12", "G12", "H12", "M12", "A13", "B14", "C14", "D15", "E13", "F13", "G13", "H13", "M13", "A14", "B15", "C15", "D16", "E14", "F14", "G14", "H14", "M14", "A15", "B16", "C16", "D17", "E15", "G15", "M15", "B17", "C17", "D18", "G16", "B18", "D19", "G17", "B19", "D20", "B20", "D21", "T1"]; // Ensure T1 is present
// 96 Color Palette Keys (from user table)
const palette96Keys = ["A3", "A4", "A6", "A7", "A10", "A11", "A13", "A14", "B3", "B5", "B7", "B8", "B10", "B12", "B14", "B17", "B18", "B19", "B20", "C2", "C3", "C5", "C6", "C7", "C8", "C10", "C11", "C13", "C16", "D2", "D3", "D5", "D6", "D7", "D8", "D9", "D11", "D12", "D13", "D14", "D15", "D16", "D18", "D19", "D20", "D21", "E1", "E2", "E3", "E4", "E5", "E6", "E7", "E8", "E9", "E10", "E11", "E12", "E13", "E14", "E15", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14", "G1", "G2", "G3", "G5", "G7", "G8", "G9", "G13", "G14", "G17", "H1", "H2", "H3", "H4", "H5", "H6", "H7", "M5", "M6", "M9", "M12", "T1"]; // Added T1
// 120 Color Palette Keys (from user table)
const palette120Keys = ["A1", "A3", "A4", "A5", "A6", "A7", "A8", "A9", "A10", "A11", "A12", "A13", "A14", "A15", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8", "B10", "B11", "B12", "B13", "B14", "B15", "B16", "B17", "B18", "B19", "B20", "C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8", "C9", "C10", "C11", "C13", "C14", "C15", "C16", "C17", "D1", "D2", "D3", "D5", "D6", "D7", "D8", "D9", "D11", "D12", "D13", "D14", "D15", "D16", "D17", "D18", "D19", "D20", "D21", "E1", "E2", "E3", "E4", "E5", "E6", "E7", "E8", "E9", "E10", "E11", "E12", "E13", "E14", "E15", "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12", "F13", "F14", "G1", "G2", "G3", "G5", "G6", "G7", "G8", "G9", "G13", "G14", "G17", "H1", "H2", "H3", "H4", "H5", "H6", "H7", "H12", "M5", "M6", "M9", "M12", "T1"]; // Added T1
// 72 Color Palette Keys (from user table)
const palette72Keys = ["A3", "A4", "A6", "A7", "A10", "A11", "A13", "B3", "B5", "B7", "B8", "B10", "B12", "B14", "B17", "B18", "B19", "B20", "C2", "C3", "C5", "C6", "C7", "C8", "C10", "C11", "C13", "C16", "D2", "D3", "D6", "D7", "D8", "D9", "D11", "D12", "D13", "D14", "D15", "D16", "D18", "D19", "D20", "D21", "E1", "E2", "E3", "E4", "E5", "E8", "E12", "E13", "F5", "F7", "F8", "F10", "F13", "G1", "G2", "G3", "G5", "G7", "G8", "G9", "G13", "H1", "H2", "H3", "H4", "H5", "H7", "T1"]; // Added T1
// Placeholder for other palettes
// const palette48Keys = [...];
// const palette24Keys = [...];
const paletteOptions = {
'all': { name: `全色系291色`, keys: allPaletteKeys },
'168': { name: '168色', keys: palette168Keys },
'144': { name: '144色', keys: palette144Keys },
'120': { name: '120色', keys: palette120Keys },
'96': { name: '96色', keys: palette96Keys },
'72': { name: '72色', keys: palette72Keys }, // Added 72
// Add other palettes here
};
type PaletteOptionKey = keyof typeof paletteOptions;
// Pre-process the FULL palette data once
const fullBeadPalette: PaletteColor[] = Object.entries(beadPaletteData)
.map(([key, hex]) => {
const rgb = hexToRgb(hex);
if (!rgb) {
console.warn(`Invalid hex code "${hex}" for key "${key}". Skipping.`);
return null;
}
return { key, hex, rgb };
})
.filter((color): color is PaletteColor => color !== null);
// ++ 添加透明键定义 ++
const TRANSPARENT_KEY = 'ERASE';
// ++ 添加透明色数据 ++
const transparentColorData: MappedPixel = { key: TRANSPARENT_KEY, color: '#FFFFFF', isExternal: true };
// ++ Add definition for background color keys ++
const BACKGROUND_COLOR_KEYS = ['T01', 'H01', 'H02']; // 修正为与映射表一致的格式
// 1. 导入新组件
import PixelatedPreviewCanvas from '../components/PixelatedPreviewCanvas';
import GridTooltip from '../components/GridTooltip';
import CustomPaletteEditor from '../components/CustomPaletteEditor';
import { loadPaletteSelections, savePaletteSelections, presetToSelections, PaletteSelections } from '../utils/localStorageUtils';
// 1. 导入新的 DonationModal 组件
import DonationModal from '../components/DonationModal';
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 [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState<PaletteOptionKey>('all');
// 新增:色号系统选择状态
const [selectedColorSystem, setSelectedColorSystem] = useState<ColorSystem>('MARD');
const [activeBeadPalette, setActiveBeadPalette] = useState<PaletteColor[]>(() => {
const initialKey = 'all'; // Match the key used above
const options = paletteOptions[initialKey];
if (!options) return fullBeadPalette; // Fallback
const keySet = new Set(options.keys);
return fullBeadPalette.filter(color => keySet.has(color.key));
});
const [excludedColorKeys, setExcludedColorKeys] = useState<Set<string>>(new Set());
const [showExcludedColors, setShowExcludedColors] = useState<boolean>(false);
const [initialGridColorKeys, setInitialGridColorKeys] = useState<Set<string> | null>(null);
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 [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,
gridLineColor: gridLineColorOptions[0].value,
includeStats: true // 默认包含统计信息
});
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 isInSelectedPalette = paletteOptions[selectedPaletteKeySet]?.keys.includes(color.key);
const isNotExcluded = !excludedColorKeys.has(color.key);
return isInSelectedPalette && isNotExcluded;
});
// 根据选择的色号系统转换调色板
const convertedPalette = convertPaletteToColorSystem(newActiveBeadPalette, selectedColorSystem);
setActiveBeadPalette(convertedPalette);
}, [selectedPaletteKeySet, 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 [];
const uniqueColorsMap = new Map<string, MappedPixel>();
mappedPixelData.flat().forEach(cell => {
if (cell && cell.key && !cell.isExternal && !uniqueColorsMap.has(cell.key)) {
// Store the full MappedPixel object to preserve key and color
uniqueColorsMap.set(cell.key, { key: cell.key, color: cell.color });
}
});
// Sort colors like the stats list, if desired
const originalColors = Array.from(uniqueColorsMap.values()).sort((a, b) => sortColorKeys(a.key, b.key));
// 转换色号系统
return originalColors.map(color => ({
...color,
key: getDisplayColorKey(color.key, selectedColorSystem)
}));
}, [mappedPixelData, selectedColorSystem]); // 添加selectedColorSystem到依赖项
// 初始化时从本地存储加载自定义色板选择
useEffect(() => {
// 尝试从localStorage加载
const savedSelections = loadPaletteSelections();
if (savedSelections && Object.keys(savedSelections).length > 0) {
setCustomPaletteSelections(savedSelections);
setIsCustomPalette(true);
} else {
// 如果没有保存的选择,用当前预设初始化
const initialSelections = presetToSelections(
allPaletteKeys,
paletteOptions[selectedPaletteKeySet]?.keys || []
);
setCustomPaletteSelections(initialSelections);
setIsCustomPalette(false);
}
}, [selectedPaletteKeySet]); // Add selectedPaletteKeySet to the dependency array
// 更新 activeBeadPalette 基于自定义选择和排除列表
useEffect(() => {
const newActiveBeadPalette = fullBeadPalette.filter(color => {
const isSelectedInCustomPalette = customPaletteSelections[color.key];
const isNotExcluded = !excludedColorKeys.has(color.key);
return isSelectedInCustomPalette && isNotExcluded;
});
// 根据选择的色号系统转换调色板
const convertedPalette = convertPaletteToColorSystem(newActiveBeadPalette, selectedColorSystem);
setActiveBeadPalette(convertedPalette);
}, [customPaletteSelections, excludedColorKeys, remapTrigger, selectedColorSystem]);
// --- Event Handlers ---
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++
processFile(file);
}
};
const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer.files && event.dataTransfer.files[0]) {
const file = event.dataTransfer.files[0];
if (file.type.startsWith('image/')) {
setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++
processFile(file);
} else {
alert("请拖放图片文件 (JPG, PNG)");
}
}
};
const handleDragOver = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
};
const processFile = (file: File) => {
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(null); // ++ 重置初始键 ++
// ++ 重置横轴格子数量为默认值 ++
const defaultGranularity = 100;
setGranularity(defaultGranularity);
setGranularityInput(defaultGranularity.toString());
setRemapTrigger(prev => prev + 1); // Trigger full remap for new image
};
reader.onerror = () => {
console.error("文件读取失败");
alert("无法读取文件。");
setInitialGridColorKeys(null); // ++ 重置初始键 ++
}
reader.readAsDataURL(file);
// ++ Reset manual coloring mode when a new file is processed ++
setIsManualColoringMode(false);
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 = 200;
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(null);
};
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}`);
const outputWidth = 500;
const outputHeight = Math.round(outputWidth * aspectRatio);
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 && !BACKGROUND_COLOR_KEYS.includes(cell.key)) {
initialColorCounts[cell.key] = (initialColorCounts[cell.key] || 0) + 1;
}
});
console.log("Initial color counts (excluding background):", 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: 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.");
}
// --- 结束新的全局颜色合并逻辑 ---
console.log("Global color merging complete. Starting background removal.");
// --- Flood Fill Background Process ---
// ... 保持洪水填充算法不变但在mergedData上操作 ...
const visitedForFloodFill: boolean[][] = Array(M).fill(null).map(() => Array(N).fill(false));
// 将递归的floodFill改为迭代实现使用队列避免栈溢出
const iterativeFloodFill = (startR: number, startC: number) => {
// 先检查起始点是否有效
if (startR < 0 || startR >= M || startC < 0 || startC >= N ||
visitedForFloodFill[startR][startC]) {
return;
}
const startCell = mergedData[startR]?.[startC];
if (!startCell || !BACKGROUND_COLOR_KEYS.includes(startCell.key)) {
return;
}
const queue: { r: number; c: number }[] = [{ r: startR, c: startC }];
visitedForFloodFill[startR][startC] = true;
startCell.isExternal = true;
while (queue.length > 0) {
const { r, c } = queue.shift()!;
// 检查四个方向的邻居
const neighbors = [
{ nr: r + 1, nc: c },
{ nr: r - 1, nc: c },
{ nr: r, nc: c + 1 },
{ nr: r, nc: c - 1 }
];
for (const { nr, nc } of neighbors) {
// 检查边界和访问状态
if (nr >= 0 && nr < M && nc >= 0 && nc < N && !visitedForFloodFill[nr][nc]) {
const neighborCell = mergedData[nr]?.[nc];
// 检查是否是背景色
if (neighborCell && BACKGROUND_COLOR_KEYS.includes(neighborCell.key)) {
visitedForFloodFill[nr][nc] = true;
neighborCell.isExternal = true;
queue.push({ r: nr, c: nc });
}
}
}
}
};
// 保留原始的循环但改用迭代版本的flood fill
for (let i = 0; i < N; i++) {
if (!visitedForFloodFill[0][i] && mergedData[0]?.[i] && BACKGROUND_COLOR_KEYS.includes(mergedData[0][i].key)) iterativeFloodFill(0, i);
if (!visitedForFloodFill[M - 1][i] && mergedData[M - 1]?.[i] && BACKGROUND_COLOR_KEYS.includes(mergedData[M - 1][i].key)) iterativeFloodFill(M - 1, i);
}
for (let j = 0; j < M; j++) {
if (!visitedForFloodFill[j][0] && mergedData[j]?.[0] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][0].key)) iterativeFloodFill(j, 0);
if (!visitedForFloodFill[j][N - 1] && mergedData[j]?.[N - 1] && BACKGROUND_COLOR_KEYS.includes(mergedData[j][N - 1].key)) iterativeFloodFill(j, N - 1);
}
console.log("Background flood fill marking complete.");
// --- 绘制和状态更新 ---
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) {
// 使用显示的色号作为统计键值
const displayKey = cell.key;
if (!counts[displayKey]) {
counts[displayKey] = { count: 0, color: cell.color };
}
counts[displayKey].count++;
totalCount++;
}
});
setColorCounts(counts);
setTotalBeadCount(totalCount);
setInitialGridColorKeys(new Set(Object.keys(counts)));
console.log("Color counts updated based on merged data (excluding external background):", counts);
console.log("Total bead count (excluding background):", 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]);
// --- Download function (ensure filename includes palette) ---
const handleDownloadRequest = (options?: GridDownloadOptions) => {
// 调用移动到utils/imageDownloader.ts中的downloadImage函数
downloadImage({
mappedPixelData,
gridDimensions,
colorCounts,
totalBeadCount,
options: options || downloadOptions,
activeBeadPalette,
selectedPaletteKeySet,
selectedColorSystem
});
};
// --- Handler to toggle color exclusion ---
const handleToggleExcludeColor = (key: string) => {
const currentExcluded = excludedColorKeys;
const isExcluding = !currentExcluded.has(key);
if (isExcluding) {
console.log(`---------\nAttempting to EXCLUDE color: ${key}`); // ++ Log Start ++
// --- 确保初始颜色键已记录 ---
if (!initialGridColorKeys) {
console.error("Cannot exclude color: Initial grid color keys not yet calculated.");
alert("无法排除颜色,初始颜色数据尚未准备好,请稍候。");
return;
}
console.log("Initial Grid Keys:", Array.from(initialGridColorKeys)); // ++ Log Initial Keys ++
console.log("Currently Excluded Keys (before this op):", Array.from(currentExcluded)); // ++ Log Current Exclusions ++
const nextExcludedKeys = new Set(currentExcluded); nextExcludedKeys.add(key);
// --- 使用初始颜色键进行重映射目标逻辑 ---
// 1. 从初始网格颜色集合开始
const potentialRemapKeys = new Set(initialGridColorKeys);
console.log("Step 1: Potential Keys (from initial):", Array.from(potentialRemapKeys));
// 2. 移除当前要排除的键
potentialRemapKeys.delete(key);
console.log(`Step 2: Potential Keys (after removing ${key}):`, Array.from(potentialRemapKeys));
// 3. 移除任何*其他*当前也被排除的键
currentExcluded.forEach(excludedKey => {
potentialRemapKeys.delete(excludedKey);
});
console.log("Step 3: Potential Keys (after removing other current exclusions):", Array.from(potentialRemapKeys)); // ++ Log Final Potential Keys ++
// 4. 基于剩余的*初始*颜色键创建重映射调色板
const remapTargetPalette = fullBeadPalette.filter(color => potentialRemapKeys.has(color.key));
const remapTargetKeys = remapTargetPalette.map(p => p.key); // ++ Log Target Palette Keys ++
console.log("Step 4: Remap Target Palette Keys:", remapTargetKeys);
// 5. *** 关键检查 ***:如果在考虑所有排除项后,没有*初始*颜色可供映射,则阻止此次排除
if (remapTargetPalette.length === 0) {
console.warn(`Cannot exclude color '${key}'. No other valid colors from the initial grid remain after considering all current exclusions.`);
alert(`无法排除颜色 ${key},因为图中最初存在的其他可用颜色也已被排除。请先恢复部分其他颜色。`);
console.log("---------"); // ++ Log End ++
return; // 停止排除过程
}
console.log(`Remapping target palette (based on initial grid colors minus all exclusions) contains ${remapTargetPalette.length} colors.`);
// --- 结束修正逻辑 ---
const excludedColorData = fullBeadPalette.find(p => p.key === key);
// 检查排除颜色的数据是否存在
if (!excludedColorData || !mappedPixelData || !gridDimensions) {
console.error("Cannot exclude color: Missing data for remapping.");
alert("无法排除颜色,缺少必要数据。");
console.log("---------"); // ++ Log End ++
return;
}
console.log(`Remapping cells currently using excluded color: ${key}`);
// 仅在需要重映射时创建深拷贝
const newMappedData = mappedPixelData.map(row => row.map(cell => ({...cell})));
let remappedCount = 0; const { N, M } = gridDimensions;
let firstReplacementKey: string | null = null; // Log the first replacement
for (let j = 0; j < M; j++) { for (let i = 0; i < N; i++) {
const cell = newMappedData[j]?.[i];
// 此条件正确地仅针对具有排除键的单元格
if (cell && !cell.isExternal && cell.key === key) {
// *** 使用派生的 remapTargetPalette此处保证非空查找最接近的颜色 ***
const replacementColor = findClosestPaletteColor(excludedColorData.rgb, remapTargetPalette);
if (!firstReplacementKey) firstReplacementKey = replacementColor.key; // ++ Log Replacement Key ++
newMappedData[j][i] = { ...cell, key: replacementColor.key, color: replacementColor.hex };
remappedCount++;
}
}}
console.log(`Remapped ${remappedCount} cells. First replacement key found was: ${firstReplacementKey || 'N/A'}`); // ++ Log Replacement Key ++
// 同时更新状态
setExcludedColorKeys(nextExcludedKeys); // 应用此颜色的排除
setMappedPixelData(newMappedData); // 使用重映射的数据更新
// 基于*新*映射数据重新计算计数
const newCounts: { [key: string]: { count: number; color: string } } = {}; let newTotalCount = 0;
newMappedData.flat().forEach(cell => { if (cell && cell.key && !cell.isExternal) {
if (!newCounts[cell.key]) {
const colorData = fullBeadPalette.find(p => p.key === cell.key);
// 确保颜色数据存在
newCounts[cell.key] = { count: 0, color: colorData?.hex || '#000000' };
}
newCounts[cell.key].count++; newTotalCount++;
}});
setColorCounts(newCounts); setTotalBeadCount(newTotalCount);
console.log("State updated after exclusion and local remap based on initial grid colors.");
console.log("---------"); // ++ Log End ++
// ++ 在更新状态后,重新绘制 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: ${key}`); // ++ Log Start ++
console.log(`Re-including color: ${key}. Triggering full remap.`);
const nextExcludedKeys = new Set(currentExcluded); nextExcludedKeys.delete(key);
setExcludedColorKeys(nextExcludedKeys);
// 此处无需重置 initialGridColorKeys完全重映射会通过 pixelateImage 重新计算它
setRemapTrigger(prev => prev + 1); // *** KEPT setRemapTrigger here for re-inclusion ***
console.log("---------"); // ++ Log End ++
}
// ++ Exit manual mode if colors are excluded/included ++
setIsManualColoringMode(false);
setSelectedColor(null);
};
// --- Tooltip Logic ---
// --- Canvas Interaction ---
// ++ 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];
// 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;
if (!wasExternal && previousKey !== TRANSPARENT_KEY && newColorCounts[previousKey]) {
newColorCounts[previousKey].count--;
if (newColorCounts[previousKey].count <= 0) {
delete newColorCounts[previousKey];
}
newTotalCount--;
}
if (!newCellData.isExternal && newCellData.key !== TRANSPARENT_KEY) {
if (!newColorCounts[newCellData.key]) {
const colorInfo = fullBeadPalette.find(p => p.key === newCellData.key);
newColorCounts[newCellData.key] = {
count: 0,
color: colorInfo?.hex || '#000000'
};
}
newColorCounts[newCellData.key].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 = (key: string, isSelected: boolean) => {
setCustomPaletteSelections(prev => ({
...prev,
[key]: isSelected
}));
setIsCustomPalette(true);
};
// 应用预设到自定义色板
const handleApplyPreset = (presetKey: string) => {
// 检查是否为有效的预设键
if (!Object.keys(paletteOptions).includes(presetKey)) {
console.warn(`无效的预设键: ${presetKey}`);
return;
}
const typedPresetKey = presetKey as PaletteOptionKey;
const newSelections = presetToSelections(
allPaletteKeys,
paletteOptions[typedPresetKey].keys || []
);
setCustomPaletteSelections(newSelections);
setSelectedPaletteKeySet(typedPresetKey); // 同步更新预设选择状态
setIsCustomPalette(false); // 应用预设后,标记为非自定义(除非用户再次修改)
// 不要在这里关闭编辑器,让用户可以继续编辑
// setIsCustomPaletteEditorOpen(false);
};
// 保存自定义色板并应用
const handleSaveCustomPalette = () => {
savePaletteSelections(customPaletteSelections);
setIsCustomPalette(true);
setIsCustomPaletteEditorOpen(false);
// 触发图像重新处理
setRemapTrigger(prev => prev + 1);
// 退出手动上色模式
setIsManualColoringMode(false);
setSelectedColor(null);
};
// ++ 新增:导出自定义色板配置 ++
const handleExportCustomPalette = () => {
const selectedKeys = Object.entries(customPaletteSelections)
.filter(([, isSelected]) => isSelected)
.map(([key]) => key);
if (selectedKeys.length === 0) {
alert("当前没有选中的颜色,无法导出。");
return;
}
const blob = new Blob([JSON.stringify({ selectedKeys }, 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 (!data || !Array.isArray(data.selectedKeys)) {
throw new Error("无效的文件格式:缺少 'selectedKeys' 数组。");
}
const importedKeys = data.selectedKeys as string[];
const validKeys = new Set(allPaletteKeys);
const validImportedKeys = importedKeys.filter(key => validKeys.has(key));
const invalidKeys = importedKeys.filter(key => !validKeys.has(key));
if (invalidKeys.length > 0) {
console.warn("导入时发现无效的颜色key:", invalidKeys);
alert(`导入完成,但以下色号无效已被忽略:\n${invalidKeys.join(', ')}`);
}
if (validImportedKeys.length === 0) {
alert("导入的文件中不包含任何有效的色号。");
return;
}
// 基于有效导入的key创建新的selections对象
const newSelections = presetToSelections(allPaletteKeys, validImportedKeys);
setCustomPaletteSelections(newSelections);
setIsCustomPalette(true); // 标记为自定义
alert(`成功导入 ${validImportedKeys.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 handleColorSystemChange = (event: ChangeEvent<HTMLSelectElement>) => {
const newColorSystem = event.target.value as ColorSystem;
setSelectedColorSystem(newColorSystem);
};
return (
<>
{/* 添加自定义动画样式 */}
<style dangerouslySetInnerHTML={{ __html: floatAnimation }} />
{/* ++ 修改:添加 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 */}
<div className="relative z-10 py-6">
{/* Icon background and border */}
<div className="flex justify-center mb-4 animate-float">
<div className="grid grid-cols-4 gap-1 p-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-100 dark:border-gray-700">
{/* Bead colors remain the same */}
{['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={`w-3 h-3 rounded-full ${color} transition-all duration-500 hover:scale-110 shadow-sm`}
style={{animation: `float ${2 + (i % 3)}s ease-in-out infinite ${i * 0.1}s`}}
></div>
))}
</div>
</div>
{/* Title gradient might need adjustment, but let's keep it for now */}
<h1 className="text-2xl sm:text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-blue-600 via-purple-500 to-pink-500 tracking-tight drop-shadow-sm">
稿
</h1>
{/* 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>
{/* Description text color */}
<p className="mt-3 text-sm sm:text-base text-gray-600 dark:text-gray-400 max-w-lg mx-auto leading-relaxed">
</p>
{/* 添加 GitHub 和小红书链接 */}
<div className="mt-2 flex items-center justify-center gap-3">
{/* Github link */}
<a href="https://github.com/Zippland/perler-beads.git" target="_blank" rel="noopener noreferrer" className="text-xs text-blue-500 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors duration-200 hover:underline flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-0.5">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
Github
</a>
{/* Xiaohongshu link */}
<a href="https://www.xiaohongshu.com/user/profile/623e8b080000000010007721" target="_blank" rel="noopener noreferrer" className="text-xs text-rose-500 dark:text-rose-400 hover:text-rose-700 dark:hover:text-rose-300 transition-colors duration-200 hover:underline flex items-center justify-center">
<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>
</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={() => fileInputRef.current?.click()}
className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-6 sm:p-8 text-center cursor-pointer hover:border-blue-400 dark:hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-gray-800 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 </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" 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-200):
</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="200"
/>
</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>
{/* 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>
{/* 确认按钮 - 现在对应两个输入框 */}
<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 flex-shrink-0"
></button>
</div>
</div>
{/* 色号系统选择器 */}
<div className="sm:col-span-2">
<label htmlFor="colorSystemSelect" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">:</label>
<select
id="colorSystemSelect"
value={selectedColorSystem}
onChange={handleColorSystemChange}
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"
>
{colorSystemOptions.map(option => (
<option key={option.key} value={option.key} className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200">
{option.name}
</option>
))}
</select>
</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}
onApplyPreset={handleApplyPreset}
onSaveCustomPalette={handleSaveCustomPalette}
onClose={() => setIsCustomPaletteEditorOpen(false)}
paletteOptions={paletteOptions}
// ++ 传递新的处理函数 ++
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>
{/* ++ RENDER Button/Palette ONLY in manual mode above canvas ++ */}
{isManualColoringMode && mappedPixelData && gridDimensions && (
// Apply dark mode styles to manual mode container
<div className="w-full mb-4 p-4 bg-blue-50 dark:bg-gray-800 rounded-xl shadow-md border border-blue-100 dark:border-gray-700">
{/* Finish Manual Coloring Button (already has distinct colors, maybe keep as is) */}
<button
onClick={() => {
setIsManualColoringMode(false); // Always exit mode here
setSelectedColor(null);
setTooltipData(null);
}}
className={`w-full py-2.5 px-4 text-sm sm:text-base rounded-lg transition-all duration-200 flex items-center justify-center gap-2 bg-red-500 hover:bg-red-600 text-white shadow-sm hover:shadow-md`} // Keep red for contrast?
>
<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="M6 18L18 6M6 6l12 12" /> </svg>
</button>
{/* Color Palette (only in manual mode) */}
<div className="mt-4">
<div className="flex justify-center mb-3">
{/* Apply dark mode styles to the info box */}
<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">
{/* Icon color */}
<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>
{/* Text color implicitly handled by parent */}
<span>/</span>
</div>
{/* Separator color */}
<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">
{/* Icon color */}
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{/* Text color implicitly handled by parent */}
<span>使</span>
</div>
{/* Separator color */}
<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">
{/* Icon color */}
<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>
{/* Text color implicitly handled by parent */}
<span>Ctrl/Cmd+</span>
</div>
</div>
</div>
{/* ColorPalette component will need internal dark mode styles */}
<ColorPalette
colors={[transparentColorData, ...currentGridColors]}
selectedColor={selectedColor}
onColorSelect={setSelectedColor}
transparentKey={TRANSPARENT_KEY}
selectedColorSystem={selectedColorSystem}
/>
</div>
</div>
)} {/* ++ End of RENDER Button/Palette ++ */}
{/* 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">
{/* 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-hidden"
style={{ minHeight: '150px' }}>
{/* PixelatedPreviewCanvas component needs internal changes for dark mode drawing */}
<PixelatedPreviewCanvas
canvasRef={pixelatedCanvasRef}
mappedPixelData={mappedPixelData}
gridDimensions={gridDimensions}
isManualColoringMode={isManualColoringMode}
onInteraction={handleCanvasInteraction}
/>
</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((key) => {
const isExcluded = excludedColorKeys.has(key);
const count = colorCounts[key].count;
const colorHex = colorCounts[key].color;
return (
<li
key={key}
onClick={() => handleToggleExcludeColor(key)}
// 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 ? `点击恢复 ${key}` : `点击排除 ${key}`}
>
<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'}`}>{getDisplayColorKey(key, selectedColorSystem)}</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(key => {
const colorData = fullBeadPalette.find(color => color.key === key);
return (
<li key={key} 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 || '#666666' }}
></span>
<span className="font-mono text-xs text-gray-800 dark:text-gray-200">{getDisplayColorKey(key, selectedColorSystem)}</span>
</div>
<button
onClick={() => {
// 实现恢复单个颜色的逻辑
const newExcludedKeys = new Set(excludedColorKeys);
newExcludedKeys.delete(key);
setExcludedColorKeys(newExcludedKeys);
setRemapTrigger(prev => prev + 1);
setIsManualColoringMode(false);
setSelectedColor(null);
console.log(`Restored color: ${key}`);
}}
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"> {/* Wrapper div */}
{/* Keeping button styles bright for visibility in both modes */}
<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>
</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>
{/* 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}
/>
</div>
</>
);
}