Canvas - tworzymy Painta

W poniższym tekście spróbujemy zebrać różne informacje z tego kursu i wykorzystać je do zbudowania aplikacji służącej do rysowania.


Zacznijmy od przygotowania prostej struktury projektu oraz zainstalowania odpowiednich paczek. W rozdziale o bundlerach omawialiśmy sobie tworzenie konfiguracji za pomocą webpacka, gulpa czy parcela.
W poniższym tekście użyjemy czegoś innego.

vite

Vite - bo o nim mowa, to nowsze rozwiązanie, które działa nieco inaczej od swoich braci.
Przede wszystkim swoje działanie opiera o nieco rozwinięte moduły ES6.

Co to oznacza? Narzędzie to w odróżnieniu od tamtych rozwiązań nie musi w tle każdorazowo przebudowywać wszystkich naszych skryptów, a zamiast tego serwuje je bezpośrednio w przeglądarce korzystając z tego, że nowe przeglądarki natywnie wspierają już moduły ES6. Dzięki temu praca jest o wiele, wiele szybsza w porównaniu z takim Parcelem czy Webpackiem. Dodatkowo ten mechanizm rozwija. W przypadku stosowania modułów w natywnej postaci musimy podawać pełne ścieżki wraz z rozszerzeniem - co jest szczególnie upierdliwe, gdy odwołujemy się do paczek zainstalowanych w katalogu node_modules. Vite rozwiązuje ten problem, dzięki czemu możemy pracować podobnie jak przy innych bundlerach (np. podając ścieżki bez rozszerzeń czy bezpośrednio odwołując się do zainstalowanych w node_modules paczek).

Początkowa instalacja

Przechodzimy na stronę https://vitejs.dev/ i po kliknięciu "Get started" przechodzimy do opisu początkowej instalacji.

Nie musisz tworzyć nowego katalogu, bo instalacja odpowiednio ci to ułatwi. Zgodnie z instrukcją odpalamy więc polecenie:


npm init @vitejs/app

Pojawi się kilka pytań. Podajemy nazwę projektu, wybieramy vanilla i Javascript.

Właśnie zakończyłeś instalowanie Vite. Możesz odpalić projekt.

Po zainstalowaniu powstanie katalog z bardzo prostą strukturą plików. Głównym plikiem jest index.html, do którego dołączony jest plik main.js, a w którym to rozpoczniemy pracę.

Dodatkowo w pliku package.json pojawiły się odpowiednie skrypty, które wykorzystamy do odpalenia naszego projektu:


{
    "scripts": {
        "dev": "vite", // uruchamia serwer
        "build": "vite build", // buduje aplikację w wersji produkcyjnej
        "serve": "vite preview" // lokalny podgląd wersji produkcyjnej
    }
}

Aby wiec odpalić projekt, wystarczy użyć polecenia npm run dev, a do zbudowania czy podglądu wersji produkcyjnej użyjemy poleceń npm run build i npm run serve.

Aby to jednak zrobić powinieneś wcześniej doinstalować brakujące paczki poleceniem npm i, ponieważ powyższe polecenie tworzące projekt tego nie robi.

Inna struktura katalogów

W tym momencie moglibyśmy spokojnie już ruszyć naszą pracę. Zanim jednak przejdziemy dalej, spróbujmy nieco zmodyfikować domyślną strukturę plików, wprowadzając do niej podział na katalogi src i dist (podobnie jak to robiliśmy przy innych bundlerach). Katalog dist, do którego trafi zbudowana aplikacja tworzony jest automatycznie przez Vite gdy użyjemy polecenia npm run build.

Stwórzmy więc katalog src, przerzućmy do niego plik index.html, a dodatkowo utwórzmy w nim odpowiednią strukturę plików:


src
├── scss
│    └── main.scss
├── js
│    └── main.js
└── index.html

Dodatkowo popraw ścieżkę w index.html by wskazywała na nowe miejsce pliku main.js (uwaga relatywne ścieżki zaczynaj od kropek: ./js/main.js).


    <script src="./js/app.js" type="module"></script>

Jak widzisz dodaliśmy też plik main.scss, bo właśnie w tym języku chcemy pisać css. Krok ten traktuj jako sprawdzenie funkcjonalności Vite, ponieważ w naszym projekcie zyskasz dokładnie zero (zamiast tego bez problemu możesz zostać na zwykłym css).

Domyślnie Vite obsługuje SASSa, natomiast by używać tego języka, musimy doinstalować dodatkową paczkę za pomocą polecenia (1):


npm i sass -D

Ostatnia rzecz i możemy zaczynać. Domyślnie Vite ustawiony jest na index.html mieszczący się w głównym katalogu. My go przenieśliśmy do katalogu src. Aby zmienić zachowanie Vite podobnie do innych narzędzi utwórzmy w głównym katalogu plik konfiguracyjny vite.config.js z mini konfiguracją:


module.exports = {
    root: "src",
    build: {
        outDir: "../dist" //relatywnie do katalogu root
    }
}

Nasz projekt jest gotowy do pracy. Spróbuj go odpalić poleceniem npm dev i przejdź na odpowiedni adres w przeglądarce.

Podział aplikacji

Nasza aplikacja w założeniu ma przypominać prosty Paint. Będzie miała kilka podstawowych narzędzi takich jak pędzel, rysowanie linii, prostokątów itp, a także umożliwi zmianę rozmiaru czy koloru.

Podział na pliki mniej więcej będzie wyglądał tak:


main.js //główna klasa inicjująca naszą aplikację
board.js //tworzy planszę
makeTool.js //odpowiada za tworzenie pojedynczych narzędzi
control.js //klasa odpowiedzialna za obsługę sterowania
tools/* //kolejne pliki z narzędziami jak pędzel, linie itp
gui.js //klasa odpowiedzialna za tworzenie graficznego gui

//plus dodatkowe które wyjdą w praniu

Klasa app

Nasza pierwsza klasa będzie miała za zadanie uruchomienie aplikacji.


//src/js/main.js
import style from "../scss/main.scss";
import board from "./board";

class App {
    constructor() {

    }
}

new App();

Jak widzisz nie za wiele się tutaj dzieje. Żeby używać scss musimy go zaimportować. Same css zostaną wygenerowane i automatycznie dołączone do html.

Dodatkowo importujemy klasę board, która będzie odpowiedzialna za utworzenie planszy do rysowania.

Przy okazji dodajmy do src/index.html, główny element, w którym będzie znajdować się nasza aplikacja:


<!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Painter</title>
    </head>
    <body>

        <div id="app">
            <div id="canvasCnt"></div>
        </div>

        <script src="./js/main.js" type="module"></script>
    </body>
</html>

Klasa board

Nasza aplikacja będzie składać się z dwóch elementów typu canvas.

Jeden z nich - główny - będzie zawierać narysowane figury. Drugie posłuży do dynamicznego rysowania figur. Nie wiem, czy miałeś kiedyś styczność z dowolnym programem graficznym. Gdy rysujesz pędzlem, po prostu mażesz po ekranie. Gdy jednak rysujesz dowolną figurę (np. kwadrat), trzymając klawisz myszy i przeciągając kursorem ustawiasz jej rozmiar. Narysowanie figury zostaje zatwierdzone w momencie puszczenia klawisza myszy. Właśnie do tego celu wykorzystamy drugie płótno.

Oba elementu ułożymy w tym samym miejscu za pomocą pozycjonowania absolutnego.

Nasza klasa musi więc stworzyć sobie dwa elementy canvas. Żeby nie duplikować kodu, możemy wykorzystać do tego funkcję pomocniczą:


function createCanvas(id, parentElement) {
    const canvas = document.createElement("canvas");
    canvas.width =  parentElement.offsetWidth;
    canvas.height = parentElement.offsetHeight;
    canvas.id = id;
    return canvas;
}

class Board {
    constructor(query) {
        this.container = document.querySelector(query);

        this.canvas1 = createCanvas("mainCanvas", this.container);
        this.canvas2 = createCanvas("secondaryCanvas", this.container);

        this.container.append(this.canvas1);
        this.container.append(this.canvas2);

        this.ctx1 = this.canvas1.getContext("2d");
        this.ctx2 = this.canvas2.getContext("2d");

        this.mouse = { x: 0, y : 0 }
        this.currentTool = null;
        this.toolParams = {
            color: "red",
            size: 10
        }

        this.bindEvents();
    }

    bindEvents() {
        document.addEventListener("mousemove", e => {
            this.mouse.x = e.pageX;
            this.mouse.y = e.pageY;

            if (this.currentTool) {
                this.currentTool.onMouseMove(e.pageX, e.pageY, this.ctx1, this.ctx2, this.toolParams)
            }
        });

        this.canvas1.addEventListener("mousedown", e => {
            if (this.currentTool) {
                this.currentTool.onMouseDown(e.offsetX, e.offsetY, this.ctx1, this.ctx2, this.toolParams)
            }
        });

        this.canvas1.addEventListener("mouseup", e => {
            if (this.currentTool) {
                this.currentTool.onMouseUp(e.offsetX, e.offsetY, this.ctx1, this.ctx2, this.toolParams)
            }
        });
    }

    setColor(color) {
        this.toolParams.color = color;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
    }

    setSize(size) {
        this.toolParams.size = size;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
    }

    setTool(tool) {
        this.currentTool = tool;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
    }
}

const board = new Board("#canvasCnt");
export default board;

Omówmy sobie poszczególne części:


constructor(query) {
    this.container = document.querySelector(query);

    this.canvas1 = createCanvas("mainCanvas", this.container);
    this.canvas2 = createCanvas("secondaryCanvas", this.container);

    this.container.append(this.canvas1);
    this.container.append(this.canvas2);

    this.ctx1 = this.canvas1.getContext("2d");
    this.ctx2 = this.canvas2.getContext("2d");

    this.mouse = { x :0, y : 0 }
    this.currentTool = null;
    this.toolParams = {
        color: "red",
        size: 10
    }

    this.bindEvents();
}

Raczej samo opisujący się kod. Tworzymy dwa canvasy i wrzucamy je w odpowiedni element na stronie (#canvasCnt). Następnie pobieramy ich context, bo to głównie z nich będziemy korzystać.

Dodatkowo tworzymy zmienne - mouse z pozycją myszki, currentTool, w której będziemy przechowywać aktualne narzędzie oraz toolParams, gdzie będziemy trzymać ustawienia narzędzia (kolor, rozmiar).


bindEvents() {
    document.addEventListener("mousemove", e => {
        this.mouse.x = e.pageX;
        this.mouse.y = e.pageY;

        if (this.currentTool) {
            this.currentTool.onMouseMove(e.pageX, e.pageY, this.ctx1, this.ctx2, this.toolParams)
        }
    });

    this.canvas1.addEventListener("mousedown", e => {
        if (this.currentTool) {
            this.currentTool.onMouseDown(e.offsetX, e.offsetY, this.ctx1, this.ctx2, this.toolParams)
        }
    });

    this.canvas1.addEventListener("mouseup", e => {
        if (this.currentTool) {
            this.currentTool.onMouseUp(e.offsetX, e.offsetY, this.ctx1, this.ctx2, this.toolParams)
        }
    });
}

Czyli zwykłe podpięcie zdarzeń pod główny canvas. Robiliśmy to setki razy w rozdziałach o zdarzeniach. Zauważ, że dla zdarzenia mousemove dodatkowo aktualizuję zmienną mouse.

W każdym ze zdarzeń wywołuję odpowiednią metodę dla aktualnie używanego narzędzia, co oznacza, że każde z nich będzie takie metody posiadać (patrz klasa Tool).


setColor(color) {
    this.toolParams.color = color;
    this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
}

setSize(size) {
    this.toolParams.size = size;
    this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
}

setTool(tool) {
    this.currentTool = tool;
    this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
}

Kolejne funkcje posłużą nam do ustawiania parametrów używanych narzędzi. Po ustawieniu odpalimy dodatkowo metodę onmouseMove, by zaktualizować stan narzędzia na planszy (widoczny dla użytkownika wskaźnik - tym zajmiemy się później).


const board = new Board("#canvasCnt");
export default board;

Na koniec tworzymy pojedynczą instancję i wystawiamy ją na zewnątrz. Tutaj pojawia się dość ważna cecha importów w modułach ES6. Każda rzecz, którą w taki sposób importujesz jest pojedyncza dla wszystkich miejsc gdzie używasz danego pliku. Stworzyliśmy i wyeksportowaliśmy instancję na bazie klasy Board. Jeżeli teraz zaimportujemy ją w kilku innych plikach, za każdym razem będzie to ta sama instancja.

Dla naszej planszy dodajmy też odpowiednie stylowanie w pliku main.scss:


//src/scss/main.scss

body {
    margin: 0;
	background: url(data:image/png;base64,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);
}

#canvasCnt {
    width: 100vw;
    height: 100vh;
    position: relative;
    background: transparent;
}

#canvasCnt canvas {
    position: absolute;
    left: 0;
    top: 0;
    cursor: none;
}

#mainCanvas {
    z-index: 0;
}

#secondaryCanvas {
    z-index: 1;
    pointer-events: none;
}

Po rozpoczęciu aplikacji ustawmy początkowy stan:


//src/js/main.js
import style from "../scss/main.scss";
import board from "./board";
import makeTool from "./makeTool";

class App {
    constructor() {
        board.setTool(makeTool("brush"));
        board.setColor("blue");
        board.setSize(10);
    }
}

new App();

Do utworzenia narzędzia wykorzystamy funkcję makeTool(), którą stwórzmy w oddzielnym pliku:


//src/js/makeTool.js
import Brush from "./tools/brush";
import Line from "./tools/line";
import Rectangle from "./tools/rectangle";

export default function(tool) {
    switch (tool) {
        case "brush":
            return new Brush();
        case "line":
            return new Line();
        case "rectangle":
            return new Rectangle();
    }
}

Wstępne narzędzia

Do początkowych testów stwórzmy trzy różne narzędzia jako oddzielne klasy. Żeby nie duplikować kodu, zróbmy je jako klasy dziedziczące po klasie Tool:


//src/js/tools/tool.js
export default class Tool {
    constructor() {
        this.name = "";
    }

    drawPointer(x, y, ctx1, ctx2, toolProp) {}

    onMouseMove(x, y, ctx1, ctx2, toolProp) {}

    onMouseUp(x, y, ctx1, ctx2, toolProp) {}

    onMouseDown(x, y, ctx1, ctx2, toolProp) {}
}

//src/js/tools/brush.js
import Tool from "./tool";

export default class Brush extends Tool {
    constructor() {
        super();
        this.name = "brush";
        console.log(this.name);
    }
}

//src/js/tools/line.js
import Tool from "./tool";

export default class Line extends Tool {
    constructor() {
        super();
        this.name = "line";
        console.log(this.name);
    }
}

//src/js/tools/rectangle.js
import Tool from "./tool";

export default class Rectangle extends Tool {
    constructor() {
        super();
        this.name = "rectangle";
        console.log(this.name);
    }
}

Sprawdź teraz czy tworzenie narzędzi działa, zmieniając w pliku main.js na odpowiednie narzędzie np. makeTool("line").

Mini konfiguracja

Zmiana narzędzi jak i kolorów powinna się odbywać za pomocą odpowiednich klawiszy. Obsłużymy to w klasie Control.

Żeby nasz kod był łatwiejszy w zarządzaniu, zróbmy dodatkowy plik z małą konfiguracją:


//src/js/config.js
export default {
    tools : [
        {key : "1", tool : "brush"},
        {key : "2", tool : "line"},
        {key : "3", tool : "rectangle"},
    ],

    colors : [
        {key: "r", color: "red"},
        {key: "g", color: "green"},
        {key: "b", color: "blue"}
    ]
}

Klasa Control

Klasa Control ma za zadanie obsłużyć wszystkie klawisze używane w naszej aplikacji.


//src/js/control.js
import board from "./board";
import config from "./config";
import makeTool from "./makeTool";

class Control {
    constructor() {
        this.keyUpTool = this.keyUpTool.bind(this);
        this.keyUpColor = this.keyUpColor.bind(this);
        this.wheelSize = this.wheelSize.bind(this);

        document.addEventListener("keyup", this.keyUpTool);
        document.addEventListener("keyup", this.keyUpColor);
        document.addEventListener("wheel", this.wheelSize);
    }

    keyUpTool(e) {
        config.tools.forEach(el => {
            if (e.key === el.key) {
                const tool = makeTool(el.tool);
                board.setTool(tool);
            }
        });
    }

    keyUpColor(e) {
        config.colors.forEach(el => {
            if (e.key === el.key) {
                const color = el.color;
                board.setColor(color);
            }
        });
    }

    wheelSize(e) {
        let size = board.toolParams.size;
        if (e.deltaY > 0) size = this.decreaseWidth(size);
        if (e.deltaY 

Przypatrzmy się najbardziej istotnym częściom tej klasy:


...
constructor() {
    this.keyUpTool = this.keyUpTool.bind(this);
    this.keyUpColor = this.keyUpColor.bind(this);
    this.wheelSize = this.wheelSize.bind(this);

    document.addEventListener("keyup", this.keyUpTool);
    document.addEventListener("keyup", this.keyUpColor);
    document.addEventListener("wheel", this.wheelSize);
}
...

Podpinamy pod odpowiednie zdarzenia wywołanie metod z tej klasy. Pamiętaj, że gdy podpinasz zdarzenie pod dany element (w powyższym przypadku document), wewnątrz podpiętej funkcji this wskazuje na element, do którego podpiąłeś nasłuchiwanie zdarzenia. My chcemy aby this wskazywał na dany obiekt. Żeby to uzyskać tak samo jak w rozdziale o zaawansowanym this możemy użyć funkcji strzałkowej, albo metody bind().

Nie chciałem tego robić bezpośrednio w momencie podawania metody do podpięcia:


//tak nie
document.addEventListener("keyup", this.keyUpTool.bind(this));
//i tak też nie
document.addEventListener("keyup", () => this.keyUpTool());

ponieważ potem byłby potencjalny problem z odpięciem takiej funkcji. Mówiliśmy sobie o tym problemie tutaj.

Kolejny fragment to:


keyUpTool(e) {
    config.tools.forEach(el => {
        if (e.key === el.key) {
            const tool = makeTool(el.tool);
            board.setTool(tool);
        }
    });
}

keyUpColor(e) {
    config.colors.forEach(el => {
        if (e.key === el.key) {
            const color = el.color;
            board.setColor(color);
        }
    });
}

Robimy pętlę po tablicy tools i colors (patrz konfiguracja). Jeżeli naciśnięty klawisz równa się właściwości key danego elementu w tablicy, to tworzymy nowe narzędzie lub kolor o danej nazwie.


Naszą klasę importujemy w głównym pliku:


//src/js/main.js
import style from "../scss/main.scss";
import board from "./board";
import makeTool from "./makeTool";
import control from "./control";

class App {
    constructor() {
        board.setTool(makeTool("brush"));
        board.setColor("blue");
        board.setSize(10);
    }
}

new App();

Komunikacja między komponentami

Po naciśnięciu klawisza danego narzędzia klasa Control tworzy odpowiednie narzędzie i odpala metodę setTool dla Board. Przydało by się przy okazji poinformować inne komponenty o takiej zmianie, bo a nóż będą chciały zaktualizować swoje informacje.

Wykorzystamy do tego wzorzec Observer omawiany w tym rozdziale. Gdy wrócisz do tekstu z tamtego rozdziału, zobaczysz, że do tematu można podejść na kilka sposobów, gdzie praktycznie każdy z nich jest równie dobry. Ja wybiorę sposób z sygnałami.

Tworzymy więc klasę eventObserver:


//src/js/eventObserver.js
export default class EventObserver {
    constructor() {
        this.subscribers = [];
    }

    on(fn) { //subskrypcja - dodawanie funkcji do tablicy
        this.subscribers.push(fn);
    }

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

    emit(data) { //wywoływanie wszystkich funkcji w tablicy
        this.subscribers.forEach(fn => {
            fn(data);
        });
    }
}

I na jej podstawie tworzę nowy plik z kilkoma zmiennych będącymi sygnałami:


//src/js/signalEmiter.js
import EventObserver from "./eventObserver";

export default {
    changeTool: new EventObserver(),
    changeColor: new EventObserver(),
    changeSize: new EventObserver(),
}

A następnie dodaję go do klasy Board:


//src/js/board.js
import emiter from "./signalEmiter";

function createCanvas(id, parentElement) {
    ...
}

class Board {
    constructor(query) {
        ...
    }

    bindEvents() {
        ...
    }

    setColor(color) {
        this.toolParams.color = color;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
        emiter.changeColor.emit(color);
    }

    setSize(size) {
        this.toolParams.size = size;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
        emiter.changeSize.emit(size);
    }

    setTool(tool) {
        this.currentTool = tool;
        this.currentTool.onMouseMove(this.mouse.x, this.mouse.y, this.ctx1, this.ctx2, this.toolParams);
        emiter.changeTool.emit(tool);
    }
}

const board = new Board("#canvasCnt");
export default board;

Zanim przejdziemy dalej, spróbujmy w pliku main.js dodać testowe podłączenie do powyższych sygnałów:


//src/js/main.js
import style from "../scss/main.scss";
import board from "./board";
import makeTool from "./makeTool";
import control from "./control";
import emiter from "./signalEmiter";

class App {
    constructor() {
        this.test();
        board.setTool(makeTool("brush"));
        board.setColor("blue");
        board.setSize(10);
    }

    test() {
        emiter.changeTool.on(tool => {
            console.log("Zmieniono narzędzie na", tool);
        })

        emiter.changeSize.on(size => {
            console.log("Zmieniono rozmiar na", size);
        })

        emiter.changeColor.on(color => {
            console.log("Zmieniono kolor na", color);
        })
    }
}

new App();

Spróbuj teraz przełączyć narzędzia klawiszami 1-3, kolory klawiszami r, g, b oraz wielkość narzędzi za pomocą kółka myszy.

Jeżeli wszystko jest ok, przechodzimy dalej.

Interfejs graficzny

Klasa Gui będzie odpowiedzialna za stworzenie prostego interfejsu z informacjami takimi jak kolor, rozmiar czy wybrane urządzenie. Wyglądem samego Gui zajmiemy się na końcu. Teraz bardzo prosta wersja:


//src/js/gui.js
import emiter from "./signalEmiter";
import board from "./board";

class Gui {
    constructor() {
        this.cnt = document.createElement("div");
        this.cnt.classList.add("gui");
        document.body.append(this.cnt);

        this.bindEvents();
    }

    showInfo() {
        setTimeout(() => {
            this.cnt.innerHTML = `
                <span>${board.currentTool.name}</span>
                <span>${board.toolParams.size}</span>
                <span>${board.toolParams.color}</span>
            `;
        })
    }

    bindEvents() {
        emiter.changeTool.on(toolName => {
            this.showInfo();
        })

        emiter.changeColor.on(color => {
            this.showInfo();
        })

        emiter.changeSize.on(size => {
            this.showInfo();
        })
    }
}

const gui = new Gui();
export default gui;

Dołączamy ją do głównego pliku:


//src/js/main.js
import style from "../scss/main.scss";
import board from "./board";
import makeTool from "./makeTool";
import control from "./control";
import gui from "./gui";

class App {
    constructor() {
        board.setTool(makeTool("brush"));
        board.setColor("blue");
        board.setSize(10);
    }
}

new App();

Oraz dodajmy proste stylowanie:


//src/scss/main.scss

...

.gui {
	position: absolute;
	left: 10px;
	top: 10px;
	z-index: 3;

	span {
		border: 1px solid #ddd;
		padding: 5px 10px;
		background: #fff;
		margin-right: 5px;
		display: inline-block;
		font-size: 15px;
		font-family: sans-serif;
	}
}

Sprawdź teraz czy gui reaguje na zmiany parametrów narzędzia.

Klasa Tool

W naszej aplikacji będziemy mieli kilka narzędzi. Większość z nich będzie obsługiwane za pomocą myszy. Musimy więc obsłużyć trzy zdarzenia: mousemove, mousedown, mouseup.


//src/js/tools/tool.js
import Control from "../control";
import board from "../board";

export default class Tool {
    constructor() {
        this.name = "";
    }

    drawPointer(x, y, ctx1, ctx2, toolProp) {}

    onMouseMove(x, y, ctx1, ctx2, toolProp) {}

    onMouseUp(x, y, ctx1, ctx2, toolProp) {}

    onMouseDown(x, y, ctx1, ctx2, toolProp) {}
}

Klasa Brush

Pierwszym z narzędzi będzie Brush, służące do swobodnego rysowania. Podczas ruszania kursorem po płótnie, chcemy użytkownikowi pokazywać pomocniczy wskaźnik. Posłuży do tego funkcja drawPointer(). Funkcja ta dodatkowo jest odpalana przy zmianie wielkości i koloru narzędzia (patrz klasa Tool).


import Tool from "./tool.js";

export default class Brush extends Tool {
    constructor() {
        super();
        this.name = "brush";
        this._draw = false;
        this._prevX = 0;
        this._prevY = 0;
    }

    //rysujemy mały wskaźnik w miejscu gdzie jest kursor
    drawPointer(x, y, ctx, toolProp) {
        ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
        ctx.save();
        ctx.lineWidth = 2;
        ctx.lineCap = "round";
        ctx.strokeStyle = toolProp.color;
        ctx.globalAlpha = 1;
        ctx.beginPath();
        ctx.arc(x, y, toolProp.size/2-1, 0, 2 * Math.PI);
        ctx.stroke();
        ctx.closePath();
        ctx.restore();
    }

    onMouseMove(x, y, ctx1, ctx2, toolProp) {
        //jeżeli użytkownik nie trzyma klawisza myszki
        //rysujemy mu tylko prosty wskaźnik w miejscu kursora
        //w przeciwnym razie rysujemy grubą linią
        //każdorazowo tworząc linię od poprzednich współrzędnych do obecnych
        if (!this._draw) {
            this.drawPointer(x, y, ctx2, toolProp);
        } else {
            ctx1.lineWidth = toolProp.size;
            ctx1.lineCap = "round";
            ctx1.strokeStyle = toolProp.color;
            ctx1.beginPath();
            ctx1.moveTo(this._prevX , this._prevY);
            ctx1.lineTo(x, y);
            ctx1.stroke();
        }

        this._prevX = x;
        this._prevY = y;
    }

    onMouseUp(x, y, ctx1, ctx2, toolProp) {
        this._draw = false;
        ctx1.restore();
    }

    onMouseDown(x, y, ctx1, ctx2, toolProp) {
        if (!this._draw) {
            this._draw = true;
            ctx1.save();
        }
    }
}

Klasa Line

Kolejna klasa - Line - posłuży do rysowania pojedynczych linii. W tym przypadku musimy postąpić nieco inaczej niż przy rysowaniu swobodnym.

Użytkownik naciska klawisz i poruszając kursorem zaczyna dynamicznie kreślić linię. Aby było to możliwe, musimy podczas ruchu czyścić i ponownie rysować wygląd linii. Wykorzystamy do tego pomocnicze płótno. Gdy użytkownik puści klawisz myszy, linia powinna zostać na stałę narysowana - tym razem na głównym płótnie.


import Tool from "./tool.js";

export default class Line extends Tool {
    constructor() {
        super();
        this.name = "line";
        this._draw = false;
        this._startX = 0;
        this._startY = 0;
    }

    drawFigure(x, y, ctx, toolProp) {
        ctx.lineWidth = toolProp.size;
        ctx.lineCap = "square";
        ctx.strokeStyle = toolProp.color;
        ctx.beginPath();
        ctx.moveTo(this._startX, this._startY);
        ctx.lineTo(x, y);
        ctx.stroke();
        ctx.closePath();
        ctx.restore();
    }

    drawPointer(x, y, ctx, toolProp) {
        ctx.save();
        ctx.lineWidth = 2;
        ctx.lineCap = "round";
        ctx.strokeStyle = toolProp.color;
        ctx.globalAlpha = 1;
        ctx.beginPath();
        ctx.strokeRect(x - toolProp.size / 2, y - toolProp.size / 2, toolProp.size, toolProp.size);
        ctx.restore();
    }

    onMouseMove(x, y, ctx1, ctx2, toolProp) {
        ctx2.clearRect(0, 0, ctx2.canvas.width, ctx2.canvas.height);

        //gdy użytkownik nie trzyma jeszcze klawisza myszy rysujemy mu prosty wskaźnik - kwadracik
        //gdy naciśnie klawisz myszy zaczyna rysować linię na pomocniczym płótnie
        if (!this._draw) {
            this.drawPointer(x, y, ctx2, toolProp);
        } else {
            this.drawFigure(x, y, ctx2, toolProp);
        }

    }

    onMouseUp(x, y, ctx1, ctx2, toolProp) {
        if (this._draw) {
            this.drawFigure(x, y, ctx1, toolProp);
        }
        this._draw = false;
    }

    //gdy użytkownik zacznie trzymać klawisz myszy,
    //ustawiamy początkową pozycję linii
    onMouseDown(x, y, ctx1, ctx2, toolProp) {
        if (!this._draw) {
            this._draw = true;
            this._startX = x;
            this._startY = y;
        }
    }
}

Klasa Rectangle

Kolejne narzędzie posłuży do rysowania kwadratów. Jego działanie będzie bliźniaczo podobne do powyższego. Różnica est tutaj w rysowaniu samego kwadratu. W poprzedniej klasie używaliśmy metody lineTo(x, y, x2, y2) dla której wystarczyło podać odpowiednie pozycje. W tym przypadku użyjemy metody strokeRect(x, y, width, height). Wymaga ona podania szerokości i wysokości rysowanego prostokąta, dlatego musimy dokonać lekkich wyliczeń.


//src/js/tools/rectangle.js
import Tool from "./tool.js";

export default class Line extends Tool {
    constructor() {
        super();
        this.name = "rectangle";
        this._draw = false;
        this._startX = 0;
        this._startY = 0;
    }

    drawFigure(x, y, ctx, toolProp) {
        ctx.lineWidth = toolProp.size;
        ctx.lineCap = "round";
        ctx.strokeStyle = toolProp.color;
        ctx.beginPath();
        ctx.strokeRect(this._startX, this._startY, x - this._startX, y - this._startY);
        ctx.restore();
    }

    drawPointer(x, y, ctx, toolProp) {
        ctx.save();
        ctx.lineWidth = 2;
        ctx.lineCap = "round";
        ctx.strokeStyle = toolProp.color;
        ctx.globalAlpha = 1;
        ctx.beginPath();
        ctx.strokeRect(x - toolProp.size / 2, y - toolProp.size / 2, toolProp.size, toolProp.size);
        ctx.restore();
    }

    onMouseMove(x, y, ctx1, ctx2, toolProp) {
        ctx2.clearRect(0, 0, ctx2.canvas.width, ctx2.canvas.height);

        if (!this._draw) {
            this.drawPointer(x, y, ctx2, toolProp);
        } else {
            this.drawFigure(x, y, ctx2, toolProp);
        }

    }

    onMouseUp(x, y, ctx1, ctx2, toolProp) {
        if (this._draw) {
            this.drawFigure(x, y, ctx1, toolProp);
        }
        this._draw = false;
    }

    onMouseDown(x, y, ctx1, ctx2, toolProp) {
        if (!this._draw) {
            this._draw = true;
            this._startX = x;
            this._startY = y;
        }
    }
}

Klasa koła

Zanim przejdziemy do poprawy wyglądu naszej aplikacji, spróbujmy dodać jeszcze jedno narzędzie - tym razem służące do rysowania kół czy też owali.

Po pierwsze dodajmy odpowiedni zapis w konfiguracji:


//src/js/config.js
export default {
    tools : [
        {key : "1", tool : "brush"},
        {key : "2", tool : "line"},
        {key : "3", tool : "rectangle"},
        {key : "4", tool : "circle"},
    ],

    colors : [
        {key: "r", color: "red"},
        {key: "g", color: "green"},
        {key: "b", color: "blue"}
    ]
}

Po drugie stwórzmy odpowiedni plik z klasą i dodajmy ją do funkcji tworzącej odpowiednie narzędzia:


//src/js/tools/circle.js
import Tool from "./tool";

export default class Circle extends Tool {
    constructor() {
        super();
        this.name = "circle";
        console.log(this.name);
    }
}

i dodajmy go do funkcji tworzącej narzędzia:


//src/js/makeTool.js
import Brush from "./tools/brush";
import Line from "./tools/line";
import Rectangle from "./tools/rectangle";
import Circle from "./tools/circle";

export default function(tool) {
    switch (tool) {
        case "brush":
            return new Brush();
        case "line":
            return new Line();
        case "rectangle":
            return new Rectangle();
         case "circle":
            return new Circle();
    }
}

Zanim przejdziesz dalej, sprawdź czy możesz przełączyć się na powyższe narzędzie.

I tu jest dobry moment, byś spróbował sam napisać odpowiedni kod w klasie Circle. Odpowiednie informacje znajdziesz w tym rozdziale.

Dobrze. Ja tym czasem spróbuję zrobić to po swojemu. Jeżeli już skończysz, spójrz na moje rozwiązanie.

Chcę by podobnie do rysowania poprzednich figur, użytkownik mógł kreślić owale przeciągając kursorem. Dla funkcji ctx.ellipse() podajemy środkowy punkt rysowanej elipsy, a także poziomy i pionowy promień. Trzeba więc przeprowadzić dodatkowe wyliczenia. Cała reszta klasy będzie bliźniaczo podobna do powyższych:


    //src/js/tools/circle.js
    import Tool from "./tool.js";

    export default class Circle extends Tool {
        constructor() {
            super();
            this.name = "circle";
            this._draw = false;
            this._startX = 0;
            this._startY = 0;
        }

        drawFigure(x, y, ctx, toolProp) {
            ctx.save();
            ctx.lineWidth = toolProp.size;
            ctx.lineCap = "round";
            ctx.strokeStyle = toolProp.color;
            ctx.beginPath();

            //elipsę mogę rysować w lewo lub prawo, w górę lub dół
            //dlatego muszę wyliczyć które wartości są którymi
            const minX = Math.min(this._startX, x);
            const minY = Math.min(this._startY, y);

            const maxX = Math.max(this._startX, x);
            const maxY = Math.max(this._startY, y);

            const middleX = minX + ((maxX - minX) / 2);
            const middleY = minY + ((maxY - minY) / 2);

            const rX = (maxX - minX) / 2;
            const rY = (maxY - minY) / 2;

            ctx.ellipse(middleX, middleY, rX, rY, 0, 0, 2 * Math.PI);
            ctx.stroke();
            ctx.restore();
        }

        drawPointer(x, y, ctx, toolProp) {
            ctx.save();
            ctx.lineWidth = 2;
            ctx.lineCap = "round";
            ctx.strokeStyle = toolProp.color;
            ctx.globalAlpha = 1;
            ctx.beginPath();
            ctx.arc(x, y, toolProp.size/2-1, 0, 2 * Math.PI);
            ctx.stroke();
            ctx.restore();
        }

        onMouseMove(x, y, ctx1, ctx2, toolProp) {
            ctx2.clearRect(0, 0, ctx2.canvas.width, ctx2.canvas.height);

            if (!this._draw) {
                this.drawPointer(x, y, ctx2, toolProp);
            } else {
                this.drawFigure(x, y, ctx2, toolProp);
            }

        }

        onMouseUp(x, y, ctx1, ctx2, toolProp) {
            if (this._draw) {
                this.drawFigure(x, y, ctx1, toolProp);
            }
            this._draw = false;
        }

        onMouseDown(x, y, ctx1, ctx2, toolProp) {
            if (!this._draw) {
                this._draw = true;
                this._startX = x;
                this._startY = y;
            }
        }
    }
    

Poprawiamy wygląd gui

Wreszcie coś normalnego. Pobawmy się wyglądem.


import emiter from "./signalEmiter";
import config from "./config";
import makeTool from "./makeTool";
import board from "./board";

class Gui {
    constructor(query) {
        this.cnt = null;

        this.generateHTML(query);
        this.bindEvents();
    }

    //generuję ogólny html dla gui
    generateHTML(query) {
        const icons = {
            "brush": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.061,22c1.523,0,2.84-0.543,3.91-1.613c1.123-1.123,1.707-2.854,1.551-4.494l8.564-8.564 c1.217-1.217,1.217-3.195-0.002-4.414c-1.178-1.18-3.234-1.18-4.412,0l-8.884,8.884c-1.913,0.169-3.807,1.521-3.807,3.919 c0,0.303,0.021,0.588,0.042,0.86c0.08,1.031,0.109,1.418-1.471,2.208c-0.316,0.158-0.525,0.472-0.55,0.824 c-0.025,0.352,0.138,0.691,0.428,0.893C2.52,20.563,4.623,22,7.061,22C7.06,22,7.06,22,7.061,22z M18.086,4.328 c0.424-0.424,1.158-0.426,1.586,0.002c0.437,0.437,0.437,1.147,0,1.584L12,13.586L10.414,12L18.086,4.328z M6.018,16.423 C6,16.199,5.981,15.965,5.981,15.717c0-1.545,1.445-1.953,2.21-1.953c0.356,0,0.699,0.073,0.964,0.206 c0.945,0.475,1.26,1.293,1.357,1.896c0.177,1.09-0.217,2.368-0.956,3.107C8.865,19.664,8.049,20,7.061,20H7.06 c-0.75,0-1.479-0.196-2.074-0.427C6.068,18.6,6.107,17.584,6.018,16.423z"/></svg>`,
            "line": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 11H19V13H5z"/></svg>`,
            "rectangle": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20,3H4C3.448,3,3,3.447,3,4v16c0,0.553,0.448,1,1,1h16c0.553,0,1-0.447,1-1V4C21,3.447,20.553,3,20,3z M19,19H5V5h14V19z"/></svg>`,
            "circle": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M12,20c-4.411,0-8-3.589-8-8s3.589-8,8-8 s8,3.589,8,8S16.411,20,12,20z"/></svg>`
        };

        this.cnt = document.createElement("div");
        this.cnt.classList.add("gui");

        this.cnt.innerHTML = `
            <div class="gui-color">
                <span class="gui-color__element"></span>
            </div>
            <ul class="gui-tools">
                ${config.tools.map(el => {
                    return `<li class="gui-tools__element" data-tool="${el.tool}">${icons[el.tool]}</li>`
                }).join("")}
            </ul>
        `;
        document.querySelector(query).append(this.cnt);
    }

    updateToolsList(toolName) {
        this.cnt.querySelectorAll(".gui-tools__element").forEach(el => {
            el.classList.remove("is-active");
        });

        this.cnt.querySelector(`.gui-tools__element[data-tool="${toolName}"]`).classList.add("is-active");
    }

    updateColor(color) {
        this.cnt.querySelector(".gui-color__element").style.background = color;
    }

    bindEvents() {
        //po kliknięciu na elementy listy zmieniam aktualne narzędzie
        //i emituję o tym sygnał
        this.cnt.querySelectorAll(".gui-tools__element").forEach(el => {
            el.addEventListener("click", e => {
                const tool = makeTool(el.dataset.tool);
                board.setTool(tool);
            })
        })

        emiter.changeTool.on(tool => {
            this.updateToolsList(tool.name);
        })

        emiter.changeColor.on(color => {
            this.updateColor(color);
        })
    }
}

const gui = new Gui("#app");
export default gui;

Omówmy poszczególne części. Na początku generuję cały HTML dla gui.


generateHTML(query) {
    const icons = {
        "brush": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M7.061,22c1.523,0,2.84-0.543,3.91-1.613c1.123-1.123,1.707-2.854,1.551-4.494l8.564-8.564 c1.217-1.217,1.217-3.195-0.002-4.414c-1.178-1.18-3.234-1.18-4.412,0l-8.884,8.884c-1.913,0.169-3.807,1.521-3.807,3.919 c0,0.303,0.021,0.588,0.042,0.86c0.08,1.031,0.109,1.418-1.471,2.208c-0.316,0.158-0.525,0.472-0.55,0.824 c-0.025,0.352,0.138,0.691,0.428,0.893C2.52,20.563,4.623,22,7.061,22C7.06,22,7.06,22,7.061,22z M18.086,4.328 c0.424-0.424,1.158-0.426,1.586,0.002c0.437,0.437,0.437,1.147,0,1.584L12,13.586L10.414,12L18.086,4.328z M6.018,16.423 C6,16.199,5.981,15.965,5.981,15.717c0-1.545,1.445-1.953,2.21-1.953c0.356,0,0.699,0.073,0.964,0.206 c0.945,0.475,1.26,1.293,1.357,1.896c0.177,1.09-0.217,2.368-0.956,3.107C8.865,19.664,8.049,20,7.061,20H7.06 c-0.75,0-1.479-0.196-2.074-0.427C6.068,18.6,6.107,17.584,6.018,16.423z"/></svg>`,
        "line": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M5 11H19V13H5z"/></svg>`,
        "rectangle": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M20,3H4C3.448,3,3,3.447,3,4v16c0,0.553,0.448,1,1,1h16c0.553,0,1-0.447,1-1V4C21,3.447,20.553,3,20,3z M19,19H5V5h14V19z"/></svg>`,
        "circle": `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M12,2C6.486,2,2,6.486,2,12s4.486,10,10,10s10-4.486,10-10S17.514,2,12,2z M12,20c-4.411,0-8-3.589-8-8s3.589-8,8-8 s8,3.589,8,8S16.411,20,12,20z"/></svg>`
    };

    this.cnt = document.createElement("div");
    this.cnt.classList.add("gui");

    const toolHTML = (el) => {
        return `<li class="gui-tools__element" data-tool="${el.tool}">${icons[el.tool]}</li>`;
    }

    this.cnt.innerHTML = `
        <div class="gui-color">
            <span class="gui-color__element"></span>
        </div>
        <ul class="gui-tools">
            ${config.tools.map(toolHTML).join("")}
        </ul>
    `;
    document.querySelector(query).append(this.cnt);
}

Tworzymy element div z klasą .gui, a następnie wypełniamy go odpowiednim HTML. Wewnątrz listy .gui-tools robiąc pętlę po config.tools (tak samo jak to robiliśmy w klasie Control) generujemy kolejne ikonki pobierając ich wygląd z tablicy icons. Same ikonki są w postaci SVG. Możesz je pobrać między innymi ze stron: https://boxicons.com/ i https://remixicon.com/.


bindEvents() {
    //po kliknięciu na elementy listy zmieniam aktualne narzędzie
    //i emituję o tym sygnał
    this.cnt.querySelectorAll(".gui-tools__element").forEach(el => {
        el.addEventListener("click", e => {
            const tool = makeTool(el.dataset.tool);
            board.setTool(tool);
        })
    })

    emiter.changeTool.on(tool => {
        this.updateToolsList(tool.name);
    })

    emiter.changeColor.on(color => {
        this.updateColor(color);
    })
}

Wewnątrz funkcji bindEvents() po pierwsze podpinamy kliknięcie wygenerowanym wcześniej ikonkom narzędzi. Po kliknięciu - podobnie jak to robiliśmy w pliku main.js generujemy pojedyncze narzędzie, a potem informujemy o tym resztę komponentów. Pozostaje podłączyć się pod odpowiednie sygnały.


updateToolsList(toolName) {
    this.cnt.querySelectorAll(".gui-tools__element").forEach(el => {
        el.classList.remove("is-active");
    });

    this.cnt.querySelector(`.gui-tools__element[data-tool="${toolName}"]`).classList.add("is-active");
}

updateColor(color) {
    this.cnt.querySelector(".gui-color__element").style.background = color;
}

Najprostsze funkcje w zestawieniu - odpalane są gdy dostaniemy informację o zmianie narzędzia czy zmianie koloru. Pobieramy tutaj odpowiedni element i aktualizujemy mu klasę.

Pozostaje dodać odpowiednie stylowanie. Idealnym rozwiązaniem będzie stworzenie dedykowanego pliku _gui.scss i dołączenie go do pliku main.scss:


//src/scss/main.scss
@use "gui" as gui;

//...pozostałe stylowanie
//z usunięciem starych styli gui...

//src/scss/_gui.scss
.gui {
	position: absolute;
	left: 30px;
	top: 30px;
	z-index: 3;
	background: #fff;
	width: 50px;
	border-radius: 4px;
	box-shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 6px rgba(0,0,0,0.1);
}

.gui-color {
	width: 50px;
	height: 50px;
	display: flex;
	justify-content: center;
	align-items: center;
}

.gui-color__element {
	width: 30px;
	height: 30px;
	border-radius: 2px;
	display: block;
}

.gui-tools {
	list-style: none;
	padding: 0;
	margin: 0;
	margin-bottom: 10px;
}

.gui-tools__element {
	width: 50px;
	height: 45px;
	display: flex;
	justify-content: center;
	align-items: center;
	cursor: pointer;

	svg {
		opacity: 0.2;
	}
}

.gui-tools__element.is-active {
	background: #eee;
	position: relative;

	svg {
		opacity: 1;
	}
}

.gui-tools__icon {
	max-width: 90%;
}

Demo

Gotową aplikację możesz teraz zbudować poleceniem npm run build.

Skończoną zbudowaną aplikację znajdziesz pod poniższym linkiem:

Demo

Zadania domowe

Przydało by się dodać kilka rzeczy do naszej aplikacji. Mam kilka pomysłów dla ciebie:

  • Dodanie funkcjonalności czyszczenia całej planszy
  • Rysowanie trójkątów
  • Narzędzie gumka
  • Dodanie dodatkowych bajerów. Polecam przeglądnąć stronę http://perfectionkills.com/exploring-canvas-drawing-techniques/
  • Dodanie klawisza modyfikującego wygląd. Dla przykładu trzymając klawisz Ctrl użytkownik mógłby rysować wypełnione figury, lub zamiast linii rysował by linię z grotem
  • Dodanie możliwości zmiany rozmiaru dodatkowymi klawiszami (np. [ i ])
  • Dodanie możliwości cofania zmian (Ctrl + Z)

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.