2024-03-11 01:33:47 +08:00

730 lines
20 KiB
Vue

<!--
* @Author: ShawnPhang
* @Date: 2023-05-26 17:42:26
* @Description: 调色板
* @LastEditors: ShawnPhang <https://m.palxp.cn>
* @LastEditTime: 2024-01-31 10:44:41
-->
<template>
<div class="color-picker">
<Tabs v-if="modes.length > 1" :value="mode" @update:value="onChangeMode">
<TabPanel v-for="label in modes" :key="label" :label="label"> </TabPanel>
</Tabs>
<div v-else class="title">{{ mode }}</div>
<template v-if="showGradient">
<div v-show="mode === '渐变'" class="cp__gradient flex-center">
<div class="cp__gradient-bar">
<div ref="elGradientTrack" class="cpgb__track" style="width: 100%" :style="{ background: value }">
<!-- tabindex="-1" 是元素可以触发 keydown 事件 -->
<div
v-for="(gradient, index) in gradients"
:key="index"
:class="[
'cpgb__pointer',
{
'cpgb__pointer--active': gradient === activeGradient,
},
]"
:data-sort="index"
:style="{
left: `${gradient.offset * 100}%`,
background: gradient.color,
}"
tabindex="-1"
@mousedown="onMousedownGradientPointer(gradient)"
@keydown.stop="onKeyupGradientPointer"
></div>
</div>
</div>
<AngleHandleVue v-model="angle" @change="angleChange" />
</div>
</template>
<div ref="elPalette" class="cp__palette" :style="{ background: paletteBackground }">
<div class="cpp__color-saturation"></div>
<div class="cpp__color-value"></div>
<div ref="elPalettePointer" class="cpp__pointer"></div>
</div>
<div ref="elSliderHux" class="cp__slider cp__slider-hux">
<div class="cps__track">
<div ref="elSliderHuxPointer" class="cpst__pointer"></div>
</div>
</div>
<div ref="elSliderAlpha" class="cp__slider cp__slider-alpha">
<div class="cpsa__background" :style="sliderAlphaBackgroundStyle"></div>
<div class="cps__track">
<div ref="elSliderAlphaPointer" class="cpst__pointer"></div>
</div>
</div>
<div class="cp__box">
<div class="item" @click="onClickStraw">
<xiguan v-if="hasEyeDrop" />
<input v-else class="native" type="color" @input="onClickStraw" />
</div>
<!-- <input :value="value" @input="$emit('update:value', $event.target.value)" class="input" /> -->
<input v-if="mode === '渐变'" class="input" :value="activeGradient.color" />
<input v-else :value="value" class="input" @blur="onInputBlur" />
<template v-if="mode === '纯色'">
<div v-for="pc in predefine" :key="pc" class="item item-color" :style="{ background: pc }" @click="onClickStraw({ target: { value: pc } })"></div>
</template>
<!-- <input :value="alpha" class="w-12" size="small" :min="0" :max="100" @input="onChangeAlpha" @change="onChangeAlpha" /> -->
</div>
</div>
</template>
<script>
import './index.css'
export default {
name: 'ColorPicker',
inheritAttrs: false,
}
</script>
<script setup>
import { ref, reactive, computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'
import { registerMoveableElement } from './utils/moveable.ts'
import { hexA2HSLA, HSLA2HexA, hex2RGB, RGB2HSL, hexA2RGBA, RGBA2HexA } from './utils/color.ts'
import { toGradientString, parseBackgroundValue, toolTip } from './utils/helper.ts'
import Tabs from './comps/Tabs.vue'
import xiguan from './comps/svg.vue'
import TabPanel from './comps/TabPanel.vue'
import { debounce } from 'throttle-debounce'
import AngleHandleVue from './comps/AngleHandle.vue'
const props = defineProps({
value: {
type: String,
default: '#ffffffff',
},
modes: {
type: Array,
default: () => ['纯色', '渐变'], // 图案
},
defaultColor: {
type: String,
default: '#ffffffff',
},
defaultGradient: {
type: String,
default: 'linear-gradient(90deg, #fffae0ff 0%, #ffd1f1ff 100%)',
},
defaultImage: {
type: String,
default: 'https://st0.dancf.com/csc/157/material-2d-textures/0/20190714-174653-ed3c.jpg',
},
})
const emit = defineEmits(['update:value', 'change', 'native-pick', 'blur'])
const mode = ref(parseBackgroundValue(props.value)) // 颜色、渐变、图片
const angle = ref(90)
const gradients = ref([])
const hsla = reactive({ h: 0, s: 0, l: 0, a: 0 })
const paletteBackground = ref('#f00')
const hex = ref('#000')
const alpha = ref(0)
let activeGradient = ref({})
const hasEyeDrop = 'EyeDropper' in window
const elGradientTrack = ref()
const elPalette = ref()
const elPalettePointer = ref()
const elSliderHuxPointer = ref()
const elSliderHux = ref()
const elSliderAlphaPointer = ref()
const elSliderAlpha = ref()
// const elStrawCanvas = ref();
let gradientMoveable = null
let paletteMoveable = null
let sliderHuxMoveable = null
let sliderAlphaMoveable = null
let mousedownGradientPointer = null
let backendHex = null
// 是否可以改变 palette sliderHux sliderAlpha 的 pointer 位置
let canChangeHSLAPointerPos = true
let canChangeHSLAPointerPosTimer = null
const predefine = ref([]) // 历史记录
const record = {
color: props.defaultColor,
gradient: props.defaultGradient,
image: props.defaultImage,
}
const showGradient = computed(() => {
return props.modes.includes('渐变')
})
const sliderAlphaBackgroundStyle = computed(() => {
const rgb = hex2RGB(hex.value).join(',')
return {
background: `linear-gradient(to right, rgba(${rgb}, 0) 0%, rgb(${rgb}) 100%)`,
}
})
watch(activeGradient, (value) => {
setColor(value.color)
})
watch(hex, (value) => {
onChangeHex(value)
})
watch(
() => props.value,
(value) => {
const _mode = parseBackgroundValue(value)
if (_mode !== mode.value) {
mode.value = _mode
}
changeMode(_mode)
recordValue(value)
addHistory(value)
},
)
// TODO: 添加选择历史记录
const addHistory = debounce(300, async (value) => {
const history = predefine.value
// 如果已经存在就提到前面来,避免重复
const index = history.indexOf(value)
if (index !== -1) {
predefine.value.splice(index, 1)
}
if (history.length >= 4) {
predefine.value.splice(history.length - 1, 1)
}
// 把最新的颜色放在头部
const head = [value]
predefine.value = head.concat(history)
})
const unwatchHSLA = watch(hsla, onChangeHSLA, { deep: true })
function onChangeHSLA(newHsla) {
const hexA = HSLA2HexA(...Object.values(newHsla))
let value
if (mode.value === '纯色') {
value = hexA
} else if (mode.value === '渐变') {
activeGradient.value.color = hexA
value = toGradientString(angle.value, gradients.value)
}
updateColorData(hexA)
updateValue(value)
}
onMounted(onMountedCallback)
async function onMountedCallback() {
elPalettePointer.value.style.left = `${hsla.s}%`
elPalettePointer.value.style.top = `${100 - hsla.l}%`
elSliderHuxPointer.value.style.left = `${(hsla.h / 360) * 100}%`
elSliderAlphaPointer.value.style.left = `${hsla.a * 100}%`
if (showGradient.value) {
gradientMoveable = registerMoveableElement(elGradientTrack.value, {
onmousedown: onMousedownGradient,
onmousemove: onMousemoveGradient,
onmouseup: onMouseupGradient,
})
}
function onMousedownGradient(position) {
if (mousedownGradientPointer) {
return
}
const index = gradients.value.findIndex((stop) => stop.offset >= position.x)
const start = gradients.value[index - 1]
const startRGBA = hexA2RGBA(start.color)
const end = gradients.value[index]
const endRGBA = hexA2RGBA(end.color)
const rgb = []
for (let i = 0; i < 3; i += 1) {
rgb.push(startRGBA[i] + (endRGBA[i] - startRGBA[i]) * position.x)
}
const a = end.offset - position.x - (position.x - start.offset) > 0 ? startRGBA[3] : endRGBA[3]
const color = RGBA2HexA(...rgb, a)
activeGradient.value = {
color,
offset: position.x,
}
gradients.value.splice(index, 0, activeGradient.value)
}
function onMousemoveGradient(position) {
if (!mousedownGradientPointer) return
activeGradient.value.offset = position.x
gradients.value.sort((a, b) => a.offset - b.offset)
const value = toGradientString(angle.value, gradients.value)
updateValue(value)
}
function onMouseupGradient() {
mousedownGradientPointer = false
}
paletteMoveable = registerMoveableElement(elPalette.value, {
onmousemove: onChangeSL,
onmouseup: onChangeSL,
})
function onChangeSL(position) {
disableChangeHSLA()
const x = position.x * 100
const y = position.y * 100
hsla.s = Math.round(x)
hsla.l = Math.round(100 - y)
elPalettePointer.value.style.left = `${x}%`
elPalettePointer.value.style.top = `${y}%`
}
sliderHuxMoveable = registerMoveableElement(elSliderHux.value, {
onmousemove: onChangeHux,
onmouseup: onChangeHux,
})
function onChangeHux(position) {
disableChangeHSLA()
hsla.h = position.x * 360
elSliderHuxPointer.value.style.left = `${position.x * 100}%`
}
sliderAlphaMoveable = registerMoveableElement(elSliderAlpha.value, {
onmousemove: onChangeAlpha,
onmouseup: onChangeAlpha,
})
function onChangeAlpha(position) {
disableChangeHSLA()
hsla.a = position.x
elSliderAlphaPointer.value.style.left = `${position.x * 100}%`
}
changeMode(mode.value)
recordValue(props.value)
}
onBeforeUnmount(() => {
paletteMoveable?.destroy()
sliderHuxMoveable?.destroy()
sliderAlphaMoveable?.destroy()
unwatchHSLA()
if (gradientMoveable) {
gradientMoveable.destroy()
}
})
function recordValue(value) {
if (mode.value === '纯色') {
record.color = value
} else if (mode.value === '渐变') {
record.gradient = value
} else if (mode.value === '图案') {
record.image = value
}
}
function updateValue(value) {
if (value === props.value) return
recordValue(value)
emit('update:value', value)
emit('change', {
mode: mode.value,
color: value,
angle: Number(angle.value),
stops: gradients.value,
})
}
async function onChangeMode(value) {
if (value === mode.value) return
mode.value = value
let color
if (value === '纯色') {
color = record.color
} else if (value === '渐变') {
color = record.gradient
} else if (value === '图案') {
color = record.image
}
updateValue(color)
}
function changeMode(mode) {
if (mode === '纯色') {
setColor(props.value)
} else if (mode === '渐变') {
if (gradients.value.length === 0) {
props.value.match(/[^,]+/g).forEach((item, index) => {
if (index === 0) {
angle.value = Number(item.match(/\d+/)[0])
return
}
let [color, offset] = item.trim().split(' ')
if (!color.startsWith('#')) color = RGBA2HexA(color)
offset = offset.match(/\d+/)[0] / 100
gradients.value.push({ color, offset })
activeGradient.value = gradients.value[0]
})
} else {
setColor(activeGradient.value.color)
}
}
// TODO: 图案
}
function updateColorData(hexA) {
paletteBackground.value = `hsl(${hsla.h}, 100%, 50%)`
hex.value = hexA.slice(0, 7)
backendHex = hex.value
alpha.value = Math.round((hsla.a ?? 1) * 100)
}
function setColor(color) {
// 通过 palette sliderHux sliderAlpha 交互改变 pointer 位置
// 已经改变 hsla 的值并触发 update:value
// watch props.value 再调用当前方法时无需再更新 hsla
if (canChangeHSLAPointerPos) {
const _hsla = hexA2HSLA(color)
hsla.h = _hsla[0]
hsla.s = _hsla[1]
hsla.l = _hsla[2]
hsla.a = _hsla[3]
updateColorData(color)
let x = hsla.s
const y = Math.round(100 - hsla.l)
elPalettePointer.value.style.left = `${x}%`
elPalettePointer.value.style.top = `${y}%`
x = hsla.h / 360
elSliderHuxPointer.value.style.left = `${x * 100}%`
elSliderAlphaPointer.value.style.left = `${hsla.a * 100}%`
}
}
function onMousedownGradientPointer(stop) {
mousedownGradientPointer = true
activeGradient.value = stop
}
function onKeyupGradientPointer(event) {
event.stopPropagation()
event.preventDefault()
if (!['Backspace', 'Delete'].includes(event.key)) return
if (gradients.value.length === 2) return
const index = gradients.value.indexOf(activeGradient.value)
gradients.value.splice(index, 1)
activeGradient.value = gradients.value[0]
}
function onChangeHex(value) {
if (/^#(?:[0-9a-f]{3}){1,2}$/i.test(value)) {
const rgb = hex2RGB(value)
const [h, s, l] = RGB2HSL(...rgb)
hsla.h = h
hsla.s = s
hsla.l = l
elPalettePointer.value.style.left = `${hsla.s}%`
elPalettePointer.value.style.top = `${100 - hsla.l}%`
elSliderHuxPointer.value.style.left = `${(hsla.h / 360) * 100}%`
hex.value = value
} else {
// hex.value = backendHex
}
}
function onChangeAlpha(value) {
hsla.a = value / 100
elSliderAlphaPointer.value.style.left = `${value}%`
}
function disableChangeHSLA() {
canChangeHSLAPointerPos = false
if (canChangeHSLAPointerPosTimer) clearTimeout(canChangeHSLAPointerPosTimer)
canChangeHSLAPointerPosTimer = setTimeout(() => {
canChangeHSLAPointerPos = true
}, 16)
}
async function onClickStraw(val) {
let result = ''
if (val && val.target.value) {
const color = val.target.value
result = color + (color.length === 7 ? 'ff' : '')
} else {
const eyeDropper = new window.EyeDropper() // 初始化一个EyeDropper对象
toolTip('按Esc可退出')
try {
const drop = await eyeDropper.open() // 开始拾取颜色
const colorHexValue = drop.sRGBHex
result = colorHexValue + 'ff'
} catch (e) {
console.log('用户取消了取色')
}
}
if (mode.value === '渐变') {
activeGradient.value.color = result
activeGradient.value = { ...activeGradient.value }
} else {
emit('update:value', result)
}
emit('native-pick', result)
}
const onInputBlur = (e) => {
const fixColor = patchHexColor(e.target.value)
emit('blur', fixColor)
emit('update:value', fixColor)
}
function patchHexColor(str) {
let hex = str.replace(/\s/g, '') // 移除空格
if (!str.startsWith('#')) {
hex = '#' + hex
}
if (hex.length < 9) {
hex = hex.padEnd(9, 'f')
}
return hex
}
function angleChange() {
updateValue(toGradientString(angle.value, gradients.value))
}
</script>
<style lang="less" scoped>
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: #e5e7eb;
}
.flex-center {
display: flex;
justify-content: center;
align-items: center;
}
.title {
margin-bottom: 0.75rem;
font-size: 15px;
font-weight: 600;
}
.color-picker {
-webkit-user-select: none;
user-select: none;
min-width: 220px;
}
.cp__gradient {
&-bar {
display: flex;
justify-content: center;
height: 16px;
width: 100%;
padding: 0 8px;
}
}
.cpgb__track {
position: relative;
cursor: pointer;
}
.cpgb__pointer {
cursor: grab;
position: absolute;
top: -0px;
top: -0.125rem;
height: 1.25rem;
--tw-translate-x: -50%;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-width: 2px;
border-style: solid;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
width: 18px;
&--active {
z-index: 1;
border-radius: 3px;
box-shadow: 0 0 4px 0 rgb(0 0 0 / 20%), 0 0 0 1.2px #2254f4;
}
}
.cp__palette {
height: 140px;
position: relative;
margin-top: 0.75rem;
margin-top: 0.875rem;
cursor: pointer;
overflow: hidden;
border-radius: 0.25rem;
.cpp__color-saturation,
.cpp__color-value {
position: absolute;
bottom: 0px;
right: 0px;
top: 0px;
width: 100%;
height: 100%;
}
.cpp__color-saturation {
background-image: linear-gradient(to right, var(--tw-gradient-stops));
--tw-gradient-from: #fff var(--tw-gradient-from-position);
--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.cpp__color-value {
background-image: linear-gradient(to top, var(--tw-gradient-stops));
--tw-gradient-from: #000 var(--tw-gradient-from-position);
--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.cpp__pointer {
position: absolute;
height: 0.75rem;
width: 0.75rem;
--tw-translate-x: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
--tw-translate-x: -0.375rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
--tw-translate-y: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-radius: 9999px;
border-width: 2px;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
}
.cp__slider {
position: relative;
margin-top: 0.75rem;
margin-top: 0.875rem;
height: 0.5rem;
border-radius: 0.25rem;
&-hux {
background: linear-gradient(90deg, red 0, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, red);
}
&-alpha {
background: linear-gradient(to top right, hsla(0, 0%, 80%, 0.4) 25%, transparent 0, transparent 75%, hsla(0, 0%, 80%, 0.4) 0, hsla(0, 0%, 80%, 0.4)), linear-gradient(to top right, hsla(0, 0%, 80%, 0.4) 25%, transparent 0, transparent 75%, hsla(0, 0%, 80%, 0.4) 0, hsla(0, 0%, 80%, 0.4));
background-size: 6px 6px;
background-position: 0 0, 3px 3px;
}
.cpsa__background {
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
height: 100%;
border-radius: 0.25rem;
}
}
.cp__box {
margin-top: 0.75rem;
margin-top: 0.875rem;
display: flex;
.item {
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
margin-left: 6px;
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: 4px;
}
.item-color {
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 6%);
}
.item:first-of-type {
margin: 0;
}
.item:hover {
transform: scale(1.08);
}
.input {
width: 4.7rem;
margin-left: 2px;
}
.native {
width: 100%;
height: 100%;
}
}
.cps__track {
position: absolute;
left: 0.25rem;
right: 0.25rem;
top: 0px;
}
.cpst__pointer {
cursor: pointer;
box-shadow: 0 0 2px rgb(0 0 0 / 60%);
position: absolute;
top: 0px;
box-sizing: content-box;
height: 0.5rem;
width: 0.5rem;
--tw-translate-x: -0.5rem;
--tw-translate-y: -0.25rem;
transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
border-radius: 9999px;
border-width: 4px;
--tw-border-opacity: 1;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
}
</style>