diff --git a/src/app/page.tsx b/src/app/page.tsx index cd5369d..85d0430 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -118,12 +118,15 @@ const BACKGROUND_COLOR_KEYS = ['T1', 'H1', 'H2']; // 可以根据需要调整 // 1. 导入新组件 import PixelatedPreviewCanvas from '../components/PixelatedPreviewCanvas'; import GridTooltip from '../components/GridTooltip'; +import CustomPaletteEditor from '../components/CustomPaletteEditor'; +import { loadPaletteSelections, savePaletteSelections, presetToSelections, PaletteSelections } from '../utils/localStorageUtils'; export default function Home() { const [originalImageSrc, setOriginalImageSrc] = useState(null); const [granularity, setGranularity] = useState(50); const [granularityInput, setGranularityInput] = useState("50"); const [similarityThreshold, setSimilarityThreshold] = useState(30); + const [similarityThresholdInput, setSimilarityThresholdInput] = useState("30"); // 添加像素化模式状态 const [pixelationMode, setPixelationMode] = useState(PixelationMode.Dominant); // 默认为卡通模式 const [selectedPaletteKeySet, setSelectedPaletteKeySet] = useState('all'); @@ -146,6 +149,9 @@ export default function Home() { const [selectedColor, setSelectedColor] = useState(null); // 新增状态变量:控制打赏弹窗 const [isDonationModalOpen, setIsDonationModalOpen] = useState(false); + const [customPaletteSelections, setCustomPaletteSelections] = useState({}); + const [isCustomPaletteEditorOpen, setIsCustomPaletteEditorOpen] = useState(false); + const [isCustomPalette, setIsCustomPalette] = useState(false); const originalCanvasRef = useRef(null); const pixelatedCanvasRef = useRef(null); @@ -170,10 +176,11 @@ export default function Home() { setActiveBeadPalette(newActiveBeadPalette); }, [selectedPaletteKeySet, excludedColorKeys, remapTrigger]); // ++ 添加 remapTrigger 依赖 ++ - // ++ 添加:当granularity状态改变时同步更新输入框的值 ++ + // ++ 添加:当状态变化时同步更新输入框的值 ++ useEffect(() => { setGranularityInput(granularity.toString()); - }, [granularity]); + setSimilarityThresholdInput(similarityThreshold.toString()); + }, [granularity, similarityThreshold]); // ++ Calculate unique colors currently on the grid for the palette ++ const currentGridColors = useMemo(() => { @@ -189,6 +196,33 @@ export default function Home() { return Array.from(uniqueColorsMap.values()).sort((a, b) => sortColorKeys(a.key, b.key)); }, [mappedPixelData]); // Recalculate when pixel data changes + // 初始化时从本地存储加载自定义色板选择 + 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); + } + }, []); + + // 更新 activeBeadPalette 基于自定义选择和排除列表 + useEffect(() => { + const newActiveBeadPalette = fullBeadPalette.filter(color => { + const isSelectedInCustomPalette = customPaletteSelections[color.key]; + const isNotExcluded = !excludedColorKeys.has(color.key); + return isSelectedInCustomPalette && isNotExcluded; + }); + setActiveBeadPalette(newActiveBeadPalette); + }, [customPaletteSelections, excludedColorKeys, fullBeadPalette, remapTrigger]); // --- Event Handlers --- @@ -251,8 +285,14 @@ export default function Home() { setGranularityInput(event.target.value); }; - // ++ 新增:处理确认按钮点击的函数 ++ - const handleConfirmGranularity = () => { + // ++ 添加:处理相似度输入框变化的函数 ++ + const handleSimilarityThresholdInputChange = (event: ChangeEvent) => { + setSimilarityThresholdInput(event.target.value); + }; + + // ++ 修改:处理确认按钮点击的函数,同时处理两个参数 ++ + const handleConfirmParameters = () => { + // 处理格子数 const minGranularity = 10; const maxGranularity = 200; let newGranularity = parseInt(granularityInput, 10); @@ -263,33 +303,66 @@ export default function Home() { newGranularity = maxGranularity; } - // 只有在值确实改变时才触发更新 - if (newGranularity !== granularity) { + // 处理相似度阈值 + 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); // 更新主状态 - setRemapTrigger(prev => prev + 1); // 触发重映射 - // ++ Exit manual coloring mode if parameters change ++ + 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 handlePaletteChange = (event: ChangeEvent) => { - const newKey = event.target.value as PaletteOptionKey; - if (paletteOptions[newKey]) { - setSelectedPaletteKeySet(newKey); - setExcludedColorKeys(new Set()); // ++ 重置排除列表 ++ - setRemapTrigger(prev => prev + 1); // Trigger full remap - } else { - console.warn(`Attempted to select invalid palette key: ${newKey}. Keeping current selection.`); - } - // ++ Exit manual coloring mode if palette changes ++ - setIsManualColoringMode(false); - setSelectedColor(null); - }; + const newKey = event.target.value as PaletteOptionKey; + if (paletteOptions[newKey]) { + setSelectedPaletteKeySet(newKey); + + // 更新自定义色板选择 + const newSelections = presetToSelections( + allPaletteKeys, + paletteOptions[newKey]?.keys || [] + ); + setCustomPaletteSelections(newSelections); + setIsCustomPalette(false); + + setExcludedColorKeys(new Set()); // 重置排除列表 + setRemapTrigger(prev => prev + 1); // 触发重新映射 + } else { + console.warn(`Attempted to select invalid palette key: ${newKey}. Keeping current selection.`); + } + // 退出手动上色模式 + setIsManualColoringMode(false); + setSelectedColor(null); + }; const handleSimilarityChange = (event: ChangeEvent) => { setSimilarityThreshold(parseInt(event.target.value, 10)); @@ -370,7 +443,7 @@ export default function Home() { originalCtx.drawImage(img, 0, 0, img.width, img.height); console.log("Original image drawn."); - // 使用calculatePixelGrid替换原来的颜色映射逻辑 + // 1. 使用calculatePixelGrid进行初始颜色映射 console.log("Starting initial color mapping using calculatePixelGrid..."); const initialMappedData = calculatePixelGrid( originalCtx, @@ -382,98 +455,110 @@ export default function Home() { mode, t1FallbackColor ); - console.log(`Initial data mapping complete using mode ${mode}. Starting region merging...`); + console.log(`Initial data mapping complete using mode ${mode}. Starting global color merging...`); - // --- Region Merging Step --- + // --- 新的全局颜色合并逻辑 --- const keyToRgbMap = new Map(); - currentPalette.forEach(p => keyToRgbMap.set(p.key, p.rgb)); - const visited: boolean[][] = Array(M).fill(null).map(() => Array(N).fill(false)); - const mergedData: MappedPixel[][] = Array(M).fill(null).map(() => Array(N).fill({ key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false })); - const similarityThresholdValue = threshold; + const keyToColorDataMap = new Map(); + currentPalette.forEach(p => { + keyToRgbMap.set(p.key, p.rgb); + keyToColorDataMap.set(p.key, p); + }); - for (let j = 0; j < M; j++) { - for (let i = 0; i < N; i++) { - if (visited[j][i]) continue; - - const startCellData = initialMappedData[j][i]; - const startRgb = keyToRgbMap.get(startCellData.key); - - if (!startRgb) { - console.warn(`RGB not found for key ${startCellData.key} at (${j},${i}) during merging. Using fallback.`); - visited[j][i] = true; - mergedData[j][i] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }; - continue; + // 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); - const currentRegionCells: { r: number; c: number }[] = []; - const beadKeyCountsInRegion: { [key: string]: number } = {}; - const queue: { r: number; c: number }[] = [{ r: j, c: i }]; - visited[j][i] = true; + // 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."); + } - while (queue.length > 0) { - const { r, c } = queue.shift()!; - const currentCellData = initialMappedData[r][c]; - const currentRgb = keyToRgbMap.get(currentCellData.key); - - if (!currentRgb) { - console.warn(`RGB not found for key ${currentCellData.key} at (${r},${c}) during BFS. Skipping.`); - continue; + 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(); + + // 对每个颜色按频率从高到低处理 + 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(startRgb, currentRgb); - + + // 计算颜色距离 + const dist = colorDistance(currentRgb, lowerFreqRgb); + + // 如果距离小于阈值,将低频颜色替换为高频颜色 if (dist < similarityThresholdValue) { - currentRegionCells.push({ r, c }); - beadKeyCountsInRegion[currentCellData.key] = (beadKeyCountsInRegion[currentCellData.key] || 0) + 1; - - 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 && !visited[nr][nc]) { - const neighborCellData = initialMappedData[nr][nc]; - const neighborRgb = keyToRgbMap.get(neighborCellData.key); - if (neighborRgb && colorDistance(startRgb, neighborRgb) < similarityThresholdValue) { - visited[nr][nc] = true; - queue.push({ r: nr, c: nc }); + 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 + }; + } } } } } } - - // --- Determine Dominant Color and Recolor the Region --- - if (currentRegionCells.length > 0) { - let dominantKey = ''; - let maxCount = 0; - for (const key in beadKeyCountsInRegion) { - if (beadKeyCountsInRegion[key] > maxCount) { - maxCount = beadKeyCountsInRegion[key]; - dominantKey = key; - } - } - if (!dominantKey) { - dominantKey = startCellData.key; - console.warn(`No dominant key found for region starting at (${j},${i}), using start cell key: ${dominantKey}`); - } - - const dominantColorData = currentPalette.find(p => p.key === dominantKey); - if (dominantColorData) { - const dominantColorHex = dominantColorData.hex; - currentRegionCells.forEach(({ r, c }) => { - mergedData[r][c] = { key: dominantKey, color: dominantColorHex, isExternal: false }; - }); - } else { - console.warn(`Dominant key "${dominantKey}" determined but not found in *active* palette during merge. Using fallback.`); - currentRegionCells.forEach(({ r, c }) => { - mergedData[r][c] = { key: t1FallbackColor.key, color: t1FallbackColor.hex, isExternal: false }; - }); - } - } else { - mergedData[j][i] = { ...startCellData, isExternal: false }; - } - } } - console.log("Region merging complete. Starting background removal."); + 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上操作 ... @@ -597,7 +682,7 @@ export default function Home() { // setTotalBeadCount(0); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [originalImageSrc, granularity, similarityThreshold, selectedPaletteKeySet, pixelationMode, remapTrigger]); // 添加pixelationMode到依赖数组 + }, [originalImageSrc, granularity, similarityThreshold, customPaletteSelections, pixelationMode, remapTrigger]); // --- Download function (ensure filename includes palette) --- const handleDownloadImage = () => { @@ -979,6 +1064,46 @@ export default function Home() { } }; + // 处理自定义色板中单个颜色的选择变化 + 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); + }; + return ( <> {/* 添加自定义动画样式 */} @@ -1139,13 +1264,13 @@ export default function Home() {
{/* ++ HIDE Control Row in manual mode ++ */} {!isManualColoringMode && ( - // Apply dark mode styles to the control row container -
+ /* 修改控制面板网格布局 */ +
{/* Granularity Input */}
{/* Label color */}
{/* Input field styles */} @@ -1158,71 +1283,89 @@ export default function Home() { min="10" max="200" /> - {/* Button styles (can reuse existing primary button styles) */} -
- {/* Similarity Threshold Slider */} + {/* Similarity Threshold Input */}
- {/* Label color and value color */} -
- )} {/* ++ End of HIDE Control Row ++ */} + )} + + {/* 自定义色板编辑器弹窗 - 这是新增的部分 */} + {isCustomPaletteEditorOpen && ( +
+
+
+ setIsCustomPaletteEditorOpen(false)} + paletteOptions={paletteOptions} + /> +
+
+
+ )} {/* Output Section */}
@@ -1316,7 +1459,7 @@ export default function Home() {
{/* Title color */}

- 颜色统计 & 去除杂色 ({paletteOptions[selectedPaletteKeySet]?.name || '未知色板'}) + 去除杂色

{/* Subtitle color */}

点击下方列表中的颜色可将其从可用列表中排除。总计: {totalBeadCount} 颗

@@ -1358,7 +1501,16 @@ export default function Home() { {excludedColorKeys.size > 0 && ( // Apply dark mode styles to the "restore all" button +
+ + {/* 搜索和预设 */} +
+
+
+ setSearchTerm(e.target.value)} + className="w-full px-3 py-2 pl-9 border border-gray-300 dark:border-gray-600 rounded-md text-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200 focus:ring-blue-500 focus:border-blue-500" + /> +
+ + + +
+
+
+ +
+ +
+
+ + {/* 说明文本 */} +
+

+ + + + 在此选择要使用的拼豆色系。您可以选择预设色板,然后根据需要手动添加或删除特定色号。完成后点击底部的"保存并应用"按钮。 +

+
+ + {/* 快捷操作按钮 */} +
+ + +
+ + {/* 颜色列表 */} +
+ {Object.keys(colorGroups).sort().map(prefix => ( +
+ {/* 组标题 */} +
toggleGroup(prefix)} + > +
+ {prefix} 系列 + + ({colorGroups[prefix].length} 色) + +
+ +
+ {/* 组操作按钮 */} + + + + {/* 展开/收起图标 */} + + + +
+
+ + {/* 组内容 */} + {expandedGroups[prefix] && ( +
+ {colorGroups[prefix].map(color => ( +
+ ))} +
+ + {/* 底部按钮 */} +
+ + +
+
+ ); +}; + +export default CustomPaletteEditor; \ No newline at end of file diff --git a/src/utils/localStorageUtils.ts b/src/utils/localStorageUtils.ts new file mode 100644 index 0000000..a8fbe42 --- /dev/null +++ b/src/utils/localStorageUtils.ts @@ -0,0 +1,46 @@ +const STORAGE_KEY = 'customPerlerPaletteSelections'; + +export interface PaletteSelections { + [key: string]: boolean; +} + +/** + * 保存自定义色板选择状态到localStorage + */ +export function savePaletteSelections(selections: PaletteSelections): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(selections)); + } catch (error) { + console.error("无法保存色板选择到本地存储:", error); + } +} + +/** + * 从localStorage加载自定义色板选择状态 + */ +export function loadPaletteSelections(): PaletteSelections | null { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (error) { + console.error("无法从本地存储加载色板选择:", error); + localStorage.removeItem(STORAGE_KEY); // 清除无效数据 + } + return null; +} + +/** + * 将预设色板转换为选择状态对象 + */ +export function presetToSelections(allKeys: string[], presetKeys: string[]): PaletteSelections { + const presetSet = new Set(presetKeys); + const selections: PaletteSelections = {}; + + allKeys.forEach(key => { + selections[key] = presetSet.has(key); + }); + + return selections; +} \ No newline at end of file