在页面中添加自定义调色板编辑器,支持用户选择和保存自定义颜色。同时,优化相似度阈值输入和确认按钮的处理逻辑,提升用户体验和交互性。

This commit is contained in:
zihanjian
2025-05-04 16:39:56 +08:00
parent 9ccf6e7129
commit f125d109f0
3 changed files with 637 additions and 158 deletions

View File

@@ -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<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');
@@ -146,6 +149,9 @@ export default function Home() {
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 originalCanvasRef = useRef<HTMLCanvasElement>(null);
const pixelatedCanvasRef = useRef<HTMLCanvasElement>(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<HTMLInputElement>) => {
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<HTMLSelectElement>) => {
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<HTMLInputElement>) => {
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<string, RgbColor>();
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<string, PaletteColor>();
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<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(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() {
<div className="w-full flex flex-col items-center space-y-5 sm:space-y-6">
{/* ++ HIDE Control Row in manual mode ++ */}
{!isManualColoringMode && (
// Apply dark mode styles to the control row container
<div className="w-full md:max-w-2xl grid grid-cols-1 sm:grid-cols-4 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">
/* 修改控制面板网格布局 */
<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):
(10-200):
</label>
<div className="flex items-center gap-2">
{/* Input field styles */}
@@ -1158,71 +1283,89 @@ export default function Home() {
min="10"
max="200"
/>
{/* Button styles (can reuse existing primary button styles) */}
<button
onClick={handleConfirmGranularity}
className="h-9 bg-blue-500 hover:bg-blue-600 text-white text-sm px-2.5 rounded-md whitespace-nowrap transition-colors duration-200 shadow-sm"
></button>
</div>
</div>
{/* Similarity Threshold Slider */}
{/* Similarity Threshold Input */}
<div className="flex-1">
{/* Label color and value color */}
<label htmlFor="similarityThreshold" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">
: <span className="font-semibold text-purple-600 dark:text-purple-400">{similarityThreshold}</span>
{/* 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>
{/* Slider accent color */}
<input
type="range"
id="similarityThreshold"
min="0"
max="100"
value={similarityThreshold}
onChange={handleSimilarityChange}
className="w-full h-9 accent-purple-600 dark:accent-purple-400" // Adjust accent for dark mode
/>
{/* Min/Max label color */}
<div className="flex justify-between text-xs text-gray-500 dark:text-gray-400 -mt-1">
<span></span>
<span></span>
<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>
{/* Palette Selector */}
<div className="flex-1">
{/* Label color */}
<label htmlFor="paletteSelect" className="block text-xs sm:text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5 sm:mb-2">:</label>
{/* Select field styles */}
<select
id="paletteSelect"
value={selectedPaletteKeySet}
onChange={handlePaletteChange}
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"
>
{(Object.keys(paletteOptions) as PaletteOptionKey[]).map(key => (
<option key={key} value={key} className="bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-200">{paletteOptions[key].name}</option> // Style options too
))}
</select>
</div>
{/* Pixelation Mode Selector */}
<div className="flex-1">
<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>
{/* 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"
<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 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"
>
<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>
<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>
)} {/* ++ End of HIDE Control Row ++ */}
)}
{/* 自定义色板编辑器弹窗 - 这是新增的部分 */}
{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">
<div className="p-4 sm:p-6">
<CustomPaletteEditor
allColors={fullBeadPalette}
currentSelections={customPaletteSelections}
onSelectionChange={handleSelectionChange}
onApplyPreset={handleApplyPreset}
onSaveCustomPalette={handleSaveCustomPalette}
onClose={() => setIsCustomPaletteEditorOpen(false)}
paletteOptions={paletteOptions}
/>
</div>
</div>
</div>
)}
{/* Output Section */}
<div className="w-full md:max-w-2xl">
@@ -1316,7 +1459,7 @@ export default function Home() {
<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">
{/* Title color */}
<h3 className="text-lg font-semibold mb-1 text-gray-700 dark:text-gray-200 text-center">
& ({paletteOptions[selectedPaletteKeySet]?.name || '未知色板'})
</h3>
{/* Subtitle color */}
<p className="text-xs text-center text-gray-500 dark:text-gray-400 mb-3">: {totalBeadCount} </p>
@@ -1358,7 +1501,16 @@ export default function Home() {
{excludedColorKeys.size > 0 && (
// Apply dark mode styles to the "restore all" button
<button
onClick={() => { /* ... */ }}
onClick={() => {
// 清空排除的颜色
setExcludedColorKeys(new Set());
// 触发重新映射
setRemapTrigger(prev => prev + 1);
// 退出手动上色模式
setIsManualColoringMode(false);
setSelectedColor(null);
console.log("Restored all excluded colors");
}}
className="mt-3 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"
>
({excludedColorKeys.size})
@@ -1375,7 +1527,16 @@ export default function Home() {
{excludedColorKeys.size > 0 && (
// Apply dark mode styles to the inline "restore all" button
<button
onClick={() => { /* ... */ }}
onClick={() => {
// 清空排除的颜色
setExcludedColorKeys(new Set());
// 触发重新映射
setRemapTrigger(prev => prev + 1);
// 退出手动上色模式
setIsManualColoringMode(false);
setSelectedColor(null);
console.log("Restored all excluded colors");
}}
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})

View File

@@ -0,0 +1,272 @@
'use client';
import React, { useState, useEffect } from 'react';
import { PaletteColor } from '../utils/pixelation';
import { PaletteSelections } from '../utils/localStorageUtils';
// 对颜色进行分组的工具函数,按前缀分组
function groupColorsByPrefix(colors: PaletteColor[]): Record<string, PaletteColor[]> {
const groups: Record<string, PaletteColor[]> = {};
colors.forEach(color => {
const prefix = color.key.match(/^[A-Z]+/)?.[0] || '其他';
if (!groups[prefix]) {
groups[prefix] = [];
}
groups[prefix].push(color);
});
// 对每个组内的颜色按键进行排序
Object.keys(groups).forEach(prefix => {
groups[prefix].sort((a, b) => {
const numA = parseInt(a.key.replace(/^[A-Z]+/, ''), 10) || 0;
const numB = parseInt(b.key.replace(/^[A-Z]+/, ''), 10) || 0;
return numA - numB;
});
});
return groups;
}
interface CustomPaletteEditorProps {
allColors: PaletteColor[];
currentSelections: PaletteSelections;
onSelectionChange: (key: string, isSelected: boolean) => void;
onApplyPreset: (presetKey: string) => void;
onSaveCustomPalette: () => void;
onClose: () => void;
paletteOptions: Record<string, { name: string; keys: string[] }>;
}
const CustomPaletteEditor: React.FC<CustomPaletteEditorProps> = ({
allColors,
currentSelections,
onSelectionChange,
onApplyPreset,
onSaveCustomPalette,
onClose,
paletteOptions
}) => {
// 用于跟踪当前展开的颜色组
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [searchTerm, setSearchTerm] = useState('');
const [selectedCount, setSelectedCount] = useState(0);
// 计算已选择的颜色数量
useEffect(() => {
const count = Object.values(currentSelections).filter(Boolean).length;
setSelectedCount(count);
}, [currentSelections]);
// 根据搜索词过滤颜色
const filteredColors = searchTerm
? allColors.filter(color =>
color.key.toLowerCase().includes(searchTerm.toLowerCase())
)
: allColors;
// 对过滤后的颜色进行分组
const colorGroups = groupColorsByPrefix(filteredColors);
// 切换组展开状态
const toggleGroup = (prefix: string) => {
setExpandedGroups(prev => ({
...prev,
[prefix]: !prev[prefix]
}));
};
// 切换所有颜色的选择状态
const toggleAllColors = (selected: boolean) => {
allColors.forEach(color => {
onSelectionChange(color.key, selected);
});
};
// 切换一个组内所有颜色的选择状态
const toggleGroupColors = (prefix: string, selected: boolean) => {
colorGroups[prefix].forEach(color => {
onSelectionChange(color.key, selected);
});
};
return (
<div className="flex flex-col h-full max-h-[80vh]">
{/* 头部 */}
<div className="flex justify-between items-center border-b dark:border-gray-700 pb-3 mb-3">
<h2 className="text-lg font-semibold text-gray-800 dark:text-gray-100 flex items-center">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2 text-blue-500" 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>
<span className="ml-2 text-sm text-blue-500 dark:text-blue-400">({selectedCount} )</span>
</h2>
<button
onClick={onClose}
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 搜索和预设 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div>
<div className="relative">
<input
type="text"
placeholder="搜索色号..."
value={searchTerm}
onChange={(e) => 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"
/>
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 text-gray-500 dark:text-gray-400" 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>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<select
onChange={(e) => {
if (e.target.value) {
onApplyPreset(e.target.value);
}
}}
className="w-full px-3 py-2 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"
value=""
>
<option value="" disabled>...</option>
{Object.entries(paletteOptions).map(([key, { name }]) => (
<option key={key} value={key}>{name}</option>
))}
</select>
</div>
</div>
{/* 说明文本 */}
<div className="mb-4 text-xs text-gray-600 dark:text-gray-400 bg-blue-50 dark:bg-blue-900/20 p-2 rounded-md border border-blue-100 dark:border-blue-800/30">
<p className="flex items-start">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-1 text-blue-500 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
使"保存并应用"
</p>
</div>
{/* 快捷操作按钮 */}
<div className="flex flex-wrap gap-2 mb-4">
<button
onClick={() => toggleAllColors(true)}
className="px-3 py-1.5 text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-md hover:bg-green-200 dark:hover:bg-green-900/50"
>
</button>
<button
onClick={() => toggleAllColors(false)}
className="px-3 py-1.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-md hover:bg-red-200 dark:hover:bg-red-900/50"
>
</button>
</div>
{/* 颜色列表 */}
<div className="flex-1 overflow-y-auto pr-1">
{Object.keys(colorGroups).sort().map(prefix => (
<div key={prefix} className="mb-3 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
{/* 组标题 */}
<div
className="flex justify-between items-center px-3 py-2 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-750"
onClick={() => toggleGroup(prefix)}
>
<div className="flex items-center">
<span className="font-medium text-gray-800 dark:text-gray-200">{prefix} </span>
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({colorGroups[prefix].length} )
</span>
</div>
<div className="flex items-center">
{/* 组操作按钮 */}
<button
onClick={(e) => {
e.stopPropagation();
toggleGroupColors(prefix, true);
}}
className="text-xs text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 mr-2"
>
</button>
<button
onClick={(e) => {
e.stopPropagation();
toggleGroupColors(prefix, false);
}}
className="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 mr-2"
>
</button>
{/* 展开/收起图标 */}
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-4 w-4 text-gray-500 dark:text-gray-400 transform transition-transform ${expandedGroups[prefix] ? '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>
</div>
</div>
{/* 组内容 */}
{expandedGroups[prefix] && (
<div className="p-3 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2">
{colorGroups[prefix].map(color => (
<label
key={color.key}
className="flex items-center space-x-2 p-1.5 hover:bg-gray-50 dark:hover:bg-gray-750 rounded cursor-pointer"
>
<input
type="checkbox"
checked={!!currentSelections[color.key]}
onChange={(e) => onSelectionChange(color.key, e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:ring-offset-gray-800"
/>
<div
className="w-6 h-6 rounded-sm border border-gray-300 dark:border-gray-600 flex-shrink-0"
style={{ backgroundColor: color.hex }}
/>
<span className="text-sm text-gray-800 dark:text-gray-200">{color.key}</span>
</label>
))}
</div>
)}
</div>
))}
</div>
{/* 底部按钮 */}
<div className="mt-4 pt-3 border-t dark:border-gray-700 flex justify-between">
<button
onClick={onClose}
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
>
</button>
<button
onClick={onSaveCustomPalette}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
</button>
</div>
</div>
);
};
export default CustomPaletteEditor;

View File

@@ -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;
}