ChromeのColorPickerを実装した

TODO: gif, refactor, github

         // https://gist.github.com/mjackson/5311256
        class Picker {
            constructor() {
                this.view = {
                    canvas: document.getElementById('canvas'),
                    colorPanel: document.getElementById('colorPanel'),
                    dummyPalette: document.getElementById('dummyPalette'),
                    paletteThumb: document.getElementById('paletteThumb'),
                    dummySlider: document.getElementById('dummySlider'),
                    sliderThumb: document.getElementById('sliderThumb'),
                    hexInput: document.getElementById('hex-input'),
                }
                
                this.colorUtil = {
                    rgbToHsv: (r, g, b) => {
                        r /= 255, g /= 255, b /= 255;
                        let max = Math.max(r, g, b), min = Math.min(r, g, b);
                        let h, s, v = max;
                        let d = max - min;
                        s = max == 0 ? 0 : d / max;
                        if (max === min) {
                            h = 0; // achromatic
                        } else {
                            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, s, v ];
                    },
                    hsvToRgb: (h, s, v) => {
                        let r, g, b;   
                        let i = Math.floor(h * 6);
                        let f = h * 6 - i;
                        let p = v * (1 - s);
                        let q = v * (1 - f * s);
                        let t = v * (1 - (1 - f) * s);
                        
                        switch (i % 6) {
                            case 0: r = v, g = t, b = p; break;
                            case 1: r = q, g = v, b = p; break;
                            case 2: r = p, g = v, b = t; break;
                            case 3: r = p, g = q, b = v; break;
                            case 4: r = t, g = p, b = v; break;
                            case 5: r = v, g = p, b = q; break;
                            default: break;
                        }
                        
                        return { r: r * 255, g: g * 255, b: b * 255 };
                    },
                    hexToRgb: (hex) => {
                        if (hex.length !== 6) { return }
                        const rgb = [hex.slice(0, 2), hex.slice(2, 4), hex.slice(4, 6)]
                        .map(str => parseInt(str, 16 ))
                        if (rgb.includes(NaN)) {
                            return
                        }
                        return rgb
                    },
                }
                
                this.state = new Proxy({hue: 0, brightness: 1, saturation: 1}, {
                    set: (obj, prop, value) => {
                        if (prop === 'hue') {
                            this.didSetHue(value);
                            this.updateView();
                        }
                        obj[prop] = value;
                        if (prop === 'brightness') {
                            this.updateView()
                        }
                        return true;
                    }
                }) 
                
                this.handleSlider();
                this.handleColorPalette();
                this.handleHexInput();
                this.didSetHue(0)
                this.updateView()
            }
            
            
            drawGradient(fillColor) {
                const { canvas } = this.view;
                const context = canvas.getContext('2d');
                const rect = { x: 0, y: 0, width: canvas.clientWidth, height: canvas.clientHeight };
                
                const whiteGradation = context.createLinearGradient(0, 0, rect.width, 0);
                whiteGradation.addColorStop(0, 'rgba(255, 255, 255 ,1)');
                whiteGradation.addColorStop(1, 'rgba(255, 255, 255, 0)');
                
                const blackGradation = context.createLinearGradient(0, 0, 0, rect.height);
                blackGradation.addColorStop(0, 'rgba(0, 0, 0, 0)');
                blackGradation.addColorStop(1, 'rgba(0, 0, 0, 1)');
                
                [fillColor, whiteGradation, blackGradation].forEach(fillColor => {
                    context.fillStyle = fillColor;
                    context.fillRect(rect.x, rect.y, rect.width, rect.height);
                })
            }
            
            handleSlider() {
                const { dummySlider, sliderThumb } = this.view;
                let isSliderDragging = false;
                dummySlider.addEventListener('mousemove', (e) => {
                    if (!isSliderDragging) { return }
                    sliderThumb.style.left = `${e.offsetX - sliderThumb.clientWidth / 2}px`;
                    this.state.hue = e.offsetX / e.toElement.clientWidth;
                })
                dummySlider.addEventListener('mousedown', (e) => isSliderDragging = true);
                dummySlider.addEventListener('mouseup', (e) => isSliderDragging = false);
                
                dummySlider.addEventListener('touchmove', (e) => {
                    e.preventDefault()
                    if (!isSliderDragging) { return }
                    sliderThumb.style.left = `${e.changedTouches[0].pageX - sliderThumb.clientWidth / 2}px`;
                    this.state.hue = e.changedTouches[0].pageX / e.toElement.clientWidth;
                })
                dummySlider.addEventListener('touchstart', (e) => isSliderDragging = true);
                dummySlider.addEventListener('touchend', (e) => isSliderDragging = false); 
            }
            
            handleColorPalette() {
                const { dummyPalette, paletteThumb } = this.view;
                let isPaletteDragging = false;
                dummyPalette.addEventListener('mousemove', (e) => {
                    console.log(0)
                    if (!isPaletteDragging) { return }   
                    paletteThumb.style.left = `${e.offsetX - paletteThumb.clientWidth / 2}px`;
                    paletteThumb.style.top = `${e.offsetY - paletteThumb.clientHeight / 2}px`;
                    this.state.saturation = e.offsetX / e.toElement.clientWidth;
                    this.state.brightness = 1 - e.offsetY / e.toElement.clientHeight;
                })
                
                dummyPalette.addEventListener('mousedown', () => isPaletteDragging = true) 
                dummyPalette.addEventListener('mouseup', () => isPaletteDragging = false);
            }
            
            handleHexInput() {
                const { hexInput } = this.view;
                hexInput.addEventListener('input', (e) => {
                    const rgb = this.colorUtil.hexToRgb(e.target.value.slice(1));
                    if(rgb) {
                        const hsv = this.colorUtil.rgbToHsv(rgb[0], rgb[1], rgb[2]);
                        this.state.saturation = hsv[1]
                        this.state.brightness = hsv[2]
                        this.state.hue = hsv[0]
                    }
                })
            }
            
            didSetHue() {
                const rgb = this.colorUtil.hsvToRgb(this.state.hue, 1, 1);
                const color = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)`;
                const { sliderThumb } = this.view;
                sliderThumb.style.backgroundColor = color;  
                this.drawGradient(color);
            }
            
            updateView() {
                const { colorPanel, paletteThumb, sliderThumb, dummyPalette, dummySlider, hexInput } = this.view;
                const rgb = this.colorUtil.hsvToRgb(this.state.hue, this.state.saturation, this.state.brightness);
                const rgbaColor = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 1)`;
                colorPanel.style.backgroundColor = rgbaColor;
                paletteThumb.style.backgroundColor = rgbaColor;
                
                // TODO: rgb to hex string
                hexInput.value = `#${Object.values(rgb).map(value => ("0" + parseInt(value).toString(16)).slice(-2)).join("")}`;
                
                // thumb point
                paletteThumb.style.left = `${dummyPalette.clientWidth * this.state.saturation - paletteThumb.clientWidth / 2}px`;
                paletteThumb.style.top = `${dummyPalette.clientHeight * (1 - this.state.brightness) - paletteThumb.clientHeight / 2}px`;
                sliderThumb.style.left = `${dummySlider.clientWidth * this.state.hue - sliderThumb.clientWidth / 2}px`;
                
                // delegate
                if(this.onColorChange) {
                    this.onColorChange(rgbaColor);
                }
            }
        }
        
        const picker = new Picker()
        picker.onColorChange = (color) => console.log(color)
<html>
<style>
    .hue-slider {
        position: absolute;
        /* width, centerY, to parent element */
        top: 50%;
        transform : translateY(-50%);
        width: 100%;
        height: 10px;
        background: linear-gradient(to right,
        hsl(0,100%,50%),
        hsl(60,100%,50%),
        hsl(120,100%,50%),
        hsl(180,100%,50%),
        hsl(240,100%,50%),
        hsl(300,100%,50%),
        hsl(360,100%,50%)
        );
        border-radius: 12px;
    }
    
    .thumb {
        position: absolute;
        top: 50%;
        left: -12px; 
        /*  sliderの値が0の時thumbの中央がsliderの左端の真上に来るようにsize / 2ずらす */
        transform: translateY(-50%);
        width: 24px;
        height: 24px;
        border-radius: 15px;
        border: 2px solid white;
        cursor: pointer;
    }
    
    .color-panel-thumb {
        position: absolute;
        top: -12;
        left: -12px; 
        width: 24px;
        height: 24px;
        border-radius: 15px;
        border: 2px solid white;
    }
    
    .hue-slider-container {
        position: relative;
        height: 50px;
        width: 90%;
        margin: auto;
        background-color: white;
    }
    
    .dummy-slider {
        position: relative;
        width: 100%;
        height: 100%;
        background-color: transparent;
    }
    
    .vstack {
        display: flex;
        flex-direction: column;
    }
    .hstack {
        display: flex;
        flex-direction: row;
        justify-content: space-between;
    }
    #color-panel {
        width: 100%;
        background-color: red;
        position: relative;
    }
    
    #color-panel > div {
        color: white;
        font-size: 30px;
        top: 50%;
        left: 50%;
        transform: translate(-50%,-50%);
        position:absolute;
        text-align: center;
    }
    
    .container {
        background-color: white;
        width: 800px;
        border: 1px solid gray;
    }
    
    .color-string-container {
        width: 95%;
        margin: auto;
        height: 50px;
        
    }
    
    .hex-string-container {
        width: 100%;
        position: relative;
    }
    
    .hex {
        left: 50%;
        transform: translate(-50%,-45%);
        position: absolute;
        width: 35px;
        background: #fff;
        font-size: 12px;
        text-align: center;
        color: #3c4043;
    }
    
    
    .border {
        border: 1px solid #dadce0;
        border-radius: 5px;
        height: 36px;
        margin: 5px auto 5px auto;
    }
    
    input {
        text-align: center;
        background-color: #fff;
        position: absolute;
        top: 50%;
        left: 50%;
        transform: translate(-50%,-50%);
        width: 78%;
        border: none;
        font-family: Roboto,Arial,sans-serif;
        font-size: 12px;
        letter-spacing: 0.15px;
        color: #3c4043;
        outline: none;
    }
    
    .color-palette {
        position: relative;
    }
    
    
    #dummyPalette {
        position: absolute;
        background-color: transparent;
        width: 100%;
        height: 100%;
        top: 0px;
        left: 0px;
    }
    
</style>
<body>
    <div class="vstack container">
        <div class="hstack">
            <div id="colorPanel"><div>color</div></div>
            <div class="color-palette">
                <div id="paletteThumb" class="color-panel-thumb"></div>
                <canvas id="canvas" width="600px" height="300px"></canvas>
                <div id="dummyPalette"></div>
            </div>
        </div>
        <div class="hue-slider-container">
            <div class="hue-slider"></div>
            <div id="sliderThumb" class="thumb"></div>
            <div id="dummySlider" class="dummy-slider"></div>
        </div>
        <div class="color-string-container">
            <div class="hex-string-container">
                <div class="hex">HEX</div>
                <input id="hex-input" value="#123456">
                <div class="border"></div>
            </div>
        </div>
    </div>
    
    <script> </script>
</body>
</html>