Canvas color picker

Jakiś czas temu stworzyłem stronkę ułatwiającą używanie ikon bootstrapa.
Chciałem tam zaimplementować color picker, ale okazało się, że większość sensownych działa w oparciu o jQuery, a jako że traktowałem ten projekt jako wyzwanie, nie chciałem iść na skróty. Postanowiłem więc w ramach treningu stworzyć coś własnego.

Poniższy tekst nie należy do tych prostszych. Nie prowadzę w nim za rączkę, opisując tylko przykładowy proces działania. Dodatkowo zakładam, że wiele rzeczy z tego kursu już przerobiłeś, ponieważ nie będę dokładnie ich opisywał.

Zaczynamy.

Po przejrzeniu kilku rozwiązań wymyśliłem sobie, że końcowy wygląd color pickera będzie wyglądał tak jak poniżej:

color picker

Czyli bez kombinowania i udziwniania.

Stworzyłem więc przykładowy html i css:

Pokaż HTML

    <div class="color">

        <div class="color-hue">
            <canvas class="color-hue-canvas" width="288" height="10"></canvas>
            <div class="color-hue-drag"></div>
        </div>

        <div class="color-canvas">
            <canvas class="color-canvas-canvas" width="288" height="150"></canvas>
            <div class="color-canvas-drag"></div>
        </div>

        <input class="color-input">
        <button class="color-btn" type="button">OK</button>

        <div class="color-library">
            <button class="color-library-add" type="button">+</button>

            <div class="color-library-colors">
                <div class="color-library-el" style="background: rgb(255, 0, 0);">
                    <button class="color-library-el-delete" type="button">usuń</button>
                </div>
                <div class="color-library-el" style="background: rgb(0, 255, 34);">
                    <button class="color-library-el-delete" type="button">usuń</button>
                </div>
                <div class="color-library-el" style="background: rgb(20, 157, 180);">
                    <button class="color-library-el-delete" type="button">usuń</button>
                </div>
            </div>
        </div>
    </div>
    

Pokaż CSS

    .color {
        width: 300px;
        background: #fff;
        box-sizing: border-box;
        border: 1px solid #eee;
        border-radius: 2px;
        padding: 5px;
        box-sizing: border-box;
        display: grid;
        gap: 5px;
        grid-template-columns: auto auto;
        grid-template-areas:
            "hue hue"
            "picker picker"
            "input button"
            "library library";
    }
    .color * {
        box-sizing: border-box;
    }
    .color canvas {
        margin: 0;
        border: 0;
        height: 100%;
    }
    .color-hue {
        height: 10px;
        position: relative;
        margin-top: 5px;
        grid-area: hue;
    }
    .color-hue-canvas {
        height: 100%;
        width: 100%;
        display: block;
        position: relative;
        z-index: 0;
        background: linear-gradient(to right, red 0%, #ff0 17%, lime 33%, cyan 50%, blue 66%, #f0f 83%, red 100%);
        background: red;
    }
    .color-hue-drag {
        width: 10px;
        border-radius: 2px;
        background: red;
        box-shadow: inset 0 0 0 2px #fff;
        height: 20px;
        border: 1px solid #555;
        position: absolute;
        left: 0;
        top: 50%;
        z-index: 1;
        transform: translate(-50%, -50%);
        pointer-events: none;
    }
    .color-canvas {
        grid-area: picker;
        position: relative;
        margin-top: 5px;
        height: 150px;
        /* box-shadow: 0 0 0 1px #eee; */
    }
    .color-canvas-drag {
        width: 15px;
        height: 15px;
        border-radius: 20px;
        position: absolute;
        left: 0;
        top: 0;
        transform: translate(-50%, -50%);
        box-shadow: inset 0 0 0 2px #fff, 0 0 2px #000;
        background: transparent;
        z-index: 1;
        pointer-events: none;
    }
    .color-canvas-canvas {
        position: relative;
        z-index: 0;
        width: 100%;
        height: 100%;
        /*box-shadow:  0 0 0 1px #eee;*/
    }

    .color-input {
        grid-area: input;
        padding: 10px 5px;
        height: 25px;
        border: 1px solid #ddd;
        font-family: sans-serif;
    }
    .color-btn {
        grid-area: button;
        background: #eee;
        color: #333;
        font-size: 11px;
        font-weight: bold;
        border: 0;
        border-radius: 2px;
        padding: 0 15px;
        cursor: pointer;
    }
    .color-btn:hover {
        background-color: #dfdfdf;
    }

    .color-library {
        grid-area: library;
        display: flex;
        gap: 8px;
        border-top: 1px solid #eee;
        margin-left: -5px;
        margin-right: -5px;
        padding: 5px 5px 0;
        min-height: 30px;
    }
    .color-library-add {
        background: #eee;
        color: #333;
        font-size: 11px;
        font-weight: bold;
        border: 0;
        border-radius: 2px;
        min-width: 30px;
        min-height: 30px;
        cursor: pointer;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-plus' viewBox='0 0 16 16'%3E  %3Cpath d='M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z'%3E%3C/path%3E%3C/svg%3E");
        background-position: center;
        background-size: no-repeat;
        background-repeat: no-repeat;
        text-indent: -9999px;
        overflow: hidden;
    }
    .color-library-add:hover {
        background-color: #dfdfdf;
    }
    .color-library-colors {
        display: flex;
        flex-wrap: wrap;
        gap: 8px;
        flex: 1;
    }
    .color-library-el {
        width: 30px;
        height: 30px;
        border-radius: 2px;
        position: relative;
        cursor: pointer;
    }
    .color-library-el-delete {
        width: 15px;
        height: 15px;
        position: absolute;
        right: -2px;
        top: -2px;
        cursor: pointer;
        border: 0;
        text-indent: -999px;
        overflow: hidden;
        border-radius: 10px;
        background-color: #fff;
        box-shadow: 0 0 0 1px #ddd;
        background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='currentColor' class='bi bi-x' viewBox='0 0 16 16'%3E  %3Cpath d='M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z'%3E%3C/path%3E%3C/svg%3E");
        background-position: center;
    }
    

Całość oczywiście będziemy tworzyć dynamicznie z podziałem na oddzielne pliki. Nie będziemy tutaj natomiast używał narzędzi bundlujących - ale jeżeli chcesz, śmiało je dodaj analogicznie do innych projektów w tym kursie (1, 2).

Funkcje pomocnicze

Z pewnością będziemy potrzebować kilku funkcji do konwersji kolorów między sobą - ot chociażby gdy podamy kolor w notacji heksadecymalnej i będziemy chcieli pobrać z niej kolor czerwony (skonwertujemy ją do rgb). Przejrzałem kilka wątków na stackoverflow oraz kodów różnych pluginów i uzyskałem mniej więcej taki zbiorek jak poniżej.


//functions.js

export function clamp (nr, min, max) {
    return Math.min(Math.max(nr, min), max);
}

export function disableSelect (disable = false) {
    document.body.style.userSelect = disable ? "none" : "auto";
}

export function rgb2hex (r, g, b) {
    return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
}

export function hex2rgb (hex) {
    hex = parseInt(((hex.indexOf("#") > -1) ? hex.substring(1) : hex), 16);
    return {
        r: hex >> 16,
        g: (hex & 0x00FF00) >> 8,
        b: (hex & 0x0000FF)
    };
}

export function rgb2hsl (r, g, b) {
    r /= 255, g /= 255, b /= 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    let h; let s; const l = (max + min) / 2;

    if (max == min) {
        h = s = 0; // achromatic
    } 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);
            break;
        case g:
            h = (b - r) / d + 2;
            break;
        case b:
            h = (r - g) / d + 4;
            break;
        }
        h /= 6;
    }

    return [h, s, l];
}

