|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8" /> |
|
<title>Color Palette Generator Plus</title> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<script> |
|
tailwind.config = { darkMode: 'class' }; |
|
</script> |
|
<script> |
|
(function() { |
|
const saved = localStorage.getItem('theme'); |
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
|
if (saved ? saved === 'dark' : prefersDark) { |
|
document.documentElement.classList.add('dark'); |
|
} |
|
})(); |
|
</script> |
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js"></script> |
|
<style> |
|
.color-box { |
|
width: calc(20% - 0.5rem); |
|
aspect-ratio: 1 / 1; |
|
margin: 0.25rem; |
|
position: relative; |
|
flex-grow: 0; |
|
flex-shrink: 0; |
|
cursor: pointer; |
|
} |
|
@media (max-width: 640px) { |
|
.color-box { width: calc(50% - 0.5rem); } |
|
} |
|
.color-box-content { |
|
position: absolute; |
|
inset: 0; |
|
display: flex; |
|
justify-content: center; |
|
align-items: flex-end; |
|
} |
|
.hsl-sliders { display: none; margin-top: 1rem; } |
|
.hsl-sliders.active { display: block; } |
|
.slider-container { |
|
display: flex; |
|
align-items: center; |
|
margin-bottom: 0.5rem; |
|
} |
|
.slider-container input[type="range"] { flex-grow: 1; margin-right: 0.5rem; } |
|
.hsl-value { width: 28px; text-align: right; font-family: monospace; } |
|
.color-box.selected { |
|
outline: 2.5px solid #0f75fd; |
|
outline-offset: 3px; |
|
z-index: 10; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col items-center py-4 px-4 transition-colors"> |
|
|
|
<button id="themeToggle" |
|
class="fixed top-4 right-4 z-50 bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-100 rounded-full w-10 h-10 flex items-center justify-center shadow-md focus:outline-none focus:ring-2 focus:ring-blue-500" |
|
aria-label="Toggle dark mode"> |
|
<span id="themeIcon">π</span> |
|
</button> |
|
|
|
<div class="w-full max-w-2xl mx-auto mt-12"> |
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-md overflow-hidden"> |
|
<div class="p-4 sm:p-8"> |
|
<h1 class="text-2xl sm:text-3xl font-bold mb-6 text-center">Color Palette Generator +</h1> |
|
|
|
|
|
<div class="flex flex-wrap justify-center gap-4 mb-4"> |
|
<label><input type="radio" name="colorSource" value="random" checked> |
|
<span class="ml-1">Random Palette</span></label> |
|
<label><input type="radio" name="colorSource" value="image"> |
|
<span class="ml-1">From File or URL</span></label> |
|
</div> |
|
|
|
|
|
<div id="imageInputs" class="flex-col items-center space-y-4 hidden mb-4 "> |
|
<input type="file" id="imageUpload" accept="image/*" |
|
class="border rounded px-2 py-1 w-full dark:bg-gray-700 dark:border-gray-600"> |
|
<input type="text" id="imageUrl" placeholder="Enter image URL" |
|
class="border rounded px-2 py-1 w-full dark:bg-gray-700 dark:border-gray-600"> |
|
<div id="imagePreviewWrapper" |
|
class="relative w-full max-w-xs border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden"> |
|
<img id="imagePreview" class="w-full h-auto" alt="Preview" style="display:none;"> |
|
<div id="previewError" class="text-red-500 text-sm p-2 hidden">β Could not load image</div> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="text-center flex flex-wrap justify-center gap-4 items-center mb-4"> |
|
<button id="generateBtn" class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded">Generate</button> |
|
<div class="flex items-center"> |
|
<button id="downloadBtn" class="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded">Download</button> |
|
<label class="ml-2 flex items-center"> |
|
<input type="checkbox" id="includeHexCodes" class="form-checkbox h-5 w-5 text-blue-600" checked> |
|
<span class="ml-2">Include hex codes</span> |
|
</label> |
|
</div> |
|
</div> |
|
|
|
|
|
<div class="flex flex-wrap justify-center bg-gray-200 dark:bg-gray-700 p-1 rounded mb-4 text-sm"> |
|
Click any color box to adjust the HSL. |
|
</div> |
|
<div id="palette" class="flex flex-wrap justify-center mb-6"></div> |
|
<div id="colorCodes" class="text-sm font-mono text-center break-words mb-6"></div> |
|
|
|
|
|
<div id="hslSliders" class="hsl-sliders"> |
|
<div class="slider-container"> |
|
<label class="block text-sm font-medium w-6">H</label> |
|
<input type="range" id="hueSlider" min="0" max="360" value="0"> |
|
<span id="hueValue" class="hsl-value">000</span> |
|
</div> |
|
<div class="slider-container"> |
|
<label class="block text-sm font-medium w-6">S</label> |
|
<input type="range" id="saturationSlider" min="0" max="100" value="100"> |
|
<span id="saturationValue" class="hsl-value">100</span> |
|
</div> |
|
<div class="slider-container mb-6"> |
|
<label class="block text-sm font-medium w-6">L</label> |
|
<input type="range" id="luminanceSlider" min="0" max="100" value="50"> |
|
<span id="luminanceValue" class="hsl-value">050</span> |
|
</div> |
|
</div> |
|
|
|
<div class="space-y-6"> |
|
|
|
<div class="flex items-center justify-center space-x-4"> |
|
<label for="colorCount">Colors:</label> |
|
<input type="number" id="colorCount" min="1" max="100" value="5" |
|
class="border rounded px-2 py-1 w-16 dark:bg-gray-700 dark:border-gray-600"> |
|
</div> |
|
|
|
|
|
<div class="flex flex-wrap justify-center gap-x-3 gap-y-2"> |
|
<label><input type="radio" name="colorOption" value="random" checked> <span class="ml-1">Default</span></label> |
|
<label><input type="radio" name="colorOption" value="vivid"> <span class="ml-1">Vivid</span></label> |
|
<label><input type="radio" name="colorOption" value="light"> <span class="ml-1">Light</span></label> |
|
<label><input type="radio" name="colorOption" value="dark"> <span class="ml-1">Dark</span></label> |
|
<label><input type="radio" name="colorOption" value="earth"> <span class="ml-1">Earth</span></label> |
|
<label><input type="radio" name="colorOption" value="ocean"> <span class="ml-1">Ocean</span></label> |
|
<label><input type="radio" name="colorOption" value="sunset"> <span class="ml-1">Sunset</span></label> |
|
<label><input type="radio" name="colorOption" value="forest"> <span class="ml-1">Forest</span></label> |
|
</div> |
|
|
|
|
|
<div class="flex flex-wrap justify-center gap-4 bg-gray-200 dark:bg-gray-700 p-4 rounded"> |
|
<label><input type="radio" name="colorScheme" value="random" checked> <span class="ml-1">Random</span></label> |
|
<label><input type="radio" name="colorScheme" value="harmonize"> <span class="ml-1">Harmonize</span></label> |
|
<label><input type="radio" name="colorScheme" value="monotone"> <span class="ml-1">Monotone</span></label> |
|
<label><input type="radio" name="colorScheme" value="gradient"> <span class="ml-1">Gradient</span></label> |
|
</div> |
|
|
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
|
|
const html = document.documentElement; |
|
const toggleBtn = document.getElementById('themeToggle'); |
|
const icon = document.getElementById('themeIcon'); |
|
function setTheme(dark) { |
|
html.classList.toggle('dark', dark); |
|
icon.textContent = dark ? 'βοΈ' : 'π'; |
|
localStorage.setItem('theme', dark ? 'dark' : 'light'); |
|
} |
|
const saved = localStorage.getItem('theme'); |
|
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; |
|
setTheme(saved ? saved === 'dark' : prefersDark); |
|
toggleBtn.addEventListener('click', () => setTheme(!html.classList.contains('dark'))); |
|
|
|
|
|
function hslToRgb(h, s, l) { |
|
h /= 360; s /= 100; l /= 100; |
|
let r, g, b; |
|
if (s === 0) { r = g = b = l; } else { |
|
const hue2rgb = (p, q, t) => { |
|
if (t < 0) t += 1; if (t > 1) t -= 1; |
|
if (t < 1/6) return p + (q - p) * 6 * t; |
|
if (t < 1/2) return q; |
|
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6; |
|
return p; |
|
}; |
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s; |
|
const p = 2 * l - q; |
|
r = hue2rgb(p, q, h + 1/3); |
|
g = hue2rgb(p, q, h); |
|
b = hue2rgb(p, q, h - 1/3); |
|
} |
|
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; |
|
} |
|
function rgbToHsl(r, g, b) { |
|
r /= 255; g /= 255; b /= 255; |
|
const max = Math.max(r, g, b), min = Math.min(r, g, b); |
|
let h, s, l = (max + min) / 2; |
|
if (max === min) { h = s = 0; } else { |
|
const d = max - min; |
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min); |
|
switch (max) { |
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break; |
|
case g: h = (b - r) / d + 2; break; |
|
case b: h = (r - g) / d + 4; break; |
|
} |
|
h /= 6; |
|
} |
|
return [h * 360, s * 100, l * 100]; |
|
} |
|
function rgbToHex(r, g, b) { |
|
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase(); |
|
} |
|
|
|
|
|
const generators = { |
|
random: () => [Math.floor(Math.random()*256), Math.floor(Math.random()*256), Math.floor(Math.random()*256)], |
|
vivid: () => hslToRgb(Math.random()*360, 80+Math.random()*20, 50+Math.random()*10), |
|
light: () => hslToRgb(Math.random()*360, 60+Math.random()*20, 80+Math.random()*15), |
|
dark: () => hslToRgb(Math.random()*360, 60+Math.random()*20, 10+Math.random()*20), |
|
earth: () => hslToRgb(20+Math.random()*25, 26+Math.random()*15, 36+Math.random()*41), |
|
ocean: () => hslToRgb(180+Math.random()*40, 40+Math.random()*30, 30+Math.random()*50), |
|
sunset: () => hslToRgb(Math.random()*40, 60+Math.random()*30, 40+Math.random()*50), |
|
forest: () => hslToRgb(80+Math.random()*70, 30+Math.random()*40, 20+Math.random()*50) |
|
}; |
|
|
|
function harmonize(baseColor, count) { |
|
const [h, s, l] = rgbToHsl(...baseColor); |
|
const colors = [baseColor]; |
|
for (let i = 1; i < count; i++) colors.push(hslToRgb((h + i * (360 / count)) % 360, s, l)); |
|
return colors; |
|
} |
|
function monotone(baseColor, count) { |
|
const [h, s, l] = rgbToHsl(...baseColor); |
|
const colors = []; |
|
for (let i = 0; i < count; i++) { |
|
const newL = l + (i - Math.floor(count / 2)) * (30 / count); |
|
colors.push(hslToRgb(h, s, Math.max(0, Math.min(100, newL)))); |
|
} |
|
return colors; |
|
} |
|
function gradient(startColor, endColor, count) { |
|
const colors = []; |
|
for (let i = 0; i < count; i++) { |
|
const t = i / (count - 1); |
|
colors.push([ |
|
Math.round(startColor[0] + (endColor[0] - startColor[0]) * t), |
|
Math.round(startColor[1] + (endColor[1] - startColor[1]) * t), |
|
Math.round(startColor[2] + (endColor[2] - startColor[2]) * t) |
|
]); |
|
} |
|
return colors; |
|
} |
|
|
|
|
|
const palette = document.getElementById('palette'); |
|
const colorCodes = document.getElementById('colorCodes'); |
|
const colorCount = document.getElementById('colorCount'); |
|
const generateBtn = document.getElementById('generateBtn'); |
|
const downloadBtn = document.getElementById('downloadBtn'); |
|
const imageInputs = document.getElementById('imageInputs'); |
|
const colorThief = new ColorThief(); |
|
|
|
|
|
const imageUpload = document.getElementById('imageUpload'); |
|
const imageUrl = document.getElementById('imageUrl'); |
|
const imagePreview = document.getElementById('imagePreview'); |
|
const previewError = document.getElementById('previewError'); |
|
|
|
function showPreview(src) { |
|
imagePreview.style.display = 'block'; |
|
previewError.classList.add('hidden'); |
|
imagePreview.src = src; |
|
} |
|
function clearPreview() { |
|
imagePreview.style.display = 'none'; |
|
previewError.classList.add('hidden'); |
|
imagePreview.src = ''; |
|
} |
|
imageUpload.addEventListener('change', e => { |
|
if (e.target.files && e.target.files[0]) showPreview(URL.createObjectURL(e.target.files[0])); |
|
}); |
|
imageUrl.addEventListener('change', e => { |
|
if (e.target.value.trim()) showPreview(e.target.value.trim()); |
|
else clearPreview(); |
|
}); |
|
imagePreview.addEventListener('error', () => { |
|
imagePreview.style.display = 'none'; |
|
previewError.classList.remove('hidden'); |
|
}); |
|
|
|
|
|
function displayColors(colors) { |
|
palette.innerHTML = ''; |
|
colorCodes.innerHTML = ''; |
|
colors.forEach((color, idx) => { |
|
const box = document.createElement('div'); |
|
box.className = 'color-box rounded'; |
|
box.style.backgroundColor = `rgb(${color[0]}, ${color[1]}, ${color[2]})`; |
|
box.dataset.rgb = color.join(','); |
|
const content = document.createElement('div'); |
|
content.className = 'color-box-content'; |
|
const hex = rgbToHex(...color); |
|
const txt = document.createElement('div'); |
|
txt.className = 'text-xs sm:text-sm text-center bg-black/50 text-white py-1 w-full'; |
|
txt.textContent = hex; |
|
content.appendChild(txt); box.appendChild(content); palette.appendChild(box); |
|
colorCodes.innerHTML += `${hex} `; |
|
box.addEventListener('click', () => { |
|
activateHSLSliders(color, idx); |
|
highlightSelectedColor(idx); |
|
}); |
|
}); |
|
const boxes = palette.querySelectorAll('.color-box'); |
|
const isMobile = window.innerWidth <= 640; |
|
const boxWidth = isMobile ? 32 : (100 / Math.min(5, colors.length)); |
|
boxes.forEach(b => b.style.width = `calc(${boxWidth}% - 0.5rem)`); |
|
} |
|
function highlightSelectedColor(idx) { |
|
document.querySelectorAll('.color-box').forEach((b, i) => b.classList.toggle('selected', i === idx)); |
|
} |
|
function activateHSLSliders(color, idx) { |
|
const sliders = document.getElementById('hslSliders'); |
|
sliders.classList.add('active'); |
|
const hueS = document.getElementById('hueSlider'); |
|
const satS = document.getElementById('saturationSlider'); |
|
const lumS = document.getElementById('luminanceSlider'); |
|
const hueV = document.getElementById('hueValue'); |
|
const satV = document.getElementById('saturationValue'); |
|
const lumV = document.getElementById('luminanceValue'); |
|
|
|
|
|
const [r, g, b] = palette.children[idx].dataset.rgb.split(',').map(Number); |
|
const [h, s, l] = rgbToHsl(r, g, b); |
|
hueS.value = Math.round(h); |
|
satS.value = Math.round(s); |
|
lumS.value = Math.round(l); |
|
|
|
function update() { |
|
const newColor = hslToRgb(+hueS.value, +satS.value, +lumS.value); |
|
const box = palette.children[idx]; |
|
box.style.backgroundColor = `rgb(${newColor[0]}, ${newColor[1]}, ${newColor[2]})`; |
|
box.dataset.rgb = newColor.join(','); |
|
const hex = rgbToHex(...newColor); |
|
box.querySelector('.color-box-content div').textContent = hex; |
|
const codes = colorCodes.textContent.split(' '); |
|
codes[idx] = hex; |
|
colorCodes.textContent = codes.join(' '); |
|
hueV.textContent = hueS.value.padStart(3, '0'); |
|
satV.textContent = satS.value.padStart(3, '0'); |
|
lumV.textContent = lumS.value.padStart(3, '0'); |
|
} |
|
hueS.oninput = satS.oninput = lumS.oninput = update; |
|
update(); |
|
} |
|
|
|
|
|
async function generateColors() { |
|
const count = +colorCount.value; |
|
const option = document.querySelector('input[name="colorOption"]:checked').value; |
|
const scheme = document.querySelector('input[name="colorScheme"]:checked').value; |
|
const source = document.querySelector('input[name="colorSource"]:checked').value; |
|
let colors = []; |
|
if (source === 'image') { |
|
let img; |
|
if (imageUpload.files.length) { |
|
img = new Image(); |
|
img.src = URL.createObjectURL(imageUpload.files[0]); |
|
await new Promise(r => img.onload = r); |
|
} else if (imageUrl.value.trim()) { |
|
img = new Image(); img.crossOrigin = 'Anonymous'; img.src = imageUrl.value.trim(); |
|
await new Promise(r => img.onload = r); |
|
} else { alert('Please upload an image or enter a URL.'); return; } |
|
colors = colorThief.getPalette(img, count); |
|
} else { |
|
const base = generators[option](); |
|
switch (scheme) { |
|
case 'harmonize': colors = harmonize(base, count); break; |
|
case 'monotone': colors = monotone(base, count); break; |
|
case 'gradient': colors = gradient(base, generators[option](), count); break; |
|
default: colors = Array.from({ length: count }, generators[option]); |
|
} |
|
} |
|
displayColors(colors); |
|
} |
|
|
|
|
|
function downloadPalette() { |
|
const colors = Array.from(palette.children).map(c => getComputedStyle(c).backgroundColor); |
|
const count = colors.length; |
|
const includeHex = document.getElementById('includeHexCodes').checked; |
|
const gridSize = Math.ceil(Math.sqrt(count)); |
|
const canvasSize = 1000; |
|
const cell = canvasSize / gridSize; |
|
const canvas = document.createElement('canvas'); |
|
canvas.width = canvas.height = canvasSize; |
|
const ctx = canvas.getContext('2d'); |
|
ctx.fillStyle = 'white'; ctx.fillRect(0, 0, canvasSize, canvasSize); |
|
colors.forEach((color, idx) => { |
|
const row = Math.floor(idx / gridSize); |
|
const col = idx % gridSize; |
|
ctx.fillStyle = color; |
|
ctx.fillRect(col * cell, row * cell, cell, cell); |
|
if (includeHex) { |
|
const rgb = color.match(/\d+/g).map(Number); |
|
const hex = rgbToHex(...rgb); |
|
ctx.fillStyle = (rgb[0]*299 + rgb[1]*587 + rgb[2]*114) > 128000 ? 'black' : 'white'; |
|
ctx.font = `${cell/10}px Arial`; |
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; |
|
ctx.fillText(hex, (col + 0.5) * cell, (row + 0.5) * cell); |
|
} |
|
}); |
|
const link = document.createElement('a'); |
|
link.download = 'color_palette_grid.png'; |
|
link.href = canvas.toDataURL('image/png'); |
|
link.click(); |
|
} |
|
|
|
|
|
generateBtn.addEventListener('click', generateColors); |
|
downloadBtn.addEventListener('click', downloadPalette); |
|
document.querySelectorAll('input[name="colorSource"]').forEach(r => |
|
r.addEventListener('change', e => { |
|
imageInputs.style.display = e.target.value === 'image' ? 'flex' : 'none'; |
|
if (e.target.value !== 'image') clearPreview(); |
|
}) |
|
); |
|
generateColors(); |
|
</script> |
|
</body> |
|
</html> |