取色器自定义组件

发布时间:2026/7/21 17:56:15
取色器自定义组件 template div classcolor-select refrootRef !-- 触发器色块 文本 -- div classcolor-select__trigger clicktogglePanel span classcolor-select__swatch :style{ background: currentColor }/span span classcolor-select__text{{ currentColor.toUpperCase() }}/span DownOutlined classcolor-select__arrow / /div !-- 取色面板 -- transition namecolor-fade div v-ifvisible classcolor-select__panel click.stop !-- SV 取色区 -- div classcolor-select__sv refsvRef :style{ background: svBackground } mousedownonSvMouseDown div classcolor-select__sv-white/div div classcolor-select__sv-black/div div classcolor-select__sv-cursor :style{ left: svCursor.left px, top: svCursor.top px } /div /div !-- 色相滑块 -- div classcolor-select__hue refhueRef mousedownonHueMouseDown div classcolor-select__hue-bar/div div classcolor-select__hue-thumb :style{ left: hueThumbLeft px } /div /div !-- 透明度滑块 -- div classcolor-select__alpha refalphaRef mousedownonAlphaMouseDown div classcolor-select__alpha-bar :style{ background: alphaBarBackground } /div div classcolor-select__alpha-thumb :style{ left: alphaThumbLeft px } /div /div !-- 输入与原生取色器 -- div classcolor-select__inputs a-input-group compact classcolor-select__input-group a-input v-model:valuehexInput classcolor-select__hex sizesmall changeonHexInput bluronHexBlur pressEnteronHexBlur / input typecolor classcolor-select__native :valuehexWithoutAlpha inputonNativePick / /a-input-group a-input v-model:valuealphaInput classcolor-select__alpha-input sizesmall addon-after% changeonAlphaInput bluronAlphaBlur pressEnteronAlphaBlur / /div !-- 预设色板 -- div classcolor-select__presets span v-forc in presets :keyc classcolor-select__preset :class{ is-active: c.toLowerCase() currentColor.toLowerCase() } :style{ background: c } :titlec clickonPresetClick(c) /span /div !-- 操作按钮 -- div classcolor-select__footer a-button sizesmall clickonCancel取消/a-button a-button sizesmall typeprimary clickonConfirm确定/a-button /div /div /transition /div /template script setup import { ref, reactive, computed, watch, onMounted, onBeforeUnmount, nextTick } from vue import { DownOutlined } from ant-design/icons-vue const props defineProps({ modelValue: { type: String, default: #1677ff }, presets: { type: Array, default: () [ #1677ff, #52c41a, #13c2c2, #1677ff, #722ed1, #eb2f96, #fa541c, #faad14, #f5222d, #000000, #595959, #ffffff ] }, disabled: { type: Boolean, default: false } }) const emit defineEmits([update:modelValue, change]) const rootRef ref(null) const svRef ref(null) const hueRef ref(null) const alphaRef ref(null) const visible ref(false) // 内部 HSV 状态 const hsv reactive({ h: 220, s: 1, v: 1, a: 1 }) // 备份用于取消 let backupHex props.modelValue const hexInput ref(props.modelValue) const alphaInput ref(100) /* ----------------- 颜色转换工具 ----------------- */ function clamp(v, min 0, max 1) { return Math.min(max, Math.max(min, v)) } function hsvToRgb({ h, s, v }) { const c v * s const hp (h % 360) / 60 const x c * (1 - Math.abs((hp % 2) - 1)) let r 0, g 0, b 0 if (hp 0 hp 1) { r c; g x; b 0 } else if (hp 2) { r x; g c; b 0 } else if (hp 3) { r 0; g c; b x } else if (hp 4) { r 0; g x; b c } else if (hp 5) { r x; g 0; b c } else { r c; g 0; b x } const m v - c return { r: Math.round((r m) * 255), g: Math.round((g m) * 255), b: Math.round((b m) * 255) } } function rgbToHsv({ r, g, b }) { r / 255; g / 255; b / 255 const max Math.max(r, g, b) const min Math.min(r, g, b) const d max - min let h 0 if (d ! 0) { if (max r) h ((g - b) / d) % 6 else if (max g) h (b - r) / d 2 else h (r - g) / d 4 h * 60 if (h 0) h 360 } const s max 0 ? 0 : d / max return { h, s, v: max } } function rgbToHex({ r, g, b }, a 1) { const toHex (n) n.toString(16).padStart(2, 0) const base #${toHex(r)}${toHex(g)}${toHex(b)} if (a 1) return base return base toHex(Math.round(a * 255)) } function hexToRgb(hex) { if (!hex) return null let h hex.trim().replace(/^#/, ) if (h.length 3) { h h.split().map((c) c c).join() } if (h.length 6 || h.length 8) { return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16), a: h.length 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1 } } return null } /* ----------------- 计算属性 ----------------- */ const currentRgb computed(() hsvToRgb({ h: hsv.h, s: hsv.s, v: hsv.v })) const currentColor computed(() rgbToHex(currentRgb.value, hsv.a)) const hexWithoutAlpha computed(() rgbToHex(currentRgb.value, 1)) const svBackground computed(() { const rgb hsvToRgb({ h: hsv.h, s: 1, v: 1 }) return rgb(${rgb.r}, ${rgb.g}, ${rgb.b}) }) const alphaBarBackground computed(() { const { r, g, b } currentRgb.value return linear-gradient(to right, rgba(${r},${g},${b},0), rgba(${r},${g},${b},1)) }) const svCursor reactive({ left: 0, top: 0 }) const hueThumbLeft ref(0) const alphaThumbLeft ref(0) /* ----------------- 同步游标位置 ----------------- */ function syncCursors() { if (!svRef.value || !hueRef.value || !alphaRef.value) return const svRect svRef.value.getBoundingClientRect() svCursor.left hsv.s * svRect.width svCursor.top (1 - hsv.v) * svRect.height const hueRect hueRef.value.getBoundingClientRect() hueThumbLeft.value (hsv.h / 360) * hueRect.width const alphaRect alphaRef.value.getBoundingClientRect() alphaThumbLeft.value hsv.a * alphaRect.width } /* ----------------- 外部 modelValue 同步到内部 ----------------- */ function applyModelValue(val) { const rgb hexToRgb(val) if (!rgb) return const { h, s, v } rgbToHsv(rgb) hsv.h h hsv.s s hsv.v v hsv.a rgb.a ?? 1 hexInput.value rgbToHex({ r: rgb.r, g: rgb.g, b: rgb.b }, hsv.a) alphaInput.value String(Math.round(hsv.a * 100)) nextTick(syncCursors) } watch(() props.modelValue, (val) { if (val val.toLowerCase() ! currentColor.value.toLowerCase()) { applyModelValue(val) } }) /* ----------------- 鼠标交互 ----------------- */ function onSvMouseDown(e) { updateSv(e) const move (ev) updateSv(ev) const up () { document.removeEventListener(mousemove, move) document.removeEventListener(mouseup, up) } document.addEventListener(mousemove, move) document.addEventListener(mouseup, up) } function updateSv(e) { const rect svRef.value.getBoundingClientRect() const x clamp(e.clientX - rect.left, 0, rect.width) const y clamp(e.clientY - rect.top, 0, rect.height) hsv.s x / rect.width hsv.v 1 - y / rect.height svCursor.left x svCursor.top y hexInput.value currentColor.value emit(change, currentColor.value) } function onHueMouseDown(e) { updateHue(e) const move (ev) updateHue(ev) const up () { document.removeEventListener(mousemove, move) document.removeEventListener(mouseup, up) } document.addEventListener(mousemove, move) document.addEventListener(mouseup, up) } function updateHue(e) { const rect hueRef.value.getBoundingClientRect() const x clamp(e.clientX - rect.left, 0, rect.width) hsv.h (x / rect.width) * 360 hueThumbLeft.value x hexInput.value currentColor.value emit(change, currentColor.value) } function onAlphaMouseDown(e) { updateAlpha(e) const move (ev) updateAlpha(ev) const up () { document.removeEventListener(mousemove, move) document.removeEventListener(mouseup, up) } document.addEventListener(mousemove, move) document.addEventListener(mouseup, up) } function updateAlpha(e) { const rect alphaRef.value.getBoundingClientRect() const x clamp(e.clientX - rect.left, 0, rect.width) hsv.a x / rect.width alphaThumbLeft.value x alphaInput.value String(Math.round(hsv.a * 100)) hexInput.value currentColor.value emit(change, currentColor.value) } /* ----------------- 输入框 / 原生取色 / 预设 ----------------- */ function onHexInput() { // 输入过程中合法才更新颜色预览不覆盖输入框不合法则放行让用户继续输入 const rgb hexToRgb(hexInput.value) if (!rgb) return const { h, s, v } rgbToHsv(rgb) hsv.h h hsv.s s hsv.v v hsv.a rgb.a ?? 1 alphaInput.value String(Math.round(hsv.a * 100)) nextTick(syncCursors) emit(change, currentColor.value) } function onHexBlur() { // 失焦/回车标准化为完整 HEX不合法则还原 hexInput.value currentColor.value } function onAlphaInput() { // 输入过程中合法数字才更新不覆盖输入框 let n Number(alphaInput.value) if (Number.isNaN(n)) return n clamp(n, 0, 100) hsv.a n / 100 nextTick(syncCursors) hexInput.value currentColor.value emit(change, currentColor.value) } function onAlphaBlur() { // 失焦/回车标准化百分比不合法则还原 let n Number(alphaInput.value) if (Number.isNaN(n)) n Math.round(hsv.a * 100) alphaInput.value String(clamp(n, 0, 100)) } function onNativePick(e) { applyModelValue(e.target.value) emit(change, currentColor.value) } function onPresetClick(c) { applyModelValue(c) emit(change, currentColor.value) } /* ----------------- 面板显隐 ----------------- */ function togglePanel() { if (props.disabled) return if (!visible.value) { backupHex props.modelValue applyModelValue(props.modelValue) } visible.value !visible.value if (visible.value) nextTick(syncCursors) } function onCancel() { applyModelValue(backupHex) visible.value false } function onConfirm() { emit(update:modelValue, currentColor.value) emit(change, currentColor.value) visible.value false } /* ----------------- 外部点击关闭 ----------------- */ function onDocClick(e) { if (!visible.value) return if (rootRef.value !rootRef.value.contains(e.target)) { visible.value false } } onMounted(() { applyModelValue(props.modelValue) document.addEventListener(mousedown, onDocClick) }) onBeforeUnmount(() { document.removeEventListener(mousedown, onDocClick) }) /script style scoped .color-select { position: relative; display: inline-block; font-size: 12px; user-select: none; } .color-select__trigger { display: inline-flex; align-items: center; gap: 6px; padding: 4px 8px; border: 1px solid #d9d9d9; border-radius: 6px; background: #fff; cursor: pointer; transition: border-color 0.2s; } .color-select__trigger:hover { border-color: #1677ff; } .color-select__swatch { width: 18px; height: 18px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.1); background-image: linear-gradient(45deg, #eee 25%, transparent 25%), linear-gradient(-45deg, #eee 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eee 75%), linear-gradient(-45deg, transparent 75%, #eee 75%); background-size: 8px 8px; background-position: 0 0, 0 4px, 4px -4px, -4px 0; position: relative; } .color-select__swatch::after { content: ; position: absolute; inset: 0; border-radius: 3px; background: inherit; } .color-select__text { min-width: 70px; font-family: monospace; } .color-select__arrow { font-size: 10px; color: #999; } .color-select__panel { position: absolute; top: calc(100% 6px); left: 0; z-index: 1050; width: 240px; padding: 12px; background: #fff; border-radius: 8px; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); } .color-select__sv { position: relative; width: 100%; height: 140px; border-radius: 4px; cursor: crosshair; } .color-select__sv-white { position: absolute; inset: 0; background: linear-gradient(to right, #fff, transparent); } .color-select__sv-black { position: absolute; inset: 0; background: linear-gradient(to top, #000, transparent); } .color-select__sv-cursor { position: absolute; width: 12px; height: 12px; margin: -6px 0 0 -6px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 2px rgba(0, 0, 0, 0.5); pointer-events: none; } .color-select__hue, .color-select__alpha { position: relative; height: 10px; margin-top: 10px; border-radius: 5px; cursor: pointer; } .color-select__hue-bar { position: absolute; inset: 0; border-radius: 5px; background: linear-gradient( to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100% ); } .color-select__alpha-bar { position: absolute; inset: 0; border-radius: 5px; background-image: linear-gradient(45deg, #eee 25%, transparent 25%), linear-gradient(-45deg, #eee 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #eee 75%), linear-gradient(-45deg, transparent 75%, #eee 75%); background-size: 8px 8px; background-position: 0 0, 0 4px, 4px -4px, -4px 0; } .color-select__hue-thumb, .color-select__alpha-thumb { position: absolute; top: 50%; width: 12px; height: 12px; margin: -6px 0 0 -6px; background: #fff; border: 2px solid #1677ff; border-radius: 50%; box-shadow: 0 0 2px rgba(0, 0, 0, 0.3); pointer-events: none; } .color-select__inputs { display: flex; gap: 8px; margin-top: 12px; align-items: center; } .color-select__input-group { flex: 1; display: flex; } .color-select__hex { flex: 1; font-family: monospace; } .color-select__native { width: 32px; height: 28px; padding: 0; border: 1px solid #d9d9d9; border-left: none; background: #fff; cursor: pointer; } .color-select__alpha-input { width: 90px; } .color-select__presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } .color-select__preset { width: 18px; height: 18px; border-radius: 3px; border: 1px solid rgba(0, 0, 0, 0.15); cursor: pointer; transition: transform 0.1s; } .color-select__preset:hover { transform: scale(1.15); } .color-select__preset.is-active { outline: 2px solid #1677ff; outline-offset: 1px; } .color-select__footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; } .color-fade-enter-active, .color-fade-leave-active { transition: opacity 0.15s, transform 0.15s; } .color-fade-enter-from, .color-fade-leave-to { opacity: 0; transform: translateY(-4px); } /style