export function hsl2rgb (h, s, l) {
    let r, g, b;

    if (s == 0) {
        r = g = b = l; // achromatic
    } else {
        const hue2rgb = function hue2rgb (p, q, t) {
            if (t < 0) t += 1;
            if (t > 1) t -= 1;
            if (t < 1 / 6) return p + (q - p) * 6 * t;
            if (t < 1 / 2) return q;
            if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
            return p;
        };

        const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
        const p = 2 * l - q;
        r = hue2rgb(p, q, h + 1 / 3);
        g = hue2rgb(p, q, h);
        b = hue2rgb(p, q, h - 1 / 3);
    }
    return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}

export function hex2hsb (hex) {
    const hsb = rgb2hsb(hex2rgb(hex));
    if (hsb.s === 0) hsb.h = 360;
    return hsb;
}

export function rgb2hsb (rgb) {
    const hsb = {
        h: 0,
        s: 0,
        b: 0
    };
    const min = Math.min(rgb.r, rgb.g, rgb.b);
    const max = Math.max(rgb.r, rgb.g, rgb.b);
    const delta = max - min;
    hsb.b = max;
    hsb.s = max !== 0 ? 255 * delta / max : 0;
    if (hsb.s !== 0) {
        if (rgb.r === max) {
            hsb.h = (rgb.g - rgb.b) / delta;
        } else if (rgb.g === max) {
            hsb.h = 2 + (rgb.b - rgb.r) / delta;
        } else {
            hsb.h = 4 + (rgb.r - rgb.g) / delta;
        }
    } else {
        hsb.h = -1;
    }
    hsb.h *= 60;
    if (hsb.h < 0) {
        hsb.h += 360;
    }
    hsb.s *= 100 / 255;
    hsb.b *= 100 / 255;
    return hsb;
}

Klasa color picker

Zaczynam od głównej klasy, która zepnie wszystko w całość. Podobnie jak przy innych projektach w tym kursie (np. 1, 2), tworzymy klasę, do której tym razem przekażę miejsce umieszczenia pickera.


//colorpicker.js

export class ColorPicker {
    constructor(place) {
        this.place = place; //miejsce do którego wstawimy nasz color-picker
        this.color = "#FF0000";

        this.createElement();
        this.setColor(this.color);
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color");
        this.place.append(this.el);
    }

    setColor(color) {
        this.color = color;
    }
}

Hue slider

Pierwszym elementem który utworzymy będzie górny suwak, który będzie służył do wybierania odcienia.


//hue-slider.js

import { rgb2hsl, clamp, hex2rgb } from "./functions.js";

export class HueSlider {
    constructor(place) {
        this.place = place; //miejsce do którego wrzucimy element
        this.dragged = false; //czy rozpoczęto przeciąganie suwaka
        this.cursorPos = {x : 0, y : 0}; //pozycja wskaźnika

        this.createElement(); //tworzymy wszystkie elementy
        this.setBgGradient(); //ustawiamy gradient dla tła suwaka
        this.bindEvents(); //podpinamy zdarzenia
    }
}

Funkcja createElement() utworzy tło suwaka jako canvas, oraz wskaźnik, który będziemy mogli przesuwać wybierając aktualną barwę:


//hue-slider.js

export class HueSlider {
    ...

    //tworzy canvas i element do przesuwania
    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color-hue");

        //gradientowe tło
        this.canvas = document.createElement("canvas");
        this.canvas.classList.add("color-hue-canvas");
        this.el.append(this.canvas);

        //wskaźnik
        this.dragEl = document.createElement("div");
        this.dragEl.classList.add("color-hue-drag");
        this.el.append(this.dragEl);

        this.place.append(this.el);

        //ustawiamy szerokość i pobieramy kontekst canvasu
        this.canvas.width = this.canvas.offsetWidth;
        this.canvas.height = this.canvas.offsetHeight;
        this.ctx = this.canvas.getContext("2d");
    }
}

Kolejna funkcja setBgGradient() posłuży nam do stworzenia gradientowego tła:


//hue-slider.js

export class HueSlider {
    ...

    setBgGradient() {
        const gradientHue = this.ctx.createLinearGradient(0, 0, this.canvas.width, 0);
        gradientHue.addColorStop(0, "red");
        gradientHue.addColorStop(0.17, "yellow");
        gradientHue.addColorStop(0.33, "lime");
        gradientHue.addColorStop(0.5, "cyan");
        gradientHue.addColorStop(0.66, "blue");
        gradientHue.addColorStop(0.83, "magenta");
        gradientHue.addColorStop(1, "red");
        this.ctx.fillStyle = gradientHue;
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    }
}

Pozostaje więc podłączyć przesuwanie wskaźnika na sliderze.


//hue-slider.js

export class HueSlider {
    ...

    bindEvents() {
        this.canvas.addEventListener("mousedown", e => {
            this.dragged = true;
            this.drag(e);
        });

        document.addEventListener("mousemove", e => {
            if (this.dragged) this.drag(e);
        });

        document.addEventListener("mouseup", e => {
            this.dragged = false;
        });
    }
}

Dwa zdarzenia (mousemove i mouseup) podpiąłem dla całego dokumentu. Jeżeli wszystko podpiął bym dla canvasu, użytkownik przesuwając wskaźnik musiał by idealnie prowadzić swój kursor, co by było bardzo uciążliwe. Dzięki powyższemu rozwiązaniu użytkownik kliknie na slider, zacznie przesuwać, ale spokojnie będzie mógł uciec kursorem poza obszar slidera.

Podczas przesuwania kursora myszy będę ustawiał wskaźnik w odpowiedniej pozycji. Żeby to zrobić muszę go ustawić w pozycji e.pageX i e.pageY, ale równocześnie w przedziale od 0 do szerokości canvasu. Mógł bym to zrobić kilkoma prostymi ifami, ale użyję do tego funkcji clamp() (1), którą zaimportowałem z pliku z funkcjami.


//hue-slider.js

export class HueSlider {
    ...

    drag(e) {
        const g = this.canvas.getBoundingClientRect(); //pobieram pozycję i rozmiar canvasu

        //y mnie nie interesuje bo tylko przesuwam na boki
        //g zawiera dane względem viewportu więc muszę dodać potencjalne przesunięcie strony
        let x = clamp(e.pageX - (g.left + window.scrollX), 0, g.width);  boki

        this.cursorPos.x = Math.abs(x);

        if (this.cursorPos.x > this.canvas.width - 1) {
            this.cursorPos.x = this.canvas.width - 1;
        }

        this.cursorPos.y = g.height / 2;

        const color = this.getColor();

        this.dragEl.style.left = `${x}px`;
        this.dragEl.style.background = color.rgb;
    }
}

Wskaźnik ustawiam maksymalnie w pozycji this.canvas.width - 1. W testach wyszło mi, że pozycja this.canvas.width zwracała czarny kolor.

Na koniec potrzeba mi jeszcze 2 funkcji - jednej do ustawiania odpowiedniego koloru (czyli też ustawianiu wskaźnika w odpowiednim miejscu), oraz pobierającej aktualny kolor:


//hue-slider.js

export class HueSlider {
    ...

    setColor(color) {
        const colorRGB = hex2rgb(color);
        const hslColor = rgb2hsl(colorRGB.r, colorRGB.g, colorRGB.b);

        const hue = hslColor[0] * 360;
        const percent = hue / 360 * 100;

        this.cursorPos.x = Math.round(this.canvas.width * percent / 100);
        this.dragEl.style.left = `${this.cursorPos.x}px`;

        const colorGet = this.getColor();
        this.dragEl.style.background = colorGet.rgb;
    }

    getColor() {
        const pixel = this.ctx.getImageData(this.cursorPos.x, this.cursorPos.y, 1,1).data;
        const rgb = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`;
        return {
            rgb : rgb,
            r : pixel[0],
            g : pixel[1],
            b : pixel[2]
        };
    }
}

Komunikacja między komponentami

Gdy ustawię nowy kolor dla tego slidera, chciałbym by reszta elementów w naszym pikerze na to zareagowała. Przykładowo gdy użytkownik przesunie wskaźnik na czerwoną barwę, główna plansza do wyboru koloru powinna zmienić całą swoją kolorystykę. Wykorzystam do tego opisany w tym rozdziale mechanizm sygnałów.

Wpierw tworzę dodatkowy plik z prostą klasą tworzącą obiekty typu obserwer:


//pubsub.js

export class PubSub {
    constructor() {
        this.subscribers = [];
    }

    on(fn) {
        this.subscribers.push(fn);
    }

    off(fn) {
        this.subscribers = this.subscribers.filter(el => el !== fn);
    }

    emit(data) {
        this.subscribers.forEach(el => el(data));
    }
}

A następnie importuję ją i podpinam pod powyższy suwak tworząc sygnał:


//hue.js

import { PubSub } from "./pubsub.js";

export class HueSlider {
    constructor(place) {
        //tworzę sygnał
        this.onHueSelect = new PubSub();
        ...
    }

    ...

    setColor(color) {
        ...
        //w razie potrzeby emituję informację, którą gdzieś odbiorę
        this.onHueSelect.emit(colorGet);
    }
        
    drag(e) {
        ...
        this.onHueSelect.emit(color);
    }
}

Testowanie

Żeby sprawdzić czy wszystko działa jak należy, wracam do głównej klasy, tworzę jeden element na bazie klasy HueSlider i podpinam się pod jego sygnał:


//colorpicker.js

import { HueSlider } from "./hueslider.js";

export class ColorPicker {
    constructor(place) {
        ...
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color");
        this.place.append(this.el);

        this.hue = new HueSlider(this.el);
        this.hue.onHueSelect.on(color => {
            console.log(color);
        });
    }

    setColor(color) {
        this.color = color;
        this.hue.setColor(color);
    }
}

Super narzędzie jest gotowe do testu.

Stwórz plik app.js i dodaj go do html. Na stronie dodaj miejsce testowe gdzie wrzucisz slider, a następnie w pliku app.js odpal naszą klasę:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <link rel="stylesheet" href="style.css">
    <script src="./app.js" type="module"></script>
</head>
<body>
    <div id="test"></div>
</body>
</html>

//app.js

import { ColorPicker } from "./colorpicker.js";

const testPlace = document.querySelector("#test");
const cp = new ColorPicker(testPlace, {})

Sprawdz czy podczas przesuwania wskaźnika coś się pojawia w konsoli. Jak się pojawia, lecimy dalej.

Klasa ColorSlider

Kolejna klasa to główny obszar, na którym użytkownik będzie pobierać kolor. Będzie ona wyglądać bardzo podobnie do powyżej:


//color-slider.js

import { rgb2hsl, hsl2rgb, hex2hsb, hex2rgb, clamp } from "./functions.js";
import { PubSub } from "./pubsub.js";

export class ColorSlider {
    constructor(place) {
        this.onColorSelect = new PubSub();
        this.place = place;
        this.dragged = false;
        this.cursorPos = {x : 0, y : 0};
        this.createElement();
        this.setBgGradient();
        this.bindEvents();
    }
}

Podobnie jak poprzednio wpierw tworzymy odpowiednie elementy:


export class ColorSlider {
    ...

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color-canvas");

        this.canvas = document.createElement("canvas");
        this.canvas.classList.add("color-canvas-canvas");
        this.el.append(this.canvas);

        this.dragEl = document.createElement("div");
        this.dragEl.classList.add("color-canvas-drag");
        this.el.append(this.dragEl);

        this.place.append(this.el);

        this.canvas.width = this.canvas.offsetWidth;
        this.canvas.height = this.canvas.offsetHeight;
        this.ctx = this.canvas.getContext("2d");
    }
}

Tło tym razem będzie składać się z dwóch gradientów nałożonych na pojedynczy kolor:


export class ColorSlider {
    ...

    setBgGradient(bgColor) {
        this.ctx.fillStyle = bgColor; //wpierw nakładamy odpowiedni kolor

        //potem pionowy gradient - od białego do przezroczystego
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
        let gradientH = this.ctx.createLinearGradient(0, 0, this.canvas.width, 0);
        gradientH.addColorStop(0.01, "#fff");
        gradientH.addColorStop(0.99, "rgba(255,255,255, 0)");
        this.ctx.fillStyle = gradientH;
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

        //potem pionowy gradient - od przezroczystości do czarnego
        let gradientV = this.ctx.createLinearGradient(0, 0, 0, this.canvas.height);
        gradientV.addColorStop(0.1, "rgba(0,0,0,0)");
        gradientV.addColorStop(0.99, "#000");
        this.ctx.fillStyle = gradientV;
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    }
}

Kolejna rzecz to przesuwanie wskaźnika po planszy - bardzo podobnie do poprzedniej klasy:


export class ColorSlider {
    ...

    updatePickerColor() {
        this.color = this.getColor();
        this.dragEl.style.background = this.color.rgb;
    }

    drag(e) {
        const g = this.canvas.getBoundingClientRect();

        let x = clamp(e.pageX - (g.left + window.scrollX), 0, g.width);
        let y = clamp(e.pageY - (g.top + window.scrollY), 0, g.height);

        this.cursorPos.y = Math.abs(y);
        this.cursorPos.x = Math.abs(x);

        if (this.cursorPos.x > this.canvas.width - 1) {
            this.cursorPos.x = this.canvas.width - 1;
        }

        this.color = this.getColor();
        this.updatePickerColor();

        this.onColorSelect.emit(this.color);
    }

    bindEvents() {
        this.canvas.addEventListener('mousedown', e => {
            this.dragged = true;
            this.drag(e);
        });

        document.addEventListener("mousemove", e => {
            if (this.dragged) this.drag(e);
        });

        document.addEventListener("mouseup", e => {
            this.dragged = false;
        });
    }
}

W przeciwieństwie do poprzedniej klasy doszła nam tutaj funkcja updatePickerColor(). Jeżeli użytkownik zmieni barwę w hue slider, musimy na to zareagować i przerysować powyższe płótno, ale dodatkowo ustawić wskaźnik w odpowiednim miejscu (w sumie moglibyśmy go nie ruszać, ale to nas nic nie będzie kosztowało).

I tak jak poprzednio przydadzą nam się też funkcje służące do ustawiania i pobierania aktualnego koloru.

Do pobrania koloru podejdziemy nieco inaczej niż przy poprzedniej klasie. Wystarczy pobrać informacje z planszy w miejscu położenia kursora. Posłużymy się tutaj funkcją this.ctx.getImageData(x, y, width, height) (linia 25), która zwraca nam wycinek canvasu. W tym przypadku chcemy pobrać tylko jeden piksel, stąd width i height ustawiamy na 1:


export class ColorSlider {
    ...

    setColor(color) {
        this.color = color;

        const colorRGB = hex2rgb(color);
        const hslColor = rgb2hsl(colorRGB.r, colorRGB.g, colorRGB.b);
        const hue = hslColor[0] * 360;
        const newHueRgb = hsl2rgb(hue/360, 1, 0.5);
        const newHueRgbText = `rgb(${newHueRgb.join(',')})`;

        this.setBgGradient(newHueRgbText);

        const hsb = hex2hsb(color);
        const x = clamp(Math.ceil(hsb.s / (100 / this.canvas.width)), 0, this.canvas.width - 1);
        const y = clamp(this.canvas.height - Math.ceil(hsb.b / (100 / this.canvas.height)), 0, this.canvas.height);

        this.cursorPos = {x, y}

        this.updatePickerColor();
    }

    getColor() {
        const pixel = this.ctx.getImageData(this.cursorPos.x, this.cursorPos.y, 1, 1).data;
        const rgb = `rgb(${pixel[0]},${pixel[1]},${pixel[2]})`;
        return {
            rgb : rgb,
            r : pixel[0],
            g : pixel[1],
            b : pixel[2]
        };
    }
}

Klasa gotowa. Stwórzmy więc na jej bazie nowy element w głownej klasie:


//colorpicker.js

import { HueSlider } from "./hue-slider.js";
import { ColorSlider } from "./color-slider.js";
import { rgb2hex } from "./functions.js";

export class ColorPicker {
    constructor(place) {
        this.place = place; //miejsce do którego wstawimy nasz color-picker
        this.color = "#FF0000";
        this.createElement();
        this.setColor(this.color);
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color");
        this.place.append(this.el);

        this.hue = new HueSlider(this.el);
        this.canvas = new ColorSlider(this.el);

        this.hue.onHueSelect.on(color => {
            console.log(color);
        });
    }

    setColor(color) {
        this.color = color;
        this.hue.setColor(color);
        this.canvas.setColor(color);
    }
}

I zepnijmy oba slidery ze sobą podpinając się pod odpowiednie sygnały:


//colorpicker.js

import { HueSlider } from "./hue-slider.js";
import { ColorSlider } from "./color-slider.js";
import { rgb2hex } from "./functions.js";

export class ColorPicker {
    constructor(place) {
        ...
    }
        
    createElement() {
        this.hue = new HueSlider(this.el);
        this.canvas = new ColorSlider(this.el);

        this.hue.onHueSelect.on(color => {
            this.canvas.setBgGradient(rgb2hex(color.r, color.g, color.b));
            this.canvas.updatePickerColor();
        });

        this.canvas.onColorSelect.on(color => {
            const hex = rgb2hex(color.r, color.g, color.b);
            this.color = hex;
            console.log(this.color);
        });
    }

    setColor(color) {
        ...
    }
}

Przy okazji sprawdźmy w pliku app.js czy funkcja setColor() pozwoli nam ustawić domyślny kolor:


//app.js
...

const cp = new ColorPicker(place);
cp.setColor("#472622");

Input i button

Kolejnymi w kolejce są input i button. Dodamy je w głównej klasie.


//colorpicker.js

import { HueSlider } from "./hue-slider.js";
import { ColorSlider } from "./color-slider.js";
import { rgb2hex } from "./functions.js";
import { PubSub } from "./pubsub.js";

export class ColorPicker {
    constructor(place) {
        //podobnie do poprzednich klas tworzę przydatne sygnały
        //do których będzie mógł się podpiąć programista używający tej klasy
        this.onButtonClick = new PubSub();
        this.onColorSelect = new PubSub();
        ...
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color");

        this.place.append(this.el);

        this.hue = new HueSlider(this.el);
        this.canvas = new ColorSlider(this.el);

        //tworzę input
        this.input = document.createElement("input");
        this.input.classList.add("color-input");
        this.el.append(this.input);

        //po wpisaniu koloru do inputa sprawdzam czy jest on w poprawnym formacie
        //i w razie czego aktualizuję kolor w sliderach
        this.input.addEventListener("keyup", e => {
            if (e.key === "Enter") {
                if (/^#[a-fA-F0-9]{6}$/.test(this.input.value)) {
                    this.setColor(`${this.input.value}`);
                }
            }
        })

        //tworzę button
        this.button = document.createElement("button");
        this.button.classList.add("color-btn");
        this.button.textContent = "OK";
        this.button.type = "button";
        this.el.append(this.button);
        this.button.addEventListener("click", e => {
            this.onButtonClick.emit(this.color);
        })

        this.hue.onHueSelect.on(color => {
            ...
        })

        this.canvas.onColorSelect.on(color => {
            const hex = rgb2hex(color.r, color.g, color.b);
            this.color = hex;
            this.input.value = hex;
            this.onColorSelect.emit(this.color);
        })
    }

    setColor(color) {
        this.color = color;
        this.hue.setColor(color);
        this.canvas.setColor(color);
        this.input.value = color;
    }
}

A także w pliku app.js podepnijmy się testowo pod powyższe sygnały


//app.js

const testPlace = document.querySelector("#test");
const cp = new ColorPicker(div, {});

cp.onColorSelect.on(color => {
    console.log(color);
});

cp.onButtonClick.on(color => {
    console.log(color)
});

cp.setColor("#472622");

Klasa ColorLibrary

Ostatnim elementem będzie biblioteka kolorów, które użytkownik będzie mógł sobie zapisać.

Początek podobny co poprzednio:


//color-library.js

import { PubSub } from "./pubsub.js";
import { rgb2hex } from "./functions.js";

export class ColorLibrary {
    constructor(place) {
        this.onColorSelect = new PubSub(); //odpalane gdy wybierzemy kolor z biblioteki
        this.onColorsChange = new PubSub(); //odpalane gdy zmieni się lista kolorów - zostanie usunięty czy dodany kolor
        this.place = place;
        this.colors = [];
        this.createElement();
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color-library");

        //tworzę przycisk dodawania koloru do library
        const btnAdd = document.createElement("button");
        btnAdd.classList.add("color-library-add");
        btnAdd.type = "button";
        btnAdd.textContent = "+";
        this.el.append(btnAdd);

        btnAdd.addEventListener("click", e => {
            console.log("klik");
        });

        //tworzę element na listę kolorów
        this.colorsDiv = document.createElement("div");
        this.colorsDiv.classList.add("color-library-colors");
        this.el.append(this.colorsDiv);

        this.place.append(this.el);
        this.createColors();
    }
}

Po kliknięciu na przycisk dodawania koloru musimy pobrać z głównej planszy aktualnie wybrany kolor, a następnie dodać do do kolorów. Niestety w tym przypadku komunikacja za pomocą sygnałów się nie sprawdzi, bo nie reagujemy na akcję, a po prostu chcemy odpalić funkcję getColor(). Przekażmy więc referencję do tamtego obiektu jako parametr tej klasy:


//color-library.js

export class ColorLibrary {
    constructor(place, colorPicker) {
        this.place = place;
        this.colorPicker = colorPicker;
        ...
    }

    createElement() {
        ...

        btnAdd.addEventListener("click", e => {
            const color = this.colorPicker.getColor();
            const hex = rgb2hex(color.r, color.g, color.b);
            this.addColor(hex);
            this.createColors();
        }
    }

    ...
});

Tworzenie kolorów rozbijemy na dwa kroki. Jedna funkcja createColors() będzie robić pętlę po kolorach i wywoływać będzie drugą funkcję createColorElement() służącą do tworzenia pojedynczego elementu z kolorem:


//color-library.js

export class ColorLibrary {
    ...

    createColors() {
        this.colorsDiv.textContent = "";
        this.colors.forEach(color => {
            this.createColorElement(color.color);
        });
    }

    createColorElement(color) {
        const el = document.createElement("div");
        el.classList.add("color-library-el");
        el.style.background = color;
        el.addEventListener("click", e => {
            this.onColorSelect.emit(color);
        })

        //przycisk usuwania koloru
        const elDelete = document.createElement("button");
        elDelete.classList.add("color-library-el-delete");
        elDelete.type = "button";
        elDelete.textContent = "usuń";
        el.append(elDelete);

        elDelete.addEventListener("click", e => {
            e.preventDefault();
            e.stopPropagation();

            //pobieram wszystkie elementy z kolorami
            const elements = [...this.colorsDiv.querySelectorAll(".color-library-el")];
            //dzięki temu pobieram indeks usuwanego elementu
            const index = elements.indexOf(el);
            if (index !== -1) {
                this.deleteColor(index);
            }
        })

        this.colorsDiv.append(el);
    }
}

Dodajmy też dwie funkcje służące do dodawania i usuwania koloru z bilioteki:


//color-library.js

export class ColorLibrary {
    ...

    deleteColor(index) {
        this.colors.splice(index, 1);
        this.createColors(); //po usunięciu koloru tworzę listę kolorów od początku
        this.onColorsChange.emit(this.colors);
    }

    addColor(color) {
        this.colors.push(color);
        this.createColors(); //...tak samo po dodaniu koloru
        this.onColorsChange.emit(this.colors);
    }
}

Żeby cała biblioteka kolorów miała jakikolwiek sens, dodajmy do niej zapisywanie stanu w LocalStorage.


//color-library.js

import { PubSub } from "./pubsub.js";
import { rgb2hex } from "./functions.js";

export class ColorLibrary {
    constructor(place, libraryID, colorPicker) {
        this.onColorSelect = new PubSub();
        this.onColorsChange = new PubSub();
        this.colorPicker = colorPicker;
        this.libraryID = libraryID;
        this.place = place;
        this.colors = [];
        this.readColorsFromStorage(); //na początku wczytuję już zapisane kolory
        this.createElement();
    }

    readColorsFromStorage() {
        if (localStorage.getItem(`colorPicker-${this.libraryID}`)) {
            this.colors = JSON.parse(localStorage.getItem(`colorPicker-${this.libraryID}`));
        }
    }

    createElement() {
        this.el = document.createElement("div");
        this.el.classList.add("color-library");

        const btnAdd = document.createElement("button");
        btnAdd.classList.add("color-library-add");
        btnAdd.type = "button";
        btnAdd.textContent = "+";
        this.el.append(btnAdd);

        btnAdd.addEventListener("click", e => {
            const color = this.colorPicker.getColor();
            const hex = rgb2hex(color.r, color.g, color.b);
            this.addColor(hex);
        });

        this.colorsDiv = document.createElement("div");
        this.colorsDiv.classList.add("color-library-colors");
        this.el.append(this.colorsDiv);

        this.place.append(this.el);
        this.createColors();
    }

    createColors() {
        this.colorsDiv.textContent = "";
        this.colors.forEach(color => this.createColorElement(color))
    }

    deleteColor(index) {
        this.colors.splice(index, 1);
        localStorage.setItem(`colorPicker-${this.libraryID}`, JSON.stringify(this.colors));
        this.onColorsChange.emit(this.colors);
        this.createColors();
    }

    addColor(color) {
        this.colors.push(color);
        localStorage.setItem(`colorPicker-${this.libraryID}`, JSON.stringify(this.colors));
        this.onColorsChange.emit(this.colors);
        this.createColors();
    }

    createColorElement(color) {
        const el = document.createElement("div");
        el.classList.add("color-library-el");
        el.style.background = color;
        el.addEventListener("click", e => {
            this.onColorSelect.emit(color);
        });

        const elDelete = document.createElement("button");
        elDelete.classList.add("color-library-el-delete");
        elDelete.type = "button";
        elDelete.textContent = "usuń";
        el.append(elDelete);

        elDelete.addEventListener("click", e => {
            e.preventDefault();
            e.stopPropagation();

            const elements = [...this.colorsDiv.querySelectorAll(".color-library-el")];
            const index = elements.indexOf(el);
            if (index !== -1) {
                this.deleteColor(index);
            }
        });

        this.colorsDiv.append(el);
    }
}

Klasa praktycznie gotowa. Podłączmy ją w głównej klasie:


import { ColorSlider } from "./color-slider.js";
import { HueSlider } from "./hue-slider.js";
import { rgb2hex } from "./functions.js";
import { PubSub } from "./pubsub.js";
import { Library } from "./color-library.js";

export class ColorPicker {
    constructor(place, opts) {
        ...
    }

    createElement() {
        ...

        this.library = new ColorLibrary(this.el, "colors", this.canvas);
        this.library.onColorSelect.on(color => {
            this.input.value = color;
            this.setColor(color);
        });
        this.library.onColorsChange.on(colors => {
            console.log("stan kolorów w bibliotece", colors);
        });
    }

    setColor(color) {
        ...
    }
}

W 15 linii ustawiliśmy bibliotece id "colors" (drugi parametr) dzięki czemu w LocalStorage zostanie zapisana pod nazwą "colorPicker-colors". Zakładam, że na stronie możemy mieć kilka color pickerów i każdy powinien móc korzystać z dowolnej biblioteki - albo współdzielonej albo indywidualnej. Dlatego te id powinny być podawane przez programistę. Podobnie do innych projektów w tym kursie (np. ten) dodajmy możliwość przekazywania opcji do naszej klasy:


//colorpicker.js

export class ColorPicker {
    constructor(place, opts) {
        this.place = place; //miejsce do którego wstawimy nasz color-picker
        this.color = "#FF0000";

        this.options = {...{
            libraryID : "colors"
        }, ...opts}

        this.createElement();
        this.setColor(this.color);
    }

    createElement() {
        ...

        this.library = new ColorLibrary(this.el, this.options.libraryID, this.canvas);
    }
}

i przekażmy przykładowe opcje w pliku app.js:


import { ColorPicker } from "./colorpicker.js";

const place = document.querySelector("#test");
const cp = new ColorPicker(place, {
    libraryID : "kolory"
});

cp.onColorSelect.on(color => console.log(color))
cp.onButtonClick.on(color => console.log(color))

cp.setColor("#472622")

Czas na testy. Tak jak poprzednio dodajmy do html dwa dodatkowe miejsca testowe, a następnie w app.js wrzućmy do nich 2 dodatkowe pickery:


//app.js

import { ColorPicker } from "./colorpicker.js";

{
    const place = document.querySelector("#test");
    const cp = new ColorPicker(place, {
        libraryID : "kolory"
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => {console.log(color))

    cp.setColor("#472622")
}

{
    const place = document.querySelector("#test2");
    const cp = new ColorPicker(place, {
        libraryID : "koloryB"
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => {console.log(color))

    cp.setColor("#472622")
}

{
    const place = document.querySelector("#test3");
    const cp = new ColorPicker(place, {
        libraryID : "koloryB"
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => {console.log(color))

    cp.setColor("#472622")
}

Bonus: wspólne biblioteki

W powyższym przykładzie mamy 3 color pickery, gdzie 2 z nich używają tej samej biblioteki kolorów. Niestety nie są one zsynchronizowane - gdy w jednym z nich dodam lub usunę kolor, w drugim pickerze nie zostanie to odwzorowane. Żeby móc coś takiego zrobić, dodajmy do klasy dodatkowy sygnał oraz funkcję aktualizującą bibliotekę w danym pickerze:


//colorpicker.js

export class ColorPicker {
    constructor(place, opts) {
        this.onLibraryColorsChange = new PubSub();
        ...
    }

    createElement() {
        ...

        this.library = new ColorLibrary(this.el, "colors", this.canvas);
        this.library.onColorSelect.on(color => {
            ...
        });
        this.library.onColorsChange.on(colors => {
            this.onLibraryColorsChange.emit(colors);
        })
    }

    ...

    updateLibrary() {
        this.library.updateColors();
    }
}

i wykorzystajmy je w głównym pliku:


//app.js

const allPickers = {};

function addPicker(libraryID, picker) {
    if (allPickers[libraryID] === undefined) allPickers[libraryID] = [];
    allPickers[libraryID].push(picker);
}

{
    const libraryID = "kolory";
    const place = document.querySelector("#test");
    const cp = new ColorPicker(place, {
        libraryID
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => console.log(color))

    cp.setColor("#472622")

    cp.onLibraryColorsChange.on(colors => {
        allPickers.forEach(cp => cp.updateLibrary())
    });

    addPicker(libraryID, cp);
}

{
    const libraryID = "koloryB";
    const place = document.querySelector("#test2");
    const cp = new ColorPicker(place, {
        libraryID
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => console.log(color))

    cp.setColor("#472622");

    cp.onLibraryColorsChange.on(colors => {
        allPickers.forEach(cp => cp.updateLibrary())
    });

    addPicker(libraryID, cp);
}

{
    const libraryID = "koloryB";
    const place = document.querySelector("#test3");
    const cp = new ColorPicker(place, {
        libraryID
    });

    cp.onColorSelect.on(color => console.log(color))
    cp.onButtonClick.on(color => console.log(color))

    cp.setColor("#472622");

    cp.onLibraryColorsChange.on(colors => {
        allPickers.forEach(cp => cp.updateLibrary())
    });

    addPicker(libraryID, cp);
}

Jeżeli bardzo ci zależy, możesz też powyższy mechanizm dodać do głównej klasy ColorPicker, dzięki czemu nie będziesz musiał pamiętać o takiej synchronizacji.


//colorpicker.js

const allPickers = {};

class ColorPicker {
    constructor() {
        ...

        if (allPickers[this.options.libraryID] === undefined) allPickers[this.options.libraryID] = [];
        allPickers[this.options.libraryID].push(this);
    }

    createElement() {
        ...

        this.library.onColorsChange.on(colors => {
            this.onLibraryColorsChange.emit(colors);
            allPickers[this.options.libraryID].forEach(cp => cp.updateLibrary())
        })
    }

    updateLibrary() {
        ...
    }
}

Demo

I w zasadzie tyle. Przydało by się jeszcze dodać jakieś dodatkowe opcje, które moglibyśmy podawać (np. czy pokazywać poszczególne elementy - np. bibliotekę koloru, ustawienie tekstu na przycisku itp.), ale to już zostawiam tobie.

W poniższym demie nie bundlowałem wszystkiego jakimiś narzędziami. Zamiast tego wywaliłem wszystkie import/export z kodu i wrzuciłem wszystko do jednego pliku dodatkowo zabezpieczając wszystko przed niepowołanym dostępem:


const ColorPicker = (() => {
    //tutaj wszystko
    //funkcje
    //pubsub
    //HueSlider
    //ColorSlider
    //ColorLibrary
    return class ColorPicker {
        ...
    }
});

//kod app.js
const cp = new ColorPicker(place, {
    libraryID
});

...
Demo

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.