From 3e99f1fd97c999c5a72595fdd15559758bf15602 Mon Sep 17 00:00:00 2001 From: Mahnoor-Zaffar <1999mahnoor@gmail.com> Date: Sun, 5 Jul 2026 20:06:26 +0500 Subject: [PATCH] feat: add image color palette extractor utility Add a client-side tool to extract dominant colors from uploaded images and export palettes as CSS, Tailwind, Sass, or JSON. --- src/App.jsx | 5 + src/config/sidebarSections.js | 6 + src/pages/DevUtilities/DevUtilities.jsx | 21 + .../devutilities/ColorPaletteExtractor.jsx | 644 ++++++++++++++++++ 4 files changed, 676 insertions(+) create mode 100644 src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx diff --git a/src/App.jsx b/src/App.jsx index 4f7ec31..082fe87 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -79,6 +79,7 @@ import UuidGenerator from "./pages/DevUtilities/devutilities/UuidGenerator"; import WordCounter from "./pages/DevUtilities/devutilities/WordCounter"; import GitCommandBuilder from "./pages/DevUtilities/devutilities/GitCommandBuilder"; import ImageOptimizer from "./pages/DevUtilities/devutilities/ImageOptimizer"; +import ColorPaletteExtractor from "./pages/DevUtilities/devutilities/ColorPaletteExtractor"; import Footer from "./components/Footer"; import Navbar from "./components/Navbar"; @@ -538,6 +539,10 @@ function AppInner({ toggleHUD, hudVisible }) { path="/devutilities/image-optimizer" element={} /> + } + /> } diff --git a/src/config/sidebarSections.js b/src/config/sidebarSections.js index c817d76..68fb151 100644 --- a/src/config/sidebarSections.js +++ b/src/config/sidebarSections.js @@ -318,6 +318,12 @@ const SIDEBAR_SECTIONS = [ description: "Convert colors and check contrast", path: "/devutilities/color", }, + { + label: "Image Color Palette Extractor", + description: + "Extract dominant color palettes and HEX codes from any uploaded image client-side.", + path: "/devutilities/color-extractor", + }, { label: "QR Code Generator", description: "Generate QR codes", diff --git a/src/pages/DevUtilities/DevUtilities.jsx b/src/pages/DevUtilities/DevUtilities.jsx index f8c02ee..80f77e8 100644 --- a/src/pages/DevUtilities/DevUtilities.jsx +++ b/src/pages/DevUtilities/DevUtilities.jsx @@ -392,6 +392,27 @@ const DevUtilities = () => { ), }, + { + title: "Image Color Palette Extractor", + description: + "Extract dominant color palettes and HEX, RGB, or HSL codes from any uploaded image. Export as CSS variables, Tailwind config, Sass, or JSON. Fully offline.", + path: "/devutilities/color-extractor", + icon: ( + + + + ), + }, { title: "QR Code Generator", description: diff --git a/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx b/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx new file mode 100644 index 0000000..011d6a1 --- /dev/null +++ b/src/pages/DevUtilities/devutilities/ColorPaletteExtractor.jsx @@ -0,0 +1,644 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "sonner"; +import { useTheme } from "../../../context/ThemeContext"; + +const BUCKET_BITS = 4; +const BUCKET_SHIFT = 8 - BUCKET_BITS; +const COLOR_NAMES = [ + "primary", + "secondary", + "accent", + "muted", + "surface", + "highlight", + "neutral", + "contrast", +]; +const EXPORT_TABS = ["CSS", "Tailwind", "Sass", "JSON"]; +const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp"]; + +function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); +} + +function toHexByte(value) { + return Math.round(clamp(value, 0, 255)) + .toString(16) + .padStart(2, "0") + .toUpperCase(); +} + +function rgbToHex({ r, g, b }) { + return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; +} + +function rgbToHsl(r, g, b) { + const red = r / 255; + const green = g / 255; + const blue = b / 255; + const max = Math.max(red, green, blue); + const min = Math.min(red, green, blue); + const delta = max - min; + + let hue = 0; + let saturation = 0; + const lightness = (max + min) / 2; + + if (delta !== 0) { + saturation = delta / (1 - Math.abs(2 * lightness - 1)); + + switch (max) { + case red: + hue = 60 * (((green - blue) / delta) % 6); + break; + case green: + hue = 60 * ((blue - red) / delta + 2); + break; + default: + hue = 60 * ((red - green) / delta + 4); + break; + } + } + + return { + h: Math.round((hue + 360) % 360), + s: Math.round(saturation * 100), + l: Math.round(lightness * 100), + }; +} + +function rgbToHslString(r, g, b) { + const { h, s, l } = rgbToHsl(r, g, b); + return `hsl(${h}, ${s}%, ${l}%)`; +} + +function bucketKey(r, g, b) { + return ( + ((r >> BUCKET_SHIFT) << (BUCKET_BITS * 2)) | + ((g >> BUCKET_SHIFT) << BUCKET_BITS) | + (b >> BUCKET_SHIFT) + ); +} + +function isNearWhite({ r, g, b }) { + return r > 240 && g > 240 && b > 240; +} + +function isNearBlack({ r, g, b }) { + return r < 15 && g < 15 && b < 15; +} + +function colorDistance(a, b) { + return Math.abs(a.r - b.r) + Math.abs(a.g - b.g) + Math.abs(a.b - b.b); +} + +function analyzeImage(img, canvas) { + const maxDimension = 150; + const scale = Math.min(1, maxDimension / Math.max(img.width, img.height)); + const width = Math.max(1, Math.round(img.width * scale)); + const height = Math.max(1, Math.round(img.height * scale)); + + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx) return null; + + ctx.drawImage(img, 0, 0, width, height); + return ctx.getImageData(0, 0, width, height); +} + +function extractPalette(imageData, { maxColors = 6, minShare = 0.02 } = {}) { + const { data } = imageData; + const buckets = new Map(); + let total = 0; + + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha < 128) continue; + + const r = data[i]; + const g = data[i + 1]; + const b = data[i + 2]; + const key = bucketKey(r, g, b); + + let entry = buckets.get(key); + if (!entry) { + entry = { r: 0, g: 0, b: 0, count: 0 }; + buckets.set(key, entry); + } + + entry.r += r; + entry.g += g; + entry.b += b; + entry.count += 1; + total += 1; + } + + if (total === 0) return []; + + const candidates = [...buckets.values()] + .map(({ r, g, b, count }) => { + const avg = { + r: Math.round(r / count), + g: Math.round(g / count), + b: Math.round(b / count), + }; + return { ...avg, count, share: count / total }; + }) + .sort((a, b) => b.count - a.count); + + const filtered = candidates.filter((color, index) => { + if (color.share < minShare) return false; + if (isNearWhite(color) || isNearBlack(color)) { + return index < 2; + } + return true; + }); + + const merged = []; + for (const color of filtered) { + const duplicate = merged.find((entry) => colorDistance(entry, color) < 30); + if (duplicate) { + duplicate.count += color.count; + duplicate.share += color.share; + duplicate.r = Math.round((duplicate.r + color.r) / 2); + duplicate.g = Math.round((duplicate.g + color.g) / 2); + duplicate.b = Math.round((duplicate.b + color.b) / 2); + } else { + merged.push({ ...color }); + } + } + + return merged + .sort((a, b) => b.count - a.count) + .slice(0, maxColors) + .map((color) => ({ + hex: rgbToHex(color), + rgb: `rgb(${color.r}, ${color.g}, ${color.b})`, + hsl: rgbToHslString(color.r, color.g, color.b), + share: Math.round(color.share * 100), + })); +} + +function colorName(index) { + return COLOR_NAMES[index] ?? `palette-${index + 1}`; +} + +function generateCSS(palette) { + const lines = [":root {"]; + palette.forEach((color, index) => { + lines.push(` --color-${colorName(index)}: ${color.hex};`); + }); + lines.push("}"); + return lines.join("\n"); +} + +function generateTailwind(palette) { + const entries = palette + .map((color, index) => ` '${colorName(index)}': '${color.hex}',`) + .join("\n"); + + return `/** @type {import('tailwindcss').Config} */ +module.exports = { + theme: { + extend: { + colors: { +${entries} + }, + }, + }, +};`; +} + +function generateSass(palette) { + return palette + .map((color, index) => `$color-${colorName(index)}: ${color.hex};`) + .join("\n"); +} + +function generateJSON(palette) { + return JSON.stringify(palette.map((color) => color.hex), null, 2); +} + +export default function ColorPaletteExtractor() { + const { dark } = useTheme(); + const canvasRef = useRef(null); + const previewUrlRef = useRef(null); + const prevMaxColorsRef = useRef(6); + + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [palette, setPalette] = useState([]); + const [maxColors, setMaxColors] = useState(6); + const [isDragging, setIsDragging] = useState(false); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(""); + const [exportTab, setExportTab] = useState("CSS"); + const [copied, setCopied] = useState(false); + + useEffect(() => { + return () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + }; + }, []); + + const analyzeFromImage = useCallback((img) => { + const canvas = canvasRef.current; + if (!canvas) { + setError("Canvas is not available in this browser."); + setIsAnalyzing(false); + return; + } + + const imageData = analyzeImage(img, canvas); + if (!imageData) { + setError("Could not read image pixels."); + setIsAnalyzing(false); + return; + } + + const extracted = extractPalette(imageData, { maxColors }); + setPalette(extracted); + setIsAnalyzing(false); + + if (extracted.length === 0) { + toast.error("No dominant colors found in this image."); + } else { + toast.success(`Extracted ${extracted.length} dominant colors`); + } + }, [maxColors]); + + const loadFile = useCallback( + (selectedFile) => { + if (!selectedFile) return; + + if (!ACCEPTED_TYPES.includes(selectedFile.type)) { + setError("Please upload a PNG, JPG, or WebP image."); + return; + } + + if (selectedFile.size > 20 * 1024 * 1024) { + setError("Please upload an image smaller than 20 MB."); + return; + } + + setError(""); + setIsAnalyzing(true); + setPalette([]); + prevMaxColorsRef.current = maxColors; + + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + + const imageUrl = URL.createObjectURL(selectedFile); + previewUrlRef.current = imageUrl; + setPreview(imageUrl); + setFile(selectedFile); + + const img = new Image(); + img.onload = () => analyzeFromImage(img); + img.onerror = () => { + setError("Could not read this image file."); + setIsAnalyzing(false); + }; + img.src = imageUrl; + }, + [analyzeFromImage], + ); + + useEffect(() => { + if (!preview || !file) return; + if (prevMaxColorsRef.current === maxColors) return; + + prevMaxColorsRef.current = maxColors; + setIsAnalyzing(true); + + const img = new Image(); + img.onload = () => analyzeFromImage(img); + img.onerror = () => { + setError("Could not re-analyze this image."); + setIsAnalyzing(false); + }; + img.src = preview; + }, [maxColors, analyzeFromImage, preview, file]); + + const handleFile = (event) => { + loadFile(event.target.files?.[0]); + event.target.value = ""; + }; + + const handleDrop = (event) => { + event.preventDefault(); + setIsDragging(false); + loadFile(event.dataTransfer.files?.[0]); + }; + + const handleClear = () => { + if (previewUrlRef.current) URL.revokeObjectURL(previewUrlRef.current); + previewUrlRef.current = null; + prevMaxColorsRef.current = 6; + setFile(null); + setPreview(null); + setPalette([]); + setError(""); + }; + + const copyHex = async (hex) => { + try { + await navigator.clipboard.writeText(hex); + toast.success(`Copied ${hex}`); + } catch { + toast.error("Failed to copy"); + } + }; + + const exportCode = useMemo(() => { + if (palette.length === 0) return ""; + if (exportTab === "CSS") return generateCSS(palette); + if (exportTab === "Tailwind") return generateTailwind(palette); + if (exportTab === "Sass") return generateSass(palette); + return generateJSON(palette); + }, [exportTab, palette]); + + const handleCopyExport = async () => { + if (!exportCode) return; + try { + await navigator.clipboard.writeText(exportCode); + toast.success("Copied to clipboard"); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + toast.error("Failed to copy"); + } + }; + + const smallBtnCls = `px-3 py-1 rounded-lg text-xs font-bold transition-colors ${ + dark + ? "bg-white text-black hover:bg-zinc-200" + : "bg-black text-white hover:bg-zinc-800" + }`; + + return ( +
+