jQUERY CANVAS
Lucas Villani Justo - 2024
O jQuery Canvas utiliza a tecnologia canvas do HTML5 aliada ao jQuery para realizar desenhos simples. O programa é orientado a objetos, na qual cada ferramenta é um objeto.
Princípio de funcionamento procedural
Inicialização
Canvas
const canvas = document.querySelector("canvas");
ctx = canvas.getContext("2d");
ctx.imageSmoothingEnabled = true;
A inicialização do canvas e do contexto do desenho são as primeiras coisas realizadas no código JS. ctx é o contexto a qual passaremos toda configuração e mudança que será aplicada ao canvas. Uma delas é habilitar a imageSmoothing que irá reduzir serrilhados no desenho.
Ferramentas no documento HTML
const ferramentas = document.getElementById("ferramentas");
const brushSize = document.getElementById("brushSize");
const acoes = document.getElementById("acoes");
Após isso definimos as regiões na qual haverá o seletor de ferramentas para o usuário dentro do arquivo.
A primeira constante, ferramentas é uma div que possui um série de botões que permite o usuário trocar a ferramenta atual.
A segunda se refere a um input do tipo range que é usado para manipular o tamanho do pincel, ou seja, a espessura das ferramentas .
A terceira a ações pertinentes a desfazer, refazer e limpar desenho.
Load e definição das dimensões do canvas
window.addEventListener("load", () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
ctx.fillStyle = "#fff"; // Define a cor de fundo como branco
ctx.fillRect(0, 0, canvas.width, canvas.height);
//Inicia o deslizante Brushsize e seta os valores de ínicio
brushSize.value = 5;
brushSize.min = 1;
brushSize.max = 100;
//Seta valor de ínicio no seletor de cores
document.getElementById("brushColor").value = "#000";
});
Chamada imediatamente, carrega o tamanho do canvas para que as coordenadas estarem em relação a ele mesmo e seu próprio tamanho. Incluindo a cor de fundo dele quenesse caso é branca, o tamanho atual, mínimo, máximo e a cor padrão do pincel.
Dicionário de ferramentas e ferramenta atual
const tools = {
"lapis": new Pencil(),
"borracha": new Eraser(),
"retangulo": new Rectangle(),
"retangulo_fill": new RectangleFill(),
"elipse": new Ellipse(),
"elipse_fill": new EllipseFill(),
"linha": new Line(),
"balde": new Bucket()
};
var current_tool = "lapis";
const simpleDo = new SimpleDo(10, ctx.getImageData(0, 0, canvas.width, canvas.height));
Aqui inicializamos todas as ferramentas que serão utilizadas pelo canvas, em seguida a variável current_tool que será usada como
a chave para sabermos qual ferramenta o usuário deseja usar.
Também iniciamos o simpleDo, que é o objeto responsável por gerenciar as ações de desfazer e refazer
Um pouco mais a frente na explicação de como as classes de ferramentas funcionam, será mostrado que todas elas possuem métodos em comum
que são chamados sequecialmente sem exceção. A combinação dessas duas entidades (tool e current_tool) permite que o código fique menor.
Pois basta sabermos para qual ferramenta estamos apontando, pois os métodos serão os mesmos. Por exemplo:
tools[current_tool].aplicar(ctx, e);
//A explicação do "ctx" e do "e" será fornecida mais a frente.
Para o lápis o comportamento no canvas será desenhar, para a borracha será apagar, para a linha será desenhar uma linha.
Portanto podemos trabalhar somente nas classes e deixar o código procedural do documento intacto.
Customização e dados importantes para o canvas
const custom = {
//Dados gerais
"brush_size": 1,
"brush_color": "#000",
//Dados para ferramentas: Retângulo, Linha, ELipse
"prevX": 0,
"prevY": 0,
"snapshot": ctx.getImageData(0, 0, canvas.width, canvas.height),
"canvas_size": {
"width": ctx.canvas.width,
"height": ctx.canvas.height
},
"limpo": true
};
O usuário tem a possibilidade de alterar o tamanho do pincel e sua cor. Esses dados são armazenados no dicionário custom. O dicionário então será utilizado pelas ferramentas no método setup que define para cada ferramenta sua cor, tamanho e outros dados do dicionaŕio se necessário. Também funciona como armazenamento do valor da cor atual e do tamanho do pincel atual.
Já as chaves prevX, prevY e snapshot serão utilizadas para as ferramentas que deslizam, como retângulo, elipse, linha. O prevX e o prevY armazenam a posição inicial, ou seja, o início de onde será desenhado aquela forma. A snapshot serve para armazenar a imagem do desenho no instante antes das ferramentas a pouco citadas. Ela é chamada continuamente para gerar a ilusão que a forma desenhada está deslizando seguindo o mouse pelo canvas, até que o botão seja solto e a forma enfim desenhada de fato. Canvas width e canvas size é utilizado para passar a informação do tamanho do canvas para as ferramentas. Já o "limpo" se refere a ação limpar desenho, não faz sentido limpar o desenho mais de uma vez se ele já está limpo, então isso é uma flag que indica que o desenho já está limpo.
//Para trocar a ferramenta
$(ferramentas).on("click", function (e) { current_tool = e.target.id; });
//Para trocar o tamanho da ferramenta
$(brushSize).on("input", function () { custom["brush_size"] = $(this).val(); });
//Para trocar a cor da ferramenta
$(brushColor).on("input", function () { custom["brush_color"] = $(this).val(); });
//Para lidar com o desfazer, refazer e limpar desenho
$(acoes).on("click", function(e) {
acao = e.target.id;
switch (acao) {
case "undo":
simpleDo.undo(ctx);
break;
case "redo":
simpleDo.redo(ctx);
break;
case "limpar":
//Limpar a tela é uma ação, mas só pode ser realizada uma vez (não faz sentido limpar um desenho que já foi limpo)
if (custom["limpo"]) break;
//Do contrário realizamos a limpeza
ctx.fillStyle = "#fff"; // Define a cor de fundo como branco
ctx.fillRect(0, 0, canvas.width, canvas.height);
simpleDo.action = true;
custom["limpo"] = true;
simpleDo.pushAction(ctx.getImageData(0, 0, canvas.width, canvas.height));
break;
default:
break;
}
});
Após a definição de todos essas constantes e variáveis adicionamos os eventos que irão escutar as interações do usuário com as seções citadas no ínicio (ferramentas, brushSize, brushColor).
Quando o usuário interagir com esses elementos o novo valor definido pelo usuário será salvo no dicionário custom para ser utilizado pelos objetos (ferramentas de desenho).
No caso das ações desfazer e refazer, simplesmente chamamos os métodos do objeto simpleDo.
Já no limpar, verificams primeiramente se o desenho já está limpo, se já estiver, não precisamos limpar novamente. Do contrário limpamos o desenho e consideramos aquilo uma nova ação,
setamos a flag de limpo como limpo, e damos um push daquela ação no stack de ações do objeto simpleDo.
Eventos no canvas
Desenho (mousedown)
Primeiramente é redefinido o conteúdo das chaves snapshot, prevX, e prevYm pois elas só são relevantes serem modificadas na iminência da aplicação da ferramenta. Justamente porque marcam o ponto inicial onde o usuário começou a desenhar, como tira um retrato do desenho antes da aplicação da ferramenta, para que seja aplicado o desfazer e refazer, ou para criar a ilusaõ de deslize no caso das ferramentas, retângulo, elipse e linha. Em seguida chamamos o método beginPath() do ctx para dizermos que a aplicação da ferramenta deve iniciar onde o usuário deu o clique, e não a partir de onde ele terminou a aplciação da ferramenta anterior.
$(canvas).on('mousedown', function(e) {
custom["snapshot"] = ctx.getImageData(0, 0, canvas.width, canvas.height);
custom["prevX"] = e.offsetX;
custom["prevY"] = e.offsetY;
simpleDo.action = true;
custom["limpo"] = false;
ctx.beginPath();
Quando o usuário realizar um clique com o botão esquerdo no canvas a ferramenta atual selecionada deve ser inicializada, ou seja, os parãmetros necessaŕios do dicionário custom serão aproveitados
por aquela ferramenta, como o tamanho do lápis, a cor, e etc...
O simpleDo.action é setado para true para indicar que o usuário de fato clicou no desenho, realizou uma ação. E se ele realizou uma nova ação com as ferramentas, o canvas não está mais limpo,
então custom["limpo"] é setado para false
Este processo ocorre no método setup, e recebe o dicionário custom como parâmetro.
Seguidamente chamamos o método iniciar. Esse método aplica as mudanças de cor, tamanho do lápis, tipo de borda de linha e etc... Ao canvas, ou seja, a partir desse momento, o canvas será desenhado utilizando as configurações específicas daquela ferramenta, também aplica um ponto único no caso de algumas ferramentas.
tools[current_tool].setup(custom);
tools[current_tool].iniciar(ctx, e);
Então, o desenho, ou seja, a aplicação será executada no canvas todas vez que o mouse (ainda pressionado) se mover pelo mesmo, ou apenas durante um clique (mousedown) (LEMBRANDO QUE ESSE EVENTO ESTÁ DENTRO DO ESCOPO DO $(canvas).on('mousedown'...):
$(canvas).on('mousemove mousedown', function(e) {
tools[current_tool].aplicar(ctx, e);
});
});
O ctx é passado para que o canvas receba as mudanças, e "e" do evento é usado para recuperar as coordenadas do mouse.
Final do movimento (mouseup)
Finalmente, quando o usuário deixar de pressionar o botão esquerdo, o movimento do desenho terá sido concluído, e o evento $(canvas).on('mousemove')... deve ser desativado.
$(canvas).on('mouseup mouseleave', function(e) {
$(canvas).off('mousemove');
simpleDo.pushAction(ctx.getImageData(0, 0, canvas.width, canvas.height));
});
Toda vez que o mouse for pressionado no canvas, o evento de aplicar a ferramenta será acionado novamente, e assim é mantido o controle do usuário para desenhar somente quando o mouse estiver pressionado em cima do canvas.
Também é ocorre se o mouse deixar o canvas, devido ao 'mouseleave'.
Como isso indica um encerramento de aplicação da ferramenta, empurramos aquele novo estado do canvas para o stack do simpleDo. Acumulando o que foi feito no canvas para fazer e refazer. O atributo simpleDo.action é o responsável de garantir que quando o usuário simplesmente passe o mouse em cima do mouse e retire, dando um trigger nesse evento aquele novo estado, em que nada foi modificado no canvas, não seja adicionado ao stack do simpleDo.
Classes de Ferramentas
As classes de ferramentas são escritas em um documento separado, tool.js, que é importado no ínicio do carregamento da página. Para evitar repetições, qualquer descrição ausente no método de uma classe, estará presente na classe anterior ou na superclasse.
Tool
class Tool {
constructor(size = 1, color = "#000") {
this.brush_size = size;
this.brush_color = color;
}
setup(custom) {
this.brush_size = custom["brush_size"];
this.brush_color = custom["brush_color"];
}
iniciar() {
throw new Error("Método 'iniciar' deve ser aplicado em subclasses");
}
aplicar() {
throw new Error("Método 'aplicar' deve ser aplicado em subclasses");
}
}
A classe Tool é uma classe abstrata que funciona como base para as demais classes. Toda subclasse derivada desta terá os 4 seguintes métodos, que farão:
- constructor: Responsável por iniciar cada ferramenta, por padrão cada ferramenta inicia com tamanho 1 e cor "#000", isto é, preto. Isso garante que todas as ferramentas estarão prontas para uso, e usarão a mesa configuração.
- setup(custom): Se ocorrer uma mudança na configuração inicial, o setup garante que a ferramenta receberá aquelas mudanças. O funcionamento e razão para o custom pode ser vista aqui.
- iniciar: O método iniciar aplica as mudanças no ctx, enquanto o setup aplica as mudanças no próprio objeto. Este método é o que configura o ctx para quando o usuário fazer uso da ferramenta. Pode também aplicar um clique, ou desenho único correspondente à ferramenta.
- aplicar: Responsável por aplicar o desenho no canvas, é a nesse método que o usuário enxerga os efeitos do uso da ferramenta no canvas, durante o mousedown junto ao mousemove.
Lápis
class Pencil extends Tool {
iniciar(ctx, e) {
ctx.strokeStyle = this.brush_color;
ctx.lineWidth = this.brush_size;
ctx.lineCap = 'round'; //Faz a linha ficar redonda
ctx.lineJoin = 'round'; //Remove os spikes
ctx.moveTo(e.offsetX, e.offsetY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
aplicar(ctx, e) {
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
}
O lápis é a ferramenta mais simples, funciona com chamadas sucessivas do método lineTo do ctx. Criando a ilusão de um lápis sendo utilizado na tela. A seguir uma explicação dos métodos. Obs: O método construtor e setup são omitidos pois são herdados dos super classe Tool. Os demais métodos tem polimorfismo.
Perceba que os valores do this.brush_color e this.brush_size já foram chamados no método setup herdado da superclasse.
- iniciar(ctx, e):
- ctx.strokeStyle -> define a cor do que será desenhado a partir daquele momento no canvas.
- ctx.lineWidth -> define o tamanho do pincel.
- ctx.lineCap = 'round'; -> Deixa as duas extremidades da linha sempre redondas.
- ctx.lineCap = 'round'; -> Deixa as conexão entre as linhas sempre redondas. Sem isso, a linha teria vários espinhos em posições aleatórias durante a aplicação e movimento da ferramenta.
- ctx.moveTo(e.offsetX, e.offsetY);
- ctx.lineTo(e.offsetX, e.offsetY);
- ctx.stroke(); -> As linhas acima aplicam um ponto no canvas correspondente a configuração da ferramenta quando há um clique único.
- aplicar(ctx, e): //Método que será chamado continuamente enquanto o mouse é pressionado e movimentado
- ctx.lineTo(e.offsetX, e.offsetY); -> Define o caminho da linha, o e de "event" é passado para que seja possível recuperar a posição do lápis dentro do canvas
- ctx.stroke(); -> Desenha a linha de fato
Borracha
class Eraser extends Tool {
constructor(size = 1, color = "#fff") {
super(size, color);
}
setup(custom) {
this.brush_size = custom["brush_size"];
}
iniciar(ctx, e) {
ctx.strokeStyle = this.brush_color;
ctx.lineWidth = this.brush_size * 3;
ctx.lineCap = 'round'; //Faz a linha ficar redonda
ctx.lineJoin = 'round'; //Remove os spikes
ctx.moveTo(e.offsetX, e.offsetY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
aplicar(ctx, e) {
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
}
A borracha se comporta como o lápis, entretanto tem diferenças importantes.
- constructor: Aqui a cor padrão é branca invés de preta.
- setup: O setup só redefine o tamanho da borracha, a cor permanece a mesma declarada no construtor (branca).
- iniciar(ctx, e):
- ctx.strokeStyle = this.brush_color; -> A cor que se refere a borracha sempre aponta para a cor branca (dito anteriormente no setup).
- ctx.lineWidth = this.brush_size * 3; -> A variação do tamanho da borracha é maior por padrão.
Sliding Tools
class slidingTools extends Tool {
setup(custom) {
this.brush_size = custom["brush_size"];
this.brush_color = custom["brush_color"];
//Para armazenar a posição do mouse, onde é o começo do quadrado
this.prevX = custom["prevX"];
this.prevY = custom["prevY"];
//Para armazenar a imagem e impedir que o desenho fique com um retângulo borrando
this.snapshot = custom["snapshot"];
}
}
As ferramentas Retângulo, Elipse e Linha tem uma necessidade especial. Enquanto o usuário desliza e não solta o botão esquerdo do mouse, deve ser possível enxergar a forma da ferramenta deslizando pelo canvas, mas, somente ser desenhada de fato quando o usuário soltar o botão esquerdo do mouse. A classe abstrata slidingTools funciona para propagar os dados necessários para essa característica em comum nas subclasses geradas a partir dela.
- setup(custom):
- this.prevX = custom["prevX"]; this.prevY = custom["prevY"]; -> Armazena a posição inicial do mouse, será nosso ponto de referência quando realizarmos o desenho da forma
- this.snapshot = custom["snapshot"]; -> Armazena uma imagem do canvas no momento do clique down do mouse, antes do movimento e aplicaçaõ da ferramenta. A imagem será reescrita no canvas continuamente dando a impressão que a forma está deslizando pelo canvas. Evitará que o desenhe fique borrado da forma deslizando.
Retângulo
class Rectangle extends slidingTools {
iniciar(ctx) {
ctx.strokeStyle = this.brush_color;
ctx.lineWidth = this.brush_size;
ctx.lineCap = 'butt'; //Faz as extremidades ficarem pontudas de novo
ctx.lineJoin = 'miter';
}
aplicar(ctx, e) {
ctx.putImageData(this.snapshot, 0, 0);
ctx.strokeRect(e.offsetX, e.offsetY, this.prevX - e.offsetX, this.prevY - e.offsetY);
}
}
O retângulo é a forma mais direta. Com poucas diferenças em relação as demais.
- iniciar(ctx):
- ctx.lineCap = 'butt'; e ctx.lineJoin = 'miter'; -> Faz a extremidade das linhas ficarem pontudas novamente.
- aplicar(ctx, e):
- ctx.putImageData(this.snapshot, 0, 0); -> Escreve a imagem salva do canvas no momento do clique, para criar a ilusão de desenho.
- ctx.strokeRect(e.offsetX, e.offsetY, this.prevX - e.offsetX, this.prevY - e.offsetY); -> As duas primeiras coordenadas seguem o ponteiro do mouse (x,y) enquanto as últimas duas definem a largura e a altura, respectivamente.
Retângulo cheio
class RectangleFill extends Rectangle {
iniciar(ctx) {
ctx.strokeStyle = this.brush_color;
ctx.fillStyle = this.brush_color;
ctx.lineWidth = this.brush_size;
ctx.lineCap = 'butt';
ctx.lineJoin = 'miter';
}
aplicar (ctx, e) {
ctx.putImageData(this.snapshot, 0, 0);
ctx.fillRect(e.offsetX, e.offsetY, this.prevX - e.offsetX, this.prevY - e.offsetY);
}
}
- iniciar(ctx):
- ctx.fillStyle = this.brush_color; -> Agora definimos também a cor do fill
- aplicar(ctx, e):
- ctx.fillRect(e.offsetX, e.offsetY, this.prevX - e.offsetX, this.prevY - e.offsetY); -> Utilizamos o método fillRect invés do método stroke.
Elipse
class Ellipse extends slidingTools {
iniciar(ctx) {
ctx.strokeStyle = this.brush_color;
ctx.lineWidth = this.brush_size;
}
aplicar(ctx, e) {
ctx.beginPath();
ctx.putImageData(this.snapshot, 0, 0);
let radiusx = Math.abs(parseInt((e.offsetX - this.prevX)/2));
let radiusy = Math.abs(parseInt((e.offsetY - this.prevY)/2));
let x = parseInt((e.offsetX - this.prevX)/2) + this.prevX;
let y = parseInt((e.offsetY - this.prevY)/2) + this.prevY;
ctx.ellipse(x, y, radiusx, radiusy, 0, 0, Math.PI * 2);
ctx.closePath();
ctx.stroke();
}
}
A elipse possui certas pelicularidades em relação ao retângulo, principalmente no que diz respeito ao closePath e beginPath, e a ausência das linhas que definem a curvatura das bordas, pois como trata-se de um arco não haverá linhas pontudas e arestas naturalmente.
Para termos certeza que a elipse foi fechada precisamos utilizar o método closePath no final, entretanto, ao chamar esse método se o método beginPath não for chamado novamente não ocorerá um novo stroke no canvas, pois o caminho foi encerrado, e o ctx não sabe onde começar. Por isso é chamado o beginPath nesse caso.
- aplicar(ctx, e):
- ctx.beginPath(); -> Garante que quando o Path for encerrado lá embaixo, qualquer nova alteração será aplicada.
- let radiusx = Math.abs(parseInt((e.offsetX - this.prevX)/2)); -> Calcula o tamanho do RAIO horizontal, por esse razão é a posição atual do mouse - a poisção inicial divido por 2. Como trata-se de uma distância o valor é absoluto.
- let radiusy = Math.abs(parseInt((e.offsetY - this.prevY)/2)); -> O mesmo acima se aplica.
- let x = parseInt((e.offsetX - this.prevX)/2) + this.prevX; -> Se refere a posição atual do mouse no canto mais extremo em relação onde a elipse foi desenhada. Consiste no raio somado com a posição inicial, garantindo que a elipse esteja contida em um "retângulo" com a diagonal referente ao ponto inicial do mouse, e sua posição atual.
- let y = parseInt((e.offsetY - this.prevY)/2) + this.prevY; -> O mesmo acima se aplica.
- ctx.ellipse(x, y, radiusx, radiusy, 0, 0, Math.PI * 2); -> Define a elipse que será desenhada, os ângulos inicial e final permanecem zero, do contrário a elipse terá um comportamento de deslize diagonal e não desejado. O Math.PI * 2 é o comprimento do arco, e possui esse valor pois desejamos uma volta completa.
- ctx.closePath(); -> A linha mais importante neste código em par com a linha ctx.beginPath();, evita que várias cópias sucessivas da elipse sejam desenhadas, e não tenha-se a ilusão de deslize.
Elipse Cheia
class EllipseFill extends Ellipse {
iniciar(ctx) {
ctx.strokeStyle = this.brush_color;
ctx.fillStyle = this.brush_color;
ctx.lineWidth = 1;
}
aplicar(ctx, e) {
ctx.beginPath();
ctx.putImageData(this.snapshot, 0, 0);
let radiusx = Math.abs(parseInt((e.offsetX - this.prevX)/2));
let radiusy = Math.abs(parseInt((e.offsetY - this.prevY)/2));
let x = parseInt((e.offsetX - this.prevX)/2) + this.prevX;
let y = parseInt((e.offsetY - this.prevY)/2) + this.prevY;
ctx.ellipse(x, y, radiusx, radiusy, 0, 0, Math.PI * 2);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
}
Poucos detalhes diferenciam a Elipse Cheia da Elipse vazada, mas são detalhes importantes.
- iniciar(ctx):
- ctx.fillStyle = this.brush_color; -> Como vamos aplicar um fill, devemos chamar este método. Terá a mesma cor que as bordas (strokeStyle).
- ctx.lineWidth = 1; -> O tamanho do pincel aqui é 1 independente do tamanho escolhido pelo usuário. Caso contraŕio, a elipse cheia ficaria com bordas muito grandes, e ao tentar desenhar uma elipse bem fina, por exemplo, isso não seria possível. Essa linha existe simplesmente em razão da intuição do usuário. O comportamento visto em um retângulo cheio, seja o mesmo visto em uma elipse cheia.
- aplicar(ctx, e):
- ctx.fill(); -> Aplica a elipse com preenchimento.
Linha
class Line extends slidingTools {
iniciar(ctx) {
ctx.strokeStyle = this.brush_color;
ctx.lineWidth = this.brush_size;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
aplicar(ctx, e) {
ctx.beginPath();
ctx.putImageData(this.snapshot, 0, 0);
ctx.moveTo(this.prevX, this.prevY); //Ponto de origem é fixo
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
}
No caso da linha, retornamos com a convenção de linhas com pontas arredondadas. Queremos também a ilusão de algo deslizando pelo canvas.
- aplicar(ctx, e):
- ctx.beginPath(); -> No caso também é necessaŕio o uso do beginPath para evitar a criação de diversas cópias da linha enquanto ela se move pelo canvas. E o ponto de origem da linha será fixo.
- ctx.moveTo(this.prevX, this.prevY); -> A cada chamada do método e ínicio do Path, retornamos a origem da linha para o ponto inicial, no primeiro clique do canvas usando a ferramenta linha.
- ctx.lineTo(e.offsetX, e.offsetY); -> Outra extremidade da linha, que corresponde a posição atual do mouse no canvas.
Balde de Preenchimento
class Bucket extends Tool {
//A RESOLUÇÃO 1080 E 720 ESTÁ HARD 'CODADA' NA FUNÇÃO DO WILLIAM MALONE
setup(custom) {
this.brush_size = 1; // O tamanho da ferramenta é sempre um pixel independente do que aconteça
this.brush_color = custom["brush_color"];
this.canvas_size = custom["canvas_size"];
}
hexToRgb(hex) {
// Remover "#" se presente
hex = hex.replace(/^#/, '');
// Converter para valores de cor RGB
var bigint = parseInt(hex, 16);
var r = (bigint >> 16) & 255;
var g = (bigint >> 8) & 255;
var b = bigint & 255;
// Retornar valores de cor RGB
return { r: r, g: g, b: b };
}
matchStartColor(pixelPos) {
let r = this.colorLayer.data[pixelPos];
let g = this.colorLayer.data[pixelPos + 1];
let b = this.colorLayer.data[pixelPos + 2];
return r === this.startR && g === this.startG && b === this.startB;
}
colorPixel(pixelPos, selected_color) {
this.colorLayer.data[pixelPos] = selected_color.r;
this.colorLayer.data[pixelPos + 1] = selected_color.g;
this.colorLayer.data[pixelPos + 2] = selected_color.b;
this.colorLayer.data[pixelPos + 3] = 255;
}
//William Malone Algorithm
floodFill(startX, startY, selected_color, ctx,
newPos, x, y, pixelPos, reachLeft, reachRight) {
newPos = this.pixelStack.pop();
x = newPos[0];
y = newPos[1];
//get current pixel position
pixelPos = (y * 1080 + x) * 4;
// Go up as long as the color matches and are inside the canvas
while (y >= 0 && this.matchStartColor(pixelPos, selected_color)) {
y--;
pixelPos -= 1080 * 4;
}
//Don't overextend
pixelPos += 1080 * 4;
y++;
reachLeft = false;
reachRight = false;
// Go down as long as the color matches and in inside the canvas
while (y < 720 && this.matchStartColor(pixelPos, selected_color)) {
this.colorPixel(pixelPos, selected_color);
if (x > 0) {
if (this.matchStartColor(pixelPos - 4, selected_color)) {
if (!reachLeft) {
//Add pixel to stack
this.pixelStack.push([x - 1, y]);
reachLeft = true;
}
} else if (reachLeft) {
reachLeft = false;
}
}
if (x < 1080 - 1) {
if (this.matchStartColor(pixelPos + 4, selected_color)) {
if (!reachRight) {
//Add pixel to stack
this.pixelStack.push([x + 1, y]);
reachRight = true;
}
} else if (reachRight) {
reachRight = false;
}
}
y++;
pixelPos += 1080 * 4;
}
//recursive until no more pixels to change
if (this.pixelStack.length) {
this.floodFill(startX, startY, selected_color, ctx,
newPos, x, y, pixelPos, reachLeft, reachRight);
}
}
actionFill(startX, startY, selected_color, ctx) {
this.colorLayer = ctx.getImageData(
0,
0,
1080,
720
);
let startPos = (startY * 1080 + startX) * 4;
//Acredito que pega a cor específica
this.startR = this.colorLayer.data[startPos];
this.startG = this.colorLayer.data[startPos + 1];
this.startB = this.colorLayer.data[startPos + 2];
if (selected_color.r === this.startR &&
selected_color.g === this.startG &&
selected_color.b === this.startB) return;
this.pixelStack = [[startX,startY]];
let newPos, x, y, pixelPos, reachLeft, reachRight;
this.floodFill(startX, startY, selected_color, ctx,
newPos, x, y, pixelPos, reachLeft, reachRight);
ctx.putImageData(this.colorLayer, 0, 0);
}
// Aqui de fato é onde a mágica acontece, a aplicação da ferramenta em um clique único
iniciar(ctx, e) {
let selected_color = this.hexToRgb(this.brush_color); //Cor selecionada mas em formato RGB
this.actionFill(e.offsetX, e.offsetY, selected_color, ctx);
}
aplicar(ctx, e) { return; }
}
Ao contrário das outras ferramentas vamos analisar esta seguindo a ordem lógica que seria aplicada no código.
- setup(custom)
- Simplesmente recuperamos dados que serão úteis durante a aplicação do algoritmo de flood Fill.
- iniciar(ctx)
- let selected_color = this.hexToRgb(this.brush_color); -> Convertemos a cor selecionada para o padrão rgb, pois isso tornará menos custoso a comparação de cores, e também é mais compatível com o algoritmo de preenchimento que será utilizado.
- this.actionFill(e.offsetX, e.offsetY, selected_color, ctx); -> Chamamos o método que iniciará todo o processo de preenchimento, inicializará as variáveis necessárias.
- actionFill(startX, startY, selected_color, ctx)
- this.colorLayer = ctx.getImageData[...] -> Recebe a imagem atual do canvas
- let startPos = (startY * 1080 + startX) * 4; -> Calcula a posição de onde está aquele pixel específico no array da colorLayer.
- this.startR = this.colorLayer.data[startPos];
this.startG = this.colorLayer.data[startPos + 1];
this.startB = this.colorLayer.data[startPos + 2];
-> Valores que serão utilizados para comparar com os pixels posteriormente - if (selected_color.r === this.startR &&[...]) -> Verifica se o pixel clicado já tem a mesma cor que a cor selecionada no pincel, nesse caso não há nada a ser feito.
- this.pixelStack = [[startX,startY]];
let newPos, x, y, pixelPos, reachLeft, reachRight; -> Inicia o stack de pixels que será utilizado no algoritmo e outras variáveis pertinentes. - this.floodFill(startX, startY, selected_color, ctx, newPos, x, y, pixelPos, reachLeft, reachRight); -> Chamamos o algoritmo que aplicará o preenchimento recursivamente.
- ctx.putImageData(this.colorLayer, 0, 0); -> Aplicamos ao nosso canvas a nova layer com o preenchimento concluído.
- floodFill(startX, startY, selected_color, ctx,
newPos, x, y, pixelPos, reachLeft, reachRight) {[...]}
-> Esse incrível algoritmo foi escrito por William Malone. Os detalhes do seu funcionamento estão no repositório github/williammalone/HTML5-Paint-Bucket-Tool . - matchStartColor(pixelPos) -> Verifica se as cores dos pixels são iguais.
- colorPixel(pixelPos, selected_color) -> Preenche um pixel com a cor selecionada.
Ações (Desfazer, Refazer e Limpar Desenho)
SimpleDo
SimpleDo é uma classe que gerencia as duas ações principais no desenho: Desfazer (undo) e Refazer (redo). A classe fica armazenada no documento actions.js e é chamada no documento html. Funciona a partir de armazenamento da imagem, estado do canvas a cada clique nele que o usuário realizar. Essa imagens ficam armazenadas em um stack, a qual retornar vai andando para a esquerda puxando as imagens (ações) mais antigas, e se andar para direita as mais novas. Isso tudo com um limite, se o stack atingir o limite máximo de tamanho definido, o estado mais antigo é removido e o mais novo é inserido naturalmente.
class SimpleDo {
//Classe abstrata para Redo e Undo, mas só permite um voltar e um avançar
//Imagina fazer uma ferramenta de voltar e ir que desse para eu navegar pelas várias partes do desenho, e ir apagando o que eu fiz?
//Tipo uma árvorem em que os novos galhos são retornos a uma posição original, na qual uma nova ação diferente é feita, e gera uma ramificação
constructor(maxsize, canvasState) {
this.maxsize = maxsize - 1; //Tamanho máximo do stack
this.stack = [canvasState];
this.stackPos = 0; //Onde estou no stack, inicialmente se refere ao tamanho zero
this.action = false;
}
undo (ctx) { //Conforme vou clicando eu vou voltando
this.stackPos -= 1; //Quando eu clicar, vou avaliar se estou indo para uma posição válida no stack
if (this.stackPos >= 0) {
ctx.putImageData(this.stack[this.stackPos], 0, 0);
//Habilito a função refazer de novo
}
else {
this.stackPos += 1; //Se não der certo, eu retorno para a posição que estava antes
//return false; //E o botão Desfazer fica desabilitado
}
}
redo (ctx) {
this.stackPos += 1; //Quando eu clicar, vou avaliar se estou indo para uma posição válida no stack
if (this.stackPos < this.stack.length) {
ctx.putImageData(this.stack[this.stackPos], 0, 0);
//Habilito a função desfazer de novo
}
else {
this.stackPos -= 1; //Se não der certo, eu retorno para a posição que estava antes
//return false; //E o botão Refazer fica desabilitado
}
}
pushAction (canvasState) { //Uma ação foi feita no canvas
if (!this.action) return;
//O mais um é pq o splice começa remover incluindo a própria posição
this.stack.splice(this.stackPos + 1); //Isso aqui tem que vir primeiro, pois podemos estar iniciando uma nova ação do meio do stack, então removeremos as ações anteriores a partir dali
//Para que a útima seja mesmo a última que fizemos
//Se o stack atingir o tamanho máximo, eu apago a ação mais antiga
if (this.stack.length > this.maxsize) this.stack.shift();
//Coloco a ação mais nova
this.stack.push(canvasState);
//Removo o resto do array se tiver, a partir da posição onde estou
//Vou me movimentando junto com o array, mas não extrapolo o limite
if(this.stackPos < this.maxsize) this.stackPos += 1;
this.action = false; //Retorna a variável para false, para esperar um clique no canvas para que seja true de novo.
}
}
- constructor(maxsize, canvasState)
- this.maxsize = maxsize; -> Define o tamanho do stack, ou seja, se for 10, a posição máxima será 9 (0-9) são 10 inserções possíveis.
- this.stack = [canvasState]; -> Inicialmente a primeira ação é o canvas vazio, qualquer ação adicionada depois daquela quando clicar voltar, pode retornar ao canvas vazio.
- this.stackPos = 0; -> Posição inicial no stack, começamos na posição 0
- this.action = false; -> Nada foi feito ainda. Inicialmente o usuário não desenhou nada. Essa variável é importante para saber quando o usuário realmente clicou para desenhar, ou simplesmente colocou o mouse em cima do canvas e removeu.
- undo(ctx) -> recebemos o ctx para colocar os estados armazenados no canvas
- this.stackPos -= 1; -> Retornamos uma posição no stack
- if (this.stackPos >= 0) -> Se a posição não for menor que 0, significa que é um estado válido.
- ctx.putImageData(this.stack[this.stackPos], 0, 0); -> Então recupero aquele estado e desenho no ctx, canvas.
- DO CONTRÁRIO: this.stackPos += 1; -> A posição era menor que zero, inválida, e retornamos para a posição original.
- redo(ctx) -> recebemos o ctx para colocar os estados armazenados no canvas
- this.stackPos += 1; -> Avançamos uma posição no stack
- if (this.stackPos < this.stack.length) -> Se a posição for menor que o tamanho do canvas (se está pelo menos até a posição máxima)
- ctx.putImageData(this.stack[this.stackPos], 0, 0); -> Então recupero aquele estado e desenho no ctx, canvas.
- DO CONTRÁRIO: this.stackPos -= 1; -> A posição era maior do que possível, inválida, e retornamos para a posição original.
- pushAction(canvasState) -> Empurra os estados, as novas ações dentro do stack (sempre na última posição)
- if (!this.action) return; -> Se não houve ação de desenho no canvas, o método retorna e não faz nada. Existe devido a maneira que se manipula os eventos de desenho no canvas de maneira assíncrona com o jQuery.
- this.stack.splice(this.stackPos + 1); -> Vamos supor que retornamos algumas ações no stack, e fizemos uma nova ação no meio do stack. Todo o resto do stack (à direita) não faz sentido mais ser recuperado e é descartado. Se estivermos na última posição do stack, que corresponde ao tamanho dele - 1, essa ação não faz nada. Pois não tem o quê ser removido a direita.
- if (this.stack.length > this.maxsize) this.stack.shift(); -> Se o tamanho máximo do stack for extrapolado, então removemos a ação mais antiga e colocamos a nova.
- if(this.stackPos < this.maxsize) this.stackPos += 1; -> Nossa ação atual cresce conforme o array também cresce, assim a mais nova ação corresponde a nossa posição atual enquanto não chamarmos o undo e redo.
- this.action = false; -> A ação foi concluída e retornamos essa variável para o padrão. Para tornar true novamente o usuário deve de fato clicar no canvas aplicando uma ferramenta (limpar o desenho mais de uma vez não conta).