Appearance
实现 屏幕取色器 工具
分类:general | 标签:取色、颜色、EyeDropper 拾取屏幕颜色 HEX/RGB
2.1 功能说明
拾取屏幕颜色 HEX/RGB
2.1.1 使用指南
功能说明
调用浏览器原生取色器(EyeDropper)从屏幕任意位置拾取颜色,显示 HEX/RGB 并可复制;不支持的浏览器可用颜色输入兜底。
图片吸管
上传本地图片后渲染到画布,鼠标移动实时预览像素颜色,点击即可锁定取色(基于 getImageData 读取单个像素)。图片仅在浏览器内存中处理,不会上传。
主色调提取
对图片像素做 5 位/通道的直方图量化后,用加权 k-means 聚类出若干主色, 并按覆盖面积从高到低排序。可调整提取数量(默认 6 个),支持忽略近白/近黑像素, 避免大面积背景色淹没真正的主体色。算法确定性,同一张图多次提取结果一致。
导出
可将提取出的调色板导出为 CSS 变量文本或 HEX 色卡列表,一键复制到项目里使用。
使用场景
设计还原、取色比对、从参考图/截图中提炼配色方案。
2.2 代码实现
2.2.1 组件结构
本工具是一个基于 Vue 3 <script setup> 语法的单文件组件(SFC),统一包裹在 ToolPageShell 组件内,由它提供页面标题、工具 ID、分类与「使用指南」插槽等通用外壳;核心业务逻辑(响应式状态、计算属性、事件处理函数)全部写在 <script setup> 中,输入/输出通过 el-input、el-button 等 Element Plus 组件与用户交互,所有数据均在浏览器本地处理,不会上传服务器。
2.2.2 核心逻辑概览
<script setup> 中定义的主要函数/方法:
pick()onPick()copy()toHex()releaseUrl()handleImage()eventToPixel()onCanvasClick()onCanvasMove()quantize()dist2()extractPalette()
2.2.3 关键实现代码
以下是该工具的完整 <script setup> 实现(核心代码)。模板(<template>)与样式(<style>)已省略。
vue
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import ToolPageShell from '@/components/ToolPageShell.vue'
const activeTab = ref('screen')
/* ---------------- 原有:屏幕取色 ---------------- */
const color = ref('#409eff')
const eyeSupported = ref(false)
onMounted(() => { eyeSupported.value = typeof window !== 'undefined' && 'EyeDropper' in window })
async function pick() {
try {
const ed = new window.EyeDropper()
const res = await ed.open()
color.value = res.sRGBHex
} catch { /* 用户取消 */ }
}
function onPick(v) { if (v) color.value = v }
const rgb = computed(() => {
const m = color.value.replace('#', '')
const r = parseInt(m.slice(0, 2), 16), g = parseInt(m.slice(2, 4), 16), b = parseInt(m.slice(4, 6), 16)
return `rgb(${r}, ${g}, ${b})`
})
function copy(v) {
if (!v) return
navigator.clipboard?.writeText(v)
ElMessage.success('已复制')
}
/* ---------------- 新增:图片吸管 ---------------- */
const MAX_W = 720
const MAX_H = 520
const canvasRef = ref(null)
const hasImage = ref(false)
const imgError = ref('')
const imgInfo = ref('')
const imgColor = ref('')
const hoverColor = ref('')
let objectUrl = ''
const imgRgbText = computed(() => {
if (!imgColor.value) return ''
const m = imgColor.value.replace('#', '')
return `rgb(${parseInt(m.slice(0, 2), 16)}, ${parseInt(m.slice(2, 4), 16)}, ${parseInt(m.slice(4, 6), 16)})`
})
const toHex = (r, g, b) =>
'#' + [r, g, b].map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('')
function releaseUrl() {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
objectUrl = ''
}
}
onBeforeUnmount(releaseUrl)
function handleImage(file) {
const raw = file.raw
if (!raw || !raw.type.startsWith('image/')) {
imgError.value = '请选择图片文件'
return
}
imgError.value = ''
releaseUrl()
objectUrl = URL.createObjectURL(raw)
const img = new Image()
img.onload = () => {
const scale = Math.min(1, MAX_W / img.naturalWidth, MAX_H / img.naturalHeight)
const w = Math.max(1, Math.round(img.naturalWidth * scale))
const h = Math.max(1, Math.round(img.naturalHeight * scale))
const cvs = canvasRef.value
if (!cvs) return
cvs.width = w
cvs.height = h
cvs.style.width = w + 'px'
const ctx = cvs.getContext('2d', { willReadFrequently: true })
ctx.clearRect(0, 0, w, h)
ctx.drawImage(img, 0, 0, w, h)
hasImage.value = true
imgInfo.value = `原始尺寸 ${img.naturalWidth}×${img.naturalHeight},画布 ${w}×${h}。`
imgColor.value = ''
extractPalette()
}
img.onerror = () => {
imgError.value = '图片加载失败,请换一张试试'
hasImage.value = false
}
img.src = objectUrl
}
// 把鼠标事件坐标换算成画布像素坐标(兼容 CSS 缩放)
function eventToPixel(e) {
const cvs = canvasRef.value
if (!cvs) return null
const rect = cvs.getBoundingClientRect()
if (!rect.width || !rect.height) return null
const x = Math.floor((e.clientX - rect.left) * (cvs.width / rect.width))
const y = Math.floor((e.clientY - rect.top) * (cvs.height / rect.height))
if (x < 0 || y < 0 || x >= cvs.width || y >= cvs.height) return null
const d = cvs.getContext('2d', { willReadFrequently: true }).getImageData(x, y, 1, 1).data
return toHex(d[0], d[1], d[2])
}
function onCanvasClick(e) {
const hex = eventToPixel(e)
if (hex) imgColor.value = hex
}
function onCanvasMove(e) {
hoverColor.value = eventToPixel(e) || ''
}
/* ---------------- 新增:主色调提取 ---------------- */
const paletteSize = ref(6)
const ignoreExtremes = ref(true)
const palette = ref([])
const extracting = ref(false)
// 5bit/通道 直方图量化 + 确定性加权 k-means 聚类
function quantize(pixels, maxColors) {
if (!pixels.length) return []
const SHIFT = 3
const map = new Map()
for (const p of pixels) {
const key = ((p[0] >> SHIFT) << 10) | ((p[1] >> SHIFT) << 5) | (p[2] >> SHIFT)
let e = map.get(key)
if (!e) { e = { r: 0, g: 0, b: 0, n: 0 }; map.set(key, e) }
e.r += p[0]; e.g += p[1]; e.b += p[2]; e.n++
}
const cells = [...map.values()].map(e => ({ r: e.r / e.n, g: e.g / e.n, b: e.b / e.n, n: e.n }))
if (cells.length <= maxColors) {
return cells.sort((a, b) => b.n - a.n).map(c => ({ r: c.r, g: c.g, b: c.b, count: c.n }))
}
const dist2 = (a, b) => {
const dr = a.r - b.r, dg = a.g - b.g, db = a.b - b.b
return dr * dr + dg * dg + db * db
}
// k-means++ 风格播种:先取占比最大的格子,之后每次取 n·d² 最大者
const seeds = [cells.reduce((m, c) => (c.n > m.n ? c : m), cells[0])]
while (seeds.length < maxColors) {
let best = null, bestScore = -1
for (const c of cells) {
let nearest = Infinity
for (const s of seeds) {
const d = dist2(c, s)
if (d < nearest) nearest = d
}
const score = c.n * nearest
if (score > bestScore) { bestScore = score; best = c }
}
if (!best || bestScore <= 0) break
seeds.push({ r: best.r, g: best.g, b: best.b })
}
let centers = seeds.map(s => ({ r: s.r, g: s.g, b: s.b, n: 0 }))
for (let iter = 0; iter < 12; iter++) {
const acc = centers.map(() => ({ r: 0, g: 0, b: 0, n: 0 }))
for (const c of cells) {
let bi = 0, bd = Infinity
for (let i = 0; i < centers.length; i++) {
const d = dist2(c, centers[i])
if (d < bd) { bd = d; bi = i }
}
acc[bi].r += c.r * c.n
acc[bi].g += c.g * c.n
acc[bi].b += c.b * c.n
acc[bi].n += c.n
}
let moved = 0
for (let i = 0; i < centers.length; i++) {
if (!acc[i].n) continue
const nr = acc[i].r / acc[i].n, ng = acc[i].g / acc[i].n, nb = acc[i].b / acc[i].n
moved += Math.abs(nr - centers[i].r) + Math.abs(ng - centers[i].g) + Math.abs(nb - centers[i].b)
centers[i] = { r: nr, g: ng, b: nb, n: acc[i].n }
}
if (moved < 0.5) break
}
return centers.filter(c => c.n).map(c => ({ r: c.r, g: c.g, b: c.b, count: c.n })).sort((a, b) => b.count - a.count)
}
function extractPalette() {
const cvs = canvasRef.value
if (!cvs || !hasImage.value) return
extracting.value = true
try {
const ctx = cvs.getContext('2d', { willReadFrequently: true })
const { data, width, height } = ctx.getImageData(0, 0, cvs.width, cvs.height)
// 大图按步长抽样,控制在 4 万像素以内
const total = width * height
const step = Math.max(1, Math.floor(Math.sqrt(total / 40000)))
const pixels = []
for (let y = 0; y < height; y += step) {
for (let x = 0; x < width; x += step) {
const i = (y * width + x) * 4
if (data[i + 3] < 128) continue // 跳过透明像素
const r = data[i], g = data[i + 1], b = data[i + 2]
if (ignoreExtremes.value) {
const mx = Math.max(r, g, b), mn = Math.min(r, g, b)
if (mx > 245 && mn > 245) continue // 近白
if (mx < 12) continue // 近黑
}
pixels.push([r, g, b])
}
}
if (!pixels.length) {
palette.value = []
imgError.value = '没有可用于统计的像素(可尝试关闭「忽略近白 / 近黑像素」)'
return
}
imgError.value = ''
const sum = pixels.length
palette.value = quantize(pixels, paletteSize.value).map(c => ({
hex: toHex(c.r, c.g, c.b),
percent: Math.round((c.count / sum) * 1000) / 10
}))
} catch (e) {
imgError.value = '主色提取失败:' + e.message
palette.value = []
} finally {
extracting.value = false
}
}
/* ---------------- 新增:导出调色板 ---------------- */
const exportFormat = ref('css')
const exportText = computed(() => {
if (!palette.value.length) return ''
const rows = palette.value
if (exportFormat.value === 'list') {
return rows.map((p, i) => `${String(i + 1).padStart(2, '0')}. ${p.hex} ${p.percent}%`).join('\n')
}
if (exportFormat.value === 'scss') {
return rows.map((p, i) => `$palette-${i + 1}: ${p.hex}; // 占比 ${p.percent}%`).join('\n')
}
return ':root {\n' +
rows.map((p, i) => ` --palette-${i + 1}: ${p.hex}; /* 占比 ${p.percent}% */`).join('\n') +
'\n}'
})2.3 效果截图
