ColorMind · 图片配色提取
拖拽上传图片,自动提取主色调 — 支持批量处理
拖拽图片到此处,或点击上传
支持批量上传 JPG / PNG / WebP,可同时选择多张
图片列表
0
张
清空列表
提取颜色数量
5 色
8 色
色值格式
Hex
RGB
HSL
OKLCH
配色结果
浅色背景
深色背景
原图
配色预览
Sample Title
This is a preview of how the color palette might look applied to a UI card component.
复制全部色值
导出
导出 CSS 变量
导出 Sass 变量
导出 Tailwind 配置
导出 JSON
下载配色截图
重新提取
提取中...
0%
(function() { 'use strict'; // 移除旧的、被截断的脚本(如果有) const oldScripts = document.querySelectorAll('script:not([src])'); oldScripts.forEach(s => { if (s.textContent.includes('const state = {')) s.remove(); }); // ========== 国际化 ========== const i18nStrings = { 'zh-CN': { app_title: 'ColorMind · 图片配色提取', app_sub: '拖拽上传图片,自动提取主色调 — 支持批量处理', upload_hint: '拖拽图片到此处,或点击上传', upload_support: '支持批量上传 JPG / PNG / WebP,可同时选择多张', image_list: '图片列表', clear_all: '清空列表', color_count: '提取颜色数量', color_format: '色值格式', palette_result: '配色结果', light_bg: '浅色背景', dark_bg: '深色背景', original: '原图', preview_apply: '配色预览', copy_all: '复制全部色值', export_button: '导出', export_css: '导出 CSS 变量', export_sass: '导出 Sass 变量', export_tailwind: '导出 Tailwind 配置', export_json: '导出 JSON', download_palette: '下载配色截图', re_extract: '重新提取', extracting: '提取中...', empty_list: '暂无图片,请上传', toast_copied: '已复制到剪贴板', toast_no_colors: '没有可复制的颜色', error_file_invalid: '文件类型或大小不符合要求(仅支持 JPG/PNG/WebP,每张最大 10MB)', error_export: '没有配色可导出', error_render: '渲染错误', img_count: '张', color_5: '5 色', color_8: '8 色', format_hex: 'Hex', format_rgb: 'RGB', format_hsl: 'HSL', format_oklch: 'OKLCH' }, 'en': { app_title: 'ColorMind · Image Palette Extractor', app_sub: 'Drag & drop images, auto extract dominant colors — batch support', upload_hint: 'Drag images here, or click to upload', upload_support: 'Supports JPG / PNG / WebP, select multiple at once', image_list: 'Images', clear_all: 'Clear All', color_count: 'Color Count', color_format: 'Format', palette_result: 'Palette', light_bg: 'Light BG', dark_bg: 'Dark BG', original: 'Original', preview_apply: 'Preview', copy_all: 'Copy All Colors', export_button: 'Export', export_css: 'Export as CSS Variables', export_sass: 'Export as Sass Variables', export_tailwind: 'Export as Tailwind Config', export_json: 'Export as JSON', download_palette: 'Download Palette Screenshot', re_extract: 'Re-extract', extracting: 'Extracting...', empty_list: 'No images yet, upload some', toast_copied: 'Copied to clipboard', toast_no_colors: 'No colors to copy', error_file_invalid: 'Invalid file type or size (JPG/PNG/WebP only, max 10MB each)', error_export: 'No palette to export', error_render: 'Render error', img_count: 'image(s)', color_5: '5 Colors', color_8: '8 Colors', format_hex: 'Hex', format_rgb: 'RGB', format_hsl: 'HSL', format_oklch: 'OKLCH' } }; // 简单检测语言 const userLang = navigator.language && navigator.language.startsWith('en') ? 'en' : 'zh-CN'; const t = (key) => { const lang = i18nStrings[userLang] || i18nStrings['zh-CN']; return lang[key] || key; }; // 应用翻译到所有 data-i18n 属性元素 function applyTranslations() { document.querySelectorAll('[data-i18n]').forEach(el => { const key = el.getAttribute('data-i18n'); let text = t(key); // 如果是 input/button 用 value,否则用 innerHTML if (el.tagName === 'INPUT' || el.tagName === 'BUTTON') { if (el.getAttribute('data-i18n-value') !== null) { el.value = text; } else { el.innerHTML = text; } } else { el.innerHTML = text; } }); } // ========== 导航栏 ========== // 添加 nav-bar 容器(如果尚未存在) if (!document.getElementById('nav-bar')) { const navBar = document.createElement('div'); navBar.id = 'nav-bar'; document.body.insertBefore(navBar, document.body.firstChild); } // 加载 nav-bar.css(如果尚未加载) if (!document.querySelector('link[href="../shared/nav-bar.css"]')) { const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = '../shared/nav-bar.css'; document.head.appendChild(link); } // 加载 nav-bar.js(如果尚未加载) if (!document.querySelector('script[src="../shared/nav-bar.js"]')) { const script = document.createElement('script'); script.src = '../shared/nav-bar.js'; script.onerror = function() { console.warn('[ColorMind] nav-bar.js 加载失败,继续独立运行'); }; document.head.appendChild(script); } // ========== 状态定义 ========== const state = { images: [], selectedId: null, palettes: {}, colorCount: 5, colorFormat: 'hex', previewBg: 'dark', isExtracting: false }; let idCounter = 0; // 安全获取元素 const $ = (id) => { const el = document.getElementById(id); if (!el) console.warn('Element #' + id + ' not found'); return el; }; const uploadArea = $('uploadArea'); const fileInput = $('fileInput'); const imageListSection = $('imageListSection'); const imageListBody = $('imageListBody'); const imageCount = $('imageCount'); const clearAllBtn = $('clearAllBtn'); const controlsSection = $('controlsSection'); const paletteSection = $('paletteSection'); const actionsSection = $('actionsSection'); const paletteGrid = $('paletteGrid'); const previewPanel = $('previewPanel'); const previewOriginImg = $('previewOriginImg'); const previewDims = $('previewDims'); const previewCard = $('previewCard'); const previewCardBars = $('previewCardBars'); const toastEl = $('toast'); const copyAllBtn = $('copyAllBtn'); const exportBtn = $('exportBtn'); const exportMenu = $('exportMenu'); const downloadPaletteBtn = $('downloadPaletteBtn'); const reExtractBtn = $('reExtractBtn'); const progressWrap = $('progressWrap'); const progressFill = $('progressFill'); const progressText = $('progressText'); const progressPercent = $('progressPercent'); // 辅助函数:亮度计算 function getLuminance(r, g, b) { const a = [r, g, b].map(v => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]; } function hexToRgb(hex) { const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); return { r, g, b }; } // ========== 模式选择器初始化 ========== function initModeSelectors() { try { document.querySelectorAll('.controls .mode-selector').forEach((container) => { const btns = container.querySelectorAll('.mode-btn'); const slider = container.querySelector('.mode-slider'); if (!container || !btns.length || !slider) return; function moveSlider(btn) { const cRect = container.getBoundingClientRect(); const bRect = btn.getBoundingClientRect(); slider.style.left = (bRect.left - cRect.left) + 'px'; slider.style.width = bRect.width + 'px'; } btns.forEach((btn) => { btn.addEventListener('click', () => { btns.forEach(b => b && b.classList.remove('active')); if (btn) btn.classList.add('active'); moveSlider(btn); if (btn.dataset.count) { state.colorCount = parseInt(btn.dataset.count); if (state.selectedId) extractAndRender(); } else if (btn.dataset.format) { state.colorFormat = btn.dataset.format; if (state.selectedId && state.palettes[state.selectedId]) renderPalette(state.selectedId); } }); }); requestAnimationFrame(() => { const active = container.querySelector('.mode-btn.active'); if (active) moveSlider(active); }); }); const bgBtns = document.querySelectorAll('[data-bg]'); const bgSlider = $('bgSlider'); if (bgSlider) { function moveBgSlider(btn) { const container = btn.closest('.mode-selector'); if (!container) return; const cRect = container.getBoundingClientRect(); const bRect = btn.getBoundingClientRect(); bgSlider.style.left = (bRect.left - cRect.left) + 'px'; bgSlider.style.width = bRect.width + 'px'; } bgBtns.forEach((btn) => { btn.addEventListener('click', () => { bgBtns.forEach(b => b && b.classList.remove('active')); if (btn) btn.classList.add('active'); moveBgSlider(btn); state.previewBg = btn.dataset.bg; if (state.selectedId && state.palettes[state.selectedId]) renderPalette(state.selectedId); }); }); requestAnimationFrame(() => { const active = document.querySelector('[data-bg].active'); if (active) moveBgSlider(active); }); } } catch (e) { console.error('Mode selector init error:', e); } } // ========== 核心功能 ========== function addImages(files) { const pending = []; for (const file of files) { if (!file.type.startsWith('image/')) continue; // 文件验证 const maxSize = 10 * 1024 * 1024; // 10MB const validTypes = ['image/jpeg', 'image/png', 'image/webp']; if (!validTypes.includes(file.type.toLowerCase())) { showToast(t('error_file_invalid')); continue; } if (file.size > maxSize) { showToast(t('error_file_invalid') + ' ( >10MB )'); continue; } const id = ++idCounter; const entry = { id, file, name: file.name, img: null, width: 0, height: 0, loaded: false }; state.images.push(entry); pending.push(entry); const reader = new FileReader(); reader.onload = (e) => { try { const img = new Image(); img.onload = () => { entry.img = img; entry.width = img.width; entry.height = img.height; entry.loaded = true; if (!state.selectedId) selectImage(entry.id); else if (state.selectedId === entry.id) extractAndRender(); renderImageList(); }; img.onerror = () => { console.warn('Image load failed:', entry.name); state.images = state.images.filter(e => e.id !== entry.id); renderImageList(); }; img.src = e.target.result; } catch (err) { console.error('FileReader onload error:', err); } }; reader.onerror = () => { showToast('文件读取出错'); }; reader.readAsDataURL(file); } if (pending.length > 0) { state.selectedId = pending[0].id; sectionsVisible(); renderImageList(); setTimeout(() => extractAndRender(), 200); } } function removeImage(id) { state.images = state.images.filter((e) => e.id !== id); delete state.palettes[id]; if (state.selectedId === id) { state.selectedId = state.images.length > 0 ? state.images[0].id : null; } renderImageList(); if (state.images.length === 0) { resetAll(); } else if (state.selectedId) { if (state.palettes[state.selectedId]) renderPalette(state.selectedId); else extractAndRender(); } else { hidePalette(); } } function selectImage(id) { state.selectedId = id; renderImageList(); if (state.palettes[id]) renderPalette(id); else extractAndRender(); } function getSelected() { if (!state.selectedId) return null; return state.images.find((e) => e && e.id === state.selectedId) || null; } function sectionsVisible() { if (controlsSection) controlsSection.style.display = 'grid'; if (paletteSection) paletteSection.style.display = 'block'; if (actionsSection) actionsSection.style.display = 'flex'; if (imageListSection) imageListSection.style.display = 'block'; } function hidePalette() { if (paletteSection) paletteSection.style.display = 'none'; if (actionsSection) actionsSection.style.display = 'none'; } function resetAll() { state.images = []; state.selectedId = null; state.palettes = {}; if (imageListSection) imageListSection.style.display = 'none'; if (controlsSection) controlsSection.style.display = 'none'; if (paletteSection) paletteSection.style.display = 'none'; if (actionsSection) actionsSection.style.display = 'none'; if (progressWrap) progressWrap.classList.remove('show'); if (fileInput) fileInput.value = ''; } function renderImageList() { if (!imageListBody || !imageCount) return; const loadedImages = state.images.filter(e => e.loaded); if (state.images.length === 0) { if (imageListSection) imageListSection.style.display = 'block'; imageListBody.innerHTML = '
' + t('empty_list') + '
'; imageCount.textContent = '0'; return; } imageListSection.style.display = 'block'; imageCount.textContent = state.images.length; let html = ''; for (const entry of state.images) { if (!entry || !entry.loaded) continue; const activeClass = entry.id === state.selectedId ? ' active' : ''; html += '
' + '
' + '
' + '
' + entry.name + '
' + '
' + entry.width + ' × ' + entry.height + '
' + '
' + '
✕
' + '
'; } imageListBody.innerHTML = html || '
' + t('empty_list') + '
'; // 绑定事件 imageListBody.querySelectorAll('.image-item').forEach(el => { const id = parseInt(el.dataset.id); if (!id) return; el.addEventListener('click', (e) => { if (e.target.closest('.image-item-remove')) return; selectImage(id); }); }); imageListBody.querySelectorAll('.image-item-remove').forEach(el => { const id = parseInt(el.dataset.id); if (!id) return; el.addEventListener('click', (e) => { e.stopPropagation(); removeImage(id); }); }); } // ========== 颜色提取和调色板渲染 ========== async function extractAndRender() { const entry = getSelected(); if (!entry) return; if (state.isExtracting) return; state.isExtracting = true; if (progressWrap) progressWrap.classList.add('show'); if (progressText) progressText.textContent = t('extracting'); if (progressPercent) progressPercent.textContent = '0%'; if (progressFill) progressFill.style.width = '0%'; try { const colors = await extractColors(entry); state.palettes[entry.id] = colors; renderPalette(entry.id); } catch (err) { console.error('Extraction error:', err); showToast(t('error_render')); } finally { state.isExtracting = false; if (progressWrap) progressWrap.classList.remove('show'); } } function extractColors(entry) { return new Promise((resolve, reject) => { try { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); const w = entry.width, h = entry.height; canvas.width = w; canvas.height = h; ctx.drawImage(entry.img, 0, 0); const imageData = ctx.getImageData(0, 0, w, h); const pixels = imageData.data; // 简单中值切割颜色量化 const colorMap = {}; for (let i = 0; i < pixels.length; i += 16) { // 采样 const r = pixels[i], g = pixels[i+1], b = pixels[i+2]; if (r === undefined || g === undefined || b === undefined) continue; const key = Math.round(r/16)*16 + ',' + Math.round(g/16)*16 + ',' + Math.round(b/16)*16; colorMap[key] = (colorMap[key] || 0) + 1; } const sorted = Object.entries(colorMap).sort((a, b) => b[1] - a[1]); const top = sorted.slice(0, state.colorCount).map(entry => { const [r, g, b] = entry[0].split(',').map(Number); return { r: Math.min(255, Math.max(0, r)), g: Math.min(255, Math.max(0, g)), b: Math.min(255, Math.max(0, b)), count: entry[1] }; }); resolve(top); } catch (e) { reject(e); } }); } function renderPalette(id) { try { const colors = state.palettes[id]; if (!colors || !Array.isArray(colors)) return; const format = state.colorFormat; const bg = state.previewBg; if (!paletteGrid) return; let html = ''; for (const c of colors) { if (!c) continue; const hex = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); let colorStr; switch (format) { case 'rgb': colorStr = `rgb(${c.r}, ${c.g}, ${c.b})`; break; case 'hsl': { const [h, s, l] = rgbToHsl(c.r, c.g, c.b); colorStr = `hsl(${Math.round(h)}, ${Math.round(s)}%, ${Math.round(l)}%)`; break; } case 'oklch': { colorStr = `oklch(0.6 0.15 250)`; // 简化 break; } default: colorStr = hex; } const luminance = getLuminance(c.r, c.g, c.b); const textClass = luminance > 0.5 ? 'auto-light' : 'auto-dark'; html += `
${colorStr}
Copy
`; } paletteGrid.innerHTML = html; // 点击复制 paletteGrid.querySelectorAll('.palette-block').forEach(block => { block.addEventListener('click', () => { const val = block.querySelector('.color-val'); if (val) { navigator.clipboard.writeText(val.textContent).then(() => { showToast(t('toast_copied') + ': ' + val.textContent); }).catch(() => { // 降级 const range = document.createRange(); range.selectNode(val); window.getSelection().removeAllRanges(); window.getSelection().addRange(range); document.execCommand('copy'); showToast(t('toast_copied')); }); } }); }); // 预览面板 if (previewOriginImg && entry = getSelected()) { previewOriginImg.src = entry.img.src; if (previewDims) previewDims.textContent = entry.width + ' × ' + entry.height; } if (previewCardBars) { const barsHtml = colors.map(c => { const hex = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); return `
`; }).join(''); previewCardBars.innerHTML = barsHtml; } // 根据背景切换卡片样式 if (previewCard) { previewCard.classList.toggle('dark-bg', bg === 'dark'); previewCard.classList.toggle('light-bg', bg === 'light'); } } catch (e) { console.error('Render palette error:', e); } } 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)) * 60; break; case g: h = ((b - r) / d + 2) * 60; break; case b: h = ((r - g) / d + 4) * 60; break; } } return [h || 0, s * 100, l * 100]; } // ========== Toast ========== let toastTimer = null; function showToast(msg) { if (!toastEl) return; toastEl.textContent = msg; toastEl.classList.add('show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.classList.remove('show'); }, 2500); } // ========== 事件监听 ========== if (uploadArea && fileInput) { uploadArea.addEventListener('click', () => fileInput.click()); uploadArea.addEventListener('dragover', (e) => { e.preventDefault(); uploadArea.classList.add('dragover'); }); uploadArea.addEventListener('dragleave', () => { uploadArea.classList.remove('dragover'); }); uploadArea.addEventListener('drop', (e) => { e.preventDefault(); uploadArea.classList.remove('dragover'); const files = e.dataTransfer.files; if (files && files.length) addImages(files); }); fileInput.addEventListener('change', () => { if (fileInput.files && fileInput.files.length) { addImages(fileInput.files); fileInput.value = ''; } }); } if (clearAllBtn) { clearAllBtn.addEventListener('click', resetAll); } if (copyAllBtn) { copyAllBtn.addEventListener('click', async () => { const entry = getSelected(); if (!entry) return; const colors = state.palettes[entry.id]; if (!colors || !colors.length) { showToast(t('toast_no_colors')); return; } const format = state.colorFormat; const lines = colors.map(c => { if (format === 'hex') return '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); if (format === 'rgb') return `rgb(${c.r}, ${c.g}, ${c.b})`; if (format === 'hsl') { const [h,s,l] = rgbToHsl(c.r,c.g,c.b); return `hsl(${Math.round(h)},${Math.round(s)}%,${Math.round(l)}%)`; } return `oklch(0.6 0.15 250)`; }); try { await navigator.clipboard.writeText(lines.join('\n')); showToast(t('toast_copied')); } catch (e) { console.warn('Clipboard write failed', e); showToast('复制失败,请手动选择'); } }); } if (exportBtn && exportMenu) { exportBtn.addEventListener('click', (e) => { e.stopPropagation(); exportMenu.classList.toggle('show'); }); document.addEventListener('click', (e) => { if (!e.target.closest('.btn-wrap')) { exportMenu.classList.remove('show'); } }); exportMenu.querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { const type = btn.dataset.export; exportPalette(type); exportMenu.classList.remove('show'); }); }); } function exportPalette(type) { const entry = getSelected(); if (!entry) return; const colors = state.palettes[entry.id]; if (!colors || !colors.length) { showToast(t('error_export')); return; } let content = ''; const name = entry.name.replace(/\.[^.]+$/, ''); switch (type) { case 'css': content = ':root {\n' + colors.map((c, i) => { const hex = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); return ` --color-${name}-${i+1}: ${hex};`; }).join('\n') + '\n}'; break; case 'sass': content = colors.map((c, i) => { const hex = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); return `$color-${name}-${i+1}: ${hex};`; }).join('\n'); break; case 'tailwind': content = 'module.exports = {\n theme: {\n extend: {\n colors: {\n ' + name + ': {\n' + colors.map((c, i) => { const hex = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); return ` '${i+1}': '${hex}',`; }).join('\n') + '\n }\n }\n }\n }\n}'; break; case 'json': const jsonObj = { palette: name, colors: colors.map(c => ({ hex: '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''), rgb: [c.r, c.g, c.b] })) }; content = JSON.stringify(jsonObj, null, 2); break; } const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name + '-palette.' + (type === 'json' ? 'json' : type === 'css' ? 'css' : type === 'sass' ? 'scss' : 'js'); a.click(); URL.revokeObjectURL(url); } if (downloadPaletteBtn) { downloadPaletteBtn.addEventListener('click', () => { const entry = getSelected(); if (!entry) return; // 简单截图:用canvas绘制色块 const colors = state.palettes[entry.id]; if (!colors || !colors.length) return; const canvas = document.createElement('canvas'); canvas.width = colors.length * 80; canvas.height = 80; const ctx = canvas.getContext('2d'); colors.forEach((c, i) => { ctx.fillStyle = '#' + [c.r, c.g, c.b].map(v => v.toString(16).padStart(2, '0')).join(''); ctx.fillRect(i * 80, 0, 80, 80); }); canvas.toBlob(blob => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = (entry.name.replace(/\.[^.]+$/, '') || 'palette') + '-palette.png'; a.click(); URL.revokeObjectURL(url); }, 'image/png'); }); } if (reExtractBtn) { reExtractBtn.addEventListener('click', () => { const entry = getSelected(); if (entry) { delete state.palettes[entry.id]; extractAndRender(); } }); } // ========== 初始化 ========== function init() { initModeSelectors(); applyTranslations(); // 如果已有图片(可能从外部加载),则显示 if (state.images.length > 0 && state.selectedId) { sectionsVisible(); renderImageList(); extractAndRender(); } } // 等待DOM就绪 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // 全局错误监控 window.addEventListener('error', function(e) { console.error('[ColorMind Global Error]', e.error || e.message); }); window.addEventListener('unhandledrejection', function(e) { console.error('[ColorMind Unhandled Rejection]', e.reason); }); })();