Projeto: Um Editor de Pixel Art
Olho para as muitas cores diante de mim. Olho para minha tela em branco. Então, tento aplicar cores como palavras que formam poemas, como notas que formam música.

O material dos capítulos anteriores lhe dá todos os elementos necessários para construir uma aplicação web básica. Neste capítulo, faremos exatamente isso.
Nossa aplicação será um programa de desenho de pixels que permite modificar uma imagem pixel por pixel manipulando uma visão ampliada dela, mostrada como uma grade de quadrados coloridos. Você pode usar o programa para abrir arquivos de imagem, rabiscar neles com o mouse ou outro dispositivo apontador e salvá-los. É assim que ele vai parecer:

Pintar em um computador é ótimo. Você não precisa se preocupar com materiais, habilidade ou talento. É só começar a borrar e ver onde você chega.
Componentes
A interface da aplicação mostra um grande elemento <canvas> no topo, com vários campos de formulário abaixo dele. O usuário desenha na imagem selecionando uma ferramenta em um campo <select> e depois clicando, tocando ou arrastando sobre o canvas. Existem ferramentas para desenhar pixels individuais ou retângulos, para preencher uma área e para selecionar uma cor da imagem.
Vamos estruturar a interface do editor como vários componentes, objetos que são responsáveis por uma parte do DOM e que podem conter outros componentes dentro de si.
O estado da aplicação consiste na imagem atual, na ferramenta selecionada e na cor selecionada. Vamos configurar as coisas para que o estado viva em um único valor e os componentes da interface sempre baseiem sua aparência no estado atual.
Para ver por que isso é importante, vamos considerar a alternativa — distribuir partes do estado pela interface. Até certo ponto, isso é mais fácil de programar. Podemos simplesmente colocar um campo de cor e ler seu valor quando precisarmos saber a cor atual.
Mas então adicionamos o seletor de cor — uma ferramenta que permite clicar na imagem para selecionar a cor de um determinado pixel. Para manter o campo de cor mostrando a cor correta, essa ferramenta precisaria saber que o campo de cor existe e atualizá-lo sempre que escolher uma nova cor. Se você adicionar outro lugar que torne a cor visível (talvez o cursor do mouse pudesse mostrá-la), teria que atualizar seu código de mudança de cor para manter isso sincronizado também.
Na prática, isso cria um problema onde cada parte da interface precisa saber sobre todas as outras partes, o que não é muito modular. Para aplicações pequenas como a deste capítulo, isso pode não ser um problema. Para projetos maiores, pode se tornar um verdadeiro pesadelo.
Para evitar esse pesadelo por princípio, vamos ser rigorosos sobre o fluxo de dados. Existe um estado, e a interface é desenhada com base nesse estado. Um componente da interface pode responder a ações do usuário atualizando o estado, momento em que os componentes têm a chance de se sincronizar com este novo estado.
Na prática, cada componente é configurado de forma que, quando recebe um novo estado, ele também notifica seus componentes filhos, na medida em que precisem ser atualizados. Configurar isso é um pouco trabalhoso. Tornar isso mais conveniente é o principal ponto de venda de muitas bibliotecas de programação para navegador. Mas para uma aplicação pequena como esta, podemos fazer sem tal infraestrutura.
Atualizações ao estado são representadas como objetos, que chamaremos de ações. Componentes podem criar tais ações e despachar-las — dá-las a uma função central de gerenciamento de estado. Essa função calcula o próximo estado, após o que os componentes da interface se atualizam para este novo estado.
Estamos pegando a tarefa confusa de rodar uma interface de usuário e aplicando estrutura a ela. Embora as partes relacionadas ao DOM ainda estejam cheias de efeito colaterals, elas são sustentadas por uma espinha dorsal conceitualmente simples: o ciclo de atualização de estado. O estado determina a aparência do DOM, e a única forma de eventos DOM mudarem o estado é despachando ações ao estado.
Existem muitas variantes desta abordagem, cada uma com seus próprios benefícios e problemas, mas a ideia central é a mesma: mudanças de estado devem passar por um único canal bem definido, não acontecer em todo lugar.
Nossos componentes serão classes conformando a uma interface. Seu construtor recebe um estado — que pode ser o estado completo da aplicação ou algum valor menor se não precisar de acesso a tudo — e usa isso para construir uma propriedade dom. Este é o elemento DOM que representa o componente. A maioria dos construtores também receberá alguns outros valores que não mudam ao longo do tempo, como a função que podem usar para despachar uma ação.
Cada componente tem um método syncState que é usado para sincronizá-lo com um novo valor de estado. O método recebe um argumento, o estado, que é do mesmo tipo que o primeiro argumento de seu construtor.
O estado
O estado da aplicação será um objeto com propriedades picture, tool e color. A imagem é em si um objeto que armazena a largura, altura e conteúdo de pixels da imagem. Os pixels são armazenados em um único array, linha por linha, de cima para baixo.
class Picture { constructor(width, height, pixels) { this.width = width; this.height = height; this.pixels = pixels; } static empty(width, height, color) { let pixels = new Array(width * height).fill(color); return new Picture(width, height, pixels); } pixel(x, y) { return this.pixels[x + y * this.width]; } draw(pixels) { let copy = this.pixels.slice(); for (let {x, y, color} of pixels) { copy[x + y * this.width] = color; } return new Picture(this.width, this.height, copy); } }
Queremos poder tratar uma imagem como um valor imutável, por razões às quais voltaremos mais adiante no capítulo. Mas também precisamos às vezes atualizar um monte de pixels de uma vez. Para poder fazer isso, a classe tem um método draw que espera um array de pixels atualizados — objetos com propriedades x, y e color — e cria uma nova imagem com esses pixels sobrescritos. Este método usa slice sem argumentos para copiar o array de pixels inteiro — o início do slice é padrão 0, e o fim é padrão o comprimento do array.
O método empty usa duas funcionalidades de array que não vimos antes. O construtor Array pode ser chamado com um número para criar um array vazio do comprimento dado. O método fill pode então ser usado para preencher este array com um valor dado. Estes são usados para criar um array no qual todos os pixels têm a mesma cor.
Cores são armazenadas como strings contendo código de cors CSS tradicionais compostos por um sinal de hash (#) seguido por seis dígitos hexadecimais (base 16) — dois para o componente vermelho, dois para o componente verde e dois para o componente azul. Esta é uma forma um tanto críptica e inconveniente de escrever cores, mas é o formato que o campo de entrada de cor HTML usa, e pode ser usado na propriedade fillStyle de um contexto de desenho canvas, então para as formas como usaremos cores neste programa, é prático o suficiente.
Preto, onde todos os componentes são zero, é escrito "#000000", e rosa brilhante se parece com "#ff00ff", onde os componentes vermelho e azul têm o valor máximo de 255, escrito ff em dígitos hexadecimais (que usam a a f para representar dígitos 10 a 15).
Vamos permitir que a interface despache ações como objetos cujas propriedades sobrescrevem as propriedades do estado anterior. O campo de cor, quando o usuário o muda, poderia despachar um objeto como {color: field., a partir do qual esta função de atualização pode calcular um novo estado.
function updateState(state, action) { return {...state, ...action}; }
Este padrão, no qual spread de objeto é usado para primeiro adicionar as propriedades de um objeto existente e então sobrescrever algumas delas, é comum em código JavaScript que usa objetos imutáveis.
Construção de DOM
Uma das principais coisas que componentes de interface fazem é criar estrutura DOM. Novamente não queremos usar os métodos verbosos do DOM diretamente para isso, então aqui está uma versão ligeiramente expandida da função elt:
function elt(type, props, ...children) { let dom = document.createElement(type); if (props) Object.assign(dom, props); for (let child of children) { if (typeof child != "string") dom.appendChild(child); else dom.appendChild(document.createTextNode(child)); } return dom; }
A principal diferença entre esta versão e a que usamos no Capítulo 16 é que ela atribui propriedades a nós DOM, não atributos. Isso significa que não podemos usá-la para definir atributos arbitrários, mas podemos usá-la para definir propriedades cujo valor não é uma string, como onclick, que pode ser definido como uma função para registrar um manipulador de evento de clique.
Isso permite este estilo conveniente para registrar manipuladores de eventos:
<body> <script> document.body.appendChild(elt("button", { onclick: () => console.log("click") }, "The button")); </script> </body>
O canvas
O primeiro componente que vamos definir é a parte da interface que exibe a imagem como uma grade de caixas coloridas. Este componente é responsável por duas coisas: mostrar uma imagem e comunicar eventos de ponteiro nessa imagem ao resto da aplicação.
Portanto, podemos defini-lo como um componente que só sabe sobre a imagem atual, não o estado completo da aplicação. Porque ele não sabe como a aplicação funciona no geral, não pode despachar ações diretamente. Em vez disso, ao responder a eventos de ponteiro, ele chama uma função de callback fornecida pelo código que o criou, que tratará as partes específicas da aplicação.
const scale = 10; class PictureCanvas { constructor(picture, pointerDown) { this.dom = elt("canvas", { onmousedown: event => this.mouse(event, pointerDown), ontouchstart: event => this.touch(event, pointerDown) }); this.syncState(picture); } syncState(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); } }
Desenhamos cada pixel como um quadrado de 10 por 10, conforme determinado pela constante scale. Para evitar trabalho desnecessário, o componente mantém registro de sua imagem atual e só faz um redesenho quando syncState recebe uma nova imagem.
A função de desenho real define o tamanho do canvas com base na escala e no tamanho da imagem e o preenche com uma série de quadrados, um para cada pixel.
function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } }
Quando o botão esquerdo do mouse é pressionado enquanto o mouse está sobre o canvas da imagem, o componente chama o callback pointerDown, dando-lhe a posição do pixel que foi clicado — em coordenadas da imagem. Isso será usado para implementar a interação do mouse com a imagem. O callback pode retornar outra função de callback para ser notificada quando o ponteiro é movido para um pixel diferente enquanto o botão está pressionado.
PictureCanvas.prototype.mouse = function(downEvent, onDown) { if (downEvent.button != 0) return; let pos = pointerPosition(downEvent, this.dom); let onMove = onDown(pos); if (!onMove) return; let move = moveEvent => { if (moveEvent.buttons == 0) { this.dom.removeEventListener("mousemove", move); } else { let newPos = pointerPosition(moveEvent, this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); } }; this.dom.addEventListener("mousemove", move); }; function pointerPosition(pos, domNode) { let rect = domNode.getBoundingClientRect(); return {x: Math.floor((pos.clientX - rect.left) / scale), y: Math.floor((pos.clientY - rect.top) / scale)}; }
Como sabemos o tamanho dos pixels e podemos usar getBoundingClientRect para encontrar a posição do canvas na tela, é possível converter de coordenadas de eventos do mouse (clientX e clientY) para coordenadas da imagem. Estas são sempre arredondadas para baixo para que se refiram a um pixel específico.
Com eventos de toque, temos que fazer algo semelhante, mas usando eventos diferentes e nos certificando de chamar preventDefault no evento "touchstart" para prevenir deslizamento.
PictureCanvas.prototype.touch = function(startEvent, onDown) { let pos = pointerPosition(startEvent.touches[0], this.dom); let onMove = onDown(pos); startEvent.preventDefault(); if (!onMove) return; let move = moveEvent => { let newPos = pointerPosition(moveEvent.touches[0], this.dom); if (newPos.x == pos.x && newPos.y == pos.y) return; pos = newPos; onMove(newPos); }; let end = () => { this.dom.removeEventListener("touchmove", move); this.dom.removeEventListener("touchend", end); }; this.dom.addEventListener("touchmove", move); this.dom.addEventListener("touchend", end); };
Para eventos de toque, clientX e clientY não estão disponíveis diretamente no objeto do evento, mas podemos usar as coordenadas do primeiro objeto de toque na propriedade touches.
A aplicação
Para tornar possível construir a aplicação peça por peça, vamos implementar o componente principal como um invólucro ao redor de um canvas de imagem e um conjunto dinâmico de ferramentas e controles que passamos ao seu construtor.
Os controles são os elementos de interface que aparecem abaixo da imagem. Eles serão fornecidos como um array de construtores de componentes.
As ferramentas fazem coisas como desenhar pixels ou preencher uma área. A aplicação mostra o conjunto de ferramentas disponíveis como um campo <select>. A ferramenta atualmente selecionada determina o que acontece quando o usuário interage com a imagem com um dispositivo apontador. O conjunto de ferramentas disponíveis é fornecido como um objeto que mapeia os nomes que aparecem no campo dropdown para funções que implementam as ferramentas. Tais funções recebem uma posição na imagem, um estado atual da aplicação e uma função dispatch como argumentos. Elas podem retornar uma função de manipulador de movimento que é chamada com uma nova posição e um estado atual quando o ponteiro se move para um pixel diferente.
class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) return pos => onMove(pos, this.state); }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } }
O manipulador de ponteiro dado a PictureCanvas chama a ferramenta atualmente selecionada com os argumentos apropriados e, se isso retornar um manipulador de movimento, o adapta para também receber o estado.
Todos os controles são construídos e armazenados em this.controls para que possam ser atualizados quando o estado da aplicação muda. A chamada a reduce introduz espaços entre os elementos DOM dos controles. Dessa forma, eles não parecem tão apertados juntos.
O primeiro controle é o menu de seleção de ferramenta. Ele cria um elemento <select> com uma opção para cada ferramenta e configura um manipulador de evento "change" que atualiza o estado da aplicação quando o usuário seleciona uma ferramenta diferente.
class ToolSelect { constructor(state, {tools, dispatch}) { this.select = elt("select", { onchange: () => dispatch({tool: this.select.value}) }, ...Object.keys(tools).map(name => elt("option", { selected: name == state.tool }, name))); this.dom = elt("label", null, "🖌 Tool: ", this.select); } syncState(state) { this.select.value = state.tool; } }
Ao envolver o texto do rótulo e o campo em um elemento <label>, dizemos ao navegador que o rótulo pertence àquele campo, para que você possa, por exemplo, clicar no rótulo para focar o campo.
Também precisamos poder mudar a cor, então vamos adicionar um controle para isso. Um elemento HTML <input> com um atributo type de color nos dá um campo de formulário especializado em selecionar cores. O valor de tal campo é sempre um código de cor CSS no formato "#RRGGBB" (vermelho, verde e azul, dois dígitos por cor). O navegador mostrará uma interface de seletor de cor quando o usuário interagir com ele.
Este controle cria tal campo e o conecta para ficar sincronizado com a propriedade color do estado da aplicação.
class ColorSelect { constructor(state, {dispatch}) { this.input = elt("input", { type: "color", value: state.color, onchange: () => dispatch({color: this.input.value}) }); this.dom = elt("label", null, "🎨 Color: ", this.input); } syncState(state) { this.input.value = state.color; } }
Ferramentas de desenho
Antes de podermos desenhar qualquer coisa, precisamos implementar as ferramentas que controlarão a funcionalidade de eventos de mouse ou toque no canvas.
A ferramenta mais básica é a ferramenta de desenho, que muda qualquer pixel que você clicar ou tocar para a cor atualmente selecionada. Ela despacha uma ação que atualiza a imagem para uma versão na qual o pixel apontado recebe a cor atualmente selecionada.
function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; }
A função chama imediatamente a função drawPixel mas também a retorna para que seja chamada novamente para pixels recém-tocados quando o usuário arrasta ou desliza sobre a imagem.
Para desenhar formas maiores, pode ser útil criar retângulos rapidamente. A ferramenta rectangle desenha um retângulo entre o ponto onde você começa a arrastar e o ponto para o qual arrasta.
function rectangle(start, state, dispatch) { function drawRectangle(pos) { let xStart = Math.min(start.x, pos.x); let yStart = Math.min(start.y, pos.y); let xEnd = Math.max(start.x, pos.x); let yEnd = Math.max(start.y, pos.y); let drawn = []; for (let y = yStart; y <= yEnd; y++) { for (let x = xStart; x <= xEnd; x++) { drawn.push({x, y, color: state.color}); } } dispatch({picture: state.picture.draw(drawn)}); } drawRectangle(start); return drawRectangle; }
Um detalhe importante nesta implementação é que ao arrastar, o retângulo é redesenhado na imagem a partir do estado original. Dessa forma, você pode aumentar e diminuir o retângulo novamente enquanto o cria, sem os retângulos intermediários ficando na imagem final. Esta é uma das razões pelas quais objetos de imagem imutáveis são úteis — veremos outra razão mais adiante.
Implementar o preenchimento é um pouco mais complexo. Esta é uma ferramenta que preenche o pixel sob o ponteiro e todos os pixels adjacentes que têm a mesma cor. “Adjacente” significa diretamente adjacente horizontal ou verticalmente, não diagonalmente. Esta imagem ilustra o conjunto de pixels coloridos quando a ferramenta de preenchimento é usada no pixel marcado:
Curiosamente, a forma como faremos isso se parece um pouco com o código de busca de caminhos do Capítulo 7. Enquanto aquele código buscava em um grafo para encontrar uma rota, este código busca em uma grade para encontrar todos os pixels “conectados”. O problema de manter o rastreamento de um conjunto ramificado de possíveis rotas é semelhante.
const around = [{dx: -1, dy: 0}, {dx: 1, dy: 0}, {dx: 0, dy: -1}, {dx: 0, dy: 1}]; function fill({x, y}, state, dispatch) { let targetColor = state.picture.pixel(x, y); let drawn = [{x, y, color: state.color}]; let visited = new Set(); for (let done = 0; done < drawn.length; done++) { for (let {dx, dy} of around) { let x = drawn[done].x + dx, y = drawn[done].y + dy; if (x >= 0 && x < state.picture.width && y >= 0 && y < state.picture.height && !visited.has(x + "," + y) && state.picture.pixel(x, y) == targetColor) { drawn.push({x, y, color: state.color}); visited.add(x + "," + y); } } } dispatch({picture: state.picture.draw(drawn)}); }
O array de pixels desenhados funciona como a lista de trabalho da função. Para cada pixel alcançado, temos que ver se algum pixel adjacente tem a mesma cor e não foi já pintado. O contador do loop fica atrás do comprimento do array drawn conforme novos pixels são adicionados. Quaisquer pixels à frente dele ainda precisam ser explorados. Quando ele alcança o comprimento, não restam pixels inexplorados, e a função termina.
A ferramenta final é um seletor de cor, que permite apontar para uma cor na imagem para usá-la como a cor de desenho atual.
function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); }
Agora podemos testar nossa aplicação!
<div></div> <script> let state = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0") }; let app = new PixelEditor(state, { tools: {draw, fill, rectangle, pick}, controls: [ToolSelect, ColorSelect], dispatch(action) { state = updateState(state, action); app.syncState(state); } }); document.querySelector("div").appendChild(app.dom); </script>
Salvando e carregando
Quando tivermos desenhado nossa obra-prima, vamos querer salvá-la para depois. Devemos adicionar um botão para baixar a imagem atual como arquivo de imagem. Este controle fornece esse botão:
class SaveButton { constructor(state) { this.picture = state.picture; this.dom = elt("button", { onclick: () => this.save() }, "💾 Save"); } save() { let canvas = elt("canvas"); drawPicture(this.picture, canvas, 1); let link = elt("a", { href: canvas.toDataURL(), download: "pixelart.png" }); document.body.appendChild(link); link.click(); link.remove(); } syncState(state) { this.picture = state.picture; } }
O componente mantém o rastreamento da imagem atual para que possa acessá-la ao salvar. Para criar o arquivo de imagem, ele usa um elemento <canvas> no qual desenha a imagem (em uma escala de um pixel por pixel).
O método toDataURL em um elemento canvas cria uma URL que usa o esquema data:. Diferente de URLs http: e https:, URLs de dados contêm o recurso inteiro na URL. Elas geralmente são muito longas, mas nos permitem criar links funcionais para imagens arbitrárias, aqui mesmo no navegador.
Para realmente fazer o navegador baixar a imagem, então criamos um elemento de link que aponta para esta URL e tem um atributo download. Tais links, quando clicados, fazem o navegador mostrar um diálogo de salvar arquivo. Adicionamos esse link ao documento, simulamos um clique nele e o removemos novamente. Você pode fazer muita coisa com tecnologia de navegador, mas às vezes a forma de fazer é bastante estranha.
E fica pior. Também vamos querer poder carregar arquivos de imagem existentes em nossa aplicação. Para isso, novamente definimos um componente de botão.
class LoadButton { constructor(_, {dispatch}) { this.dom = elt("button", { onclick: () => startLoad(dispatch) }, "📁 Load"); } syncState() {} } function startLoad(dispatch) { let input = elt("input", { type: "file", onchange: () => finishLoad(input.files[0], dispatch) }); document.body.appendChild(input); input.click(); input.remove(); }
Para acessar um arquivo no computador do usuário, precisamos que o usuário selecione o arquivo através de um campo de entrada de arquivo. Mas não queremos que o botão de carregar pareça um campo de entrada de arquivo, então criamos o campo de entrada de arquivo quando o botão é clicado e então fingimos que esse campo de entrada de arquivo foi clicado.
Quando o usuário selecionou um arquivo, podemos usar FileReader para acessar seu conteúdo, novamente como uma URL de dados. Essa URL pode ser usada para criar um elemento <img>, mas como não podemos acessar diretamente os pixels em tal imagem, não podemos criar um objeto Picture a partir dela.
function finishLoad(file, dispatch) { if (file == null) return; let reader = new FileReader(); reader.addEventListener("load", () => { let image = elt("img", { onload: () => dispatch({ picture: pictureFromImage(image) }), src: reader.result }); }); reader.readAsDataURL(file); }
Para acessar os pixels, devemos primeiro desenhar a imagem em um elemento <canvas>. O contexto do canvas tem um método getImageData que permite a um script ler seus pixels. Então, uma vez que a imagem está no canvas, podemos acessá-la e construir um objeto Picture.
function pictureFromImage(image) { let width = Math.min(100, image.width); let height = Math.min(100, image.height); let canvas = elt("canvas", {width, height}); let cx = canvas.getContext("2d"); cx.drawImage(image, 0, 0); let pixels = []; let {data} = cx.getImageData(0, 0, width, height); function hex(n) { return n.toString(16).padStart(2, "0"); } for (let i = 0; i < data.length; i += 4) { let [r, g, b] = data.slice(i, i + 3); pixels.push("#" + hex(r) + hex(g) + hex(b)); } return new Picture(width, height, pixels); }
Vamos limitar o tamanho das imagens a 100 por 100 pixels, já que qualquer coisa maior vai parecer enorme em nossa exibição e pode desacelerar a interface.
A propriedade data do objeto retornado por getImageData é um array de componentes de cor. Para cada pixel no retângulo especificado pelos argumentos, ela contém quatro valores que representam os componentes vermelho, verde, azul e alfa da cor do pixel, como números entre 0 e 255. A parte alfa representa opacidade — quando é 0, o pixel é totalmente transparente, e quando é 255, é totalmente opaco. Para nosso propósito, podemos ignorá-la.
Os dois dígitos hexadecimais por componente, como usados em nossa notação de cor, correspondem precisamente ao intervalo de 0 a 255 — dois dígitos de base 16 podem expressar 162 = 256 números diferentes. O método toString dos números pode receber uma base como argumento, então n.toString(16) produzirá uma representação em string em base 16. Temos que nos certificar de que cada número ocupe dois dígitos, então a função auxiliar hex chama padStart para adicionar um 0 inicial quando necessário.
Agora podemos carregar e salvar! Resta apenas mais um recurso antes de terminarmos.
Histórico de desfazer
Metade do processo de edição é cometer pequenos erros e corrigi-los, então um recurso importante em um programa de desenho é um histórico de desfazer.
Para poder desfazer mudanças, precisamos armazenar versões anteriores da imagem. Como as imagens são valores imutáveis, isso é fácil. Mas requer um campo adicional no estado da aplicação.
Vamos adicionar um array done para manter versões anteriores da imagem. Manter esta propriedade requer uma função de atualização de estado mais complicada que adiciona imagens ao array.
Não queremos armazenar toda mudança — apenas mudanças que estejam separadas por uma certa quantidade de tempo. Para poder fazer isso, vamos precisar de uma segunda propriedade, doneAt, para rastrear o momento em que armazenamos pela última vez uma imagem no histórico.
function historyUpdateState(state, action) { if (action.undo == true) { if (state.done.length == 0) return state; return { ...state, picture: state.done[0], done: state.done.slice(1), doneAt: 0 }; } else if (action.picture && state.doneAt < Date.now() - 1000) { return { ...state, ...action, done: [state.picture, ...state.done], doneAt: Date.now() }; } else { return {...state, ...action}; } }
Quando a ação é uma ação de desfazer, a função pega a imagem mais recente do histórico e a torna a imagem atual. Ela define doneAt como zero para que a próxima mudança seja garantida de armazenar a imagem de volta no histórico, permitindo que você reverta para ela outra vez se quiser.
Caso contrário, se a ação contém uma nova imagem e a última vez que armazenamos algo foi há mais de um segundo (1000 milissegundos), as propriedades done e doneAt são atualizadas para armazenar a imagem anterior.
O componente do botão de desfazer não faz muito. Ele despacha ações de desfazer quando clicado e se desabilita quando não há nada para desfazer.
class UndoButton { constructor(state, {dispatch}) { this.dom = elt("button", { onclick: () => dispatch({undo: true}), disabled: state.done.length == 0 }, "⮪ Undo"); } syncState(state) { this.dom.disabled = state.done.length == 0; } }
Vamos desenhar
Para configurar a aplicação, precisamos criar um estado, um conjunto de ferramentas, um conjunto de controles e uma função de despacho. Podemos passá-los ao construtor PixelEditor para criar o componente principal. Como vamos precisar criar vários editores nos exercícios, primeiro definimos algumas variáveis.
const startState = { tool: "draw", color: "#000000", picture: Picture.empty(60, 30, "#f0f0f0"), done: [], doneAt: 0 }; const baseTools = {draw, fill, rectangle, pick}; const baseControls = [ ToolSelect, ColorSelect, SaveButton, LoadButton, UndoButton ]; function startPixelEditor({state = startState, tools = baseTools, controls = baseControls}) { let app = new PixelEditor(state, { tools, controls, dispatch(action) { state = historyUpdateState(state, action); app.syncState(state); } }); return app.dom; }
Ao desestruturar um objeto ou array, você pode usar = após um nome de variável para dar à variável um valor padrão, que é usado quando a propriedade está faltando ou contém undefined. A função startPixelEditor faz uso disso para aceitar um objeto com várias propriedades opcionais como argumento. Se você não fornecer uma propriedade tools, por exemplo, tools será vinculada a baseTools.
É assim que colocamos um editor real na tela:
<div></div> <script> document.querySelector("div") .appendChild(startPixelEditor({})); </script>
Por que isso é tão difícil?
A tecnologia de navegador é incrível. Ela fornece um poderoso conjunto de blocos de construção de interface, formas de estilizá-los e manipulá-los, e ferramentas para inspecionar e depurar suas aplicações. O software que você escreve para o navegador pode ser executado em quase todo computador e celular do planeta.
Ao mesmo tempo, a tecnologia de navegador é ridícula. Você precisa aprender um grande número de truques bobos e fatos obscuros para dominá-la, e o modelo de programação padrão que ela fornece é tão problemático que a maioria dos programadores prefere cobri-lo com várias camadas de abstração em vez de lidar com ele diretamente.
Embora a situação esteja definitivamente melhorando, ela o faz principalmente na forma de mais elementos sendo adicionados para resolver deficiências — criando ainda mais complexidade. Um recurso usado por um milhão de websites não pode realmente ser substituído. Mesmo que pudesse, seria difícil decidir pelo que deveria ser substituído.
A tecnologia nunca existe no vácuo — somos limitados por nossas ferramentas e pelos fatores sociais, econômicos e históricos que as produziram. Isso pode ser irritante, mas é geralmente mais produtivo tentar construir um bom entendimento de como a realidade técnica existente funciona — e por que ela é do jeito que é — do que se revoltar contra ela ou esperar por outra realidade.
Novas abstrações podem ser úteis. O modelo de componentes e a convenção de fluxo de dados que usei neste capítulo é uma forma rudimentar disso. Como mencionado, existem bibliotecas que tentam tornar a programação de interface de usuário mais agradável. No momento da escrita, React e Svelte são escolhas populares, mas existe toda uma indústria artesanal de tais frameworks. Se você está interessado em programar aplicações web, recomendo investigar alguns deles para entender como funcionam e quais benefícios fornecem.
Exercícios
Ainda há espaço para melhorias em nosso programa. Vamos adicionar mais alguns recursos como exercícios.
Atalhos de teclado
Adicione atalhos de teclado à aplicação. A primeira letra do nome de uma ferramenta seleciona a ferramenta, e ctrl-Z ou command-Z ativa o desfazer.
Faça isso modificando o componente PixelEditor. Adicione uma propriedade tabIndex de 0 ao elemento <div> que o envolve para que ele possa receber foco do teclado. Note que a propriedade correspondente ao atributo tabindex é chamada tabIndex, com um I maiúsculo, e nossa função elt espera nomes de propriedades. Registre os manipuladores de eventos de tecla diretamente nesse elemento. Isso significa que você precisa clicar, tocar ou ir com tab até a aplicação antes de poder interagir com ela pelo teclado.
Lembre-se de que eventos de teclado têm propriedades ctrlKey e metaKey (para command no Mac) que você pode usar para ver se essas teclas estão pressionadas.
<div></div> <script> // A classe PixelEditor original. Estenda o construtor. class PixelEditor { constructor(state, config) { let {tools, controls, dispatch} = config; this.state = state; this.canvas = new PictureCanvas(state.picture, pos => { let tool = tools[this.state.tool]; let onMove = tool(pos, this.state, dispatch); if (onMove) { return pos => onMove(pos, this.state, dispatch); } }); this.controls = controls.map( Control => new Control(state, config)); this.dom = elt("div", {}, this.canvas.dom, elt("br"), ...this.controls.reduce( (a, c) => a.concat(" ", c.dom), [])); } syncState(state) { this.state = state; this.canvas.syncState(state.picture); for (let ctrl of this.controls) ctrl.syncState(state); } } document.querySelector("div") .appendChild(startPixelEditor({})); </script>
Display hints...
A propriedade key de eventos para teclas de letras será a letra minúscula em si, se shift não estiver pressionado. Não estamos interessados em eventos de tecla com shift aqui.
Um manipulador "keydown" pode inspecionar seu objeto de evento para ver se corresponde a algum dos atalhos. Você pode obter automaticamente a lista de primeiras letras do objeto tools para que não precise escrevê-las manualmente.
Quando o evento de tecla corresponde a um atalho, chame preventDefault nele e despache a ação apropriada.
Desenho eficiente
Durante o desenho, a maior parte do trabalho que nossa aplicação faz acontece em drawPicture. Criar um novo estado e atualizar o resto do DOM não é muito caro, mas repintar todos os pixels no canvas é bastante trabalho.
Encontre uma forma de tornar o método syncState de PictureCanvas mais rápido redesenhando apenas os pixels que realmente mudaram.
Lembre-se de que drawPicture também é usada pelo botão de salvar, então se você a modificar, certifique-se de que as mudanças não quebrem o uso antigo ou crie uma nova versão com um nome diferente.
Note também que mudar o tamanho de um elemento <canvas>, definindo suas propriedades width ou height, o limpa, tornando-o inteiramente transparente novamente.
<div></div> <script> // Mude este método PictureCanvas.prototype.syncState = function(picture) { if (this.picture == picture) return; this.picture = picture; drawPicture(this.picture, this.dom, scale); }; // Você pode querer usar ou mudar isso também function drawPicture(picture, canvas, scale) { canvas.width = picture.width * scale; canvas.height = picture.height * scale; let cx = canvas.getContext("2d"); for (let y = 0; y < picture.height; y++) { for (let x = 0; x < picture.width; x++) { cx.fillStyle = picture.pixel(x, y); cx.fillRect(x * scale, y * scale, scale, scale); } } } document.querySelector("div") .appendChild(startPixelEditor({})); </script>
Display hints...
Este exercício é um bom exemplo de como estruturas de dados imutáveis podem tornar o código mais rápido. Como temos tanto a imagem antiga quanto a nova, podemos compará-las e redesenhar apenas os pixels que mudaram de cor, economizando mais de 99 por cento do trabalho de desenho na maioria dos casos.
Você pode escrever uma nova função updatePicture ou fazer drawPicture receber um argumento extra, que pode ser undefined ou a imagem anterior. Para cada pixel, a função verifica se uma imagem anterior foi passada com a mesma cor nessa posição e pula o pixel quando esse for o caso.
Como o canvas é limpo quando mudamos seu tamanho, você também deve evitar tocar em suas propriedades width e height quando a imagem antiga e a nova têm o mesmo tamanho. Se forem diferentes, o que acontecerá quando uma nova imagem foi carregada, você pode definir a variável que guarda a imagem antiga como null depois de mudar o tamanho do canvas porque não deve pular nenhum pixel depois de mudar o tamanho do canvas.
Círculos
Defina uma ferramenta chamada circle que desenha um círculo preenchido quando você arrasta. O centro do círculo fica no ponto onde o gesto de arrastar ou toque começa, e seu raio é determinado pela distância arrastada.
<div></div> <script> function circle(pos, state, dispatch) { // Seu código aqui } let dom = startPixelEditor({ tools: {...baseTools, circle} }); document.querySelector("div").appendChild(dom); </script>
Display hints...
Você pode se inspirar na ferramenta rectangle. Como naquela ferramenta, você vai querer continuar desenhando na imagem inicial, em vez da imagem atual, quando o ponteiro se move.
Para descobrir quais pixels colorir, você pode usar o Teorema de Pitágoras. Primeiro descubra a distância entre a posição atual do ponteiro e a posição inicial pegando a raiz quadrada (Math.sqrt) da soma do quadrado (x ** 2) da diferença em coordenadas x e do quadrado da diferença em coordenadas y. Depois percorra um quadrado de pixels ao redor da posição inicial, cujos lados tenham pelo menos o dobro do raio, e colora aqueles que estão dentro do raio do círculo, novamente usando a fórmula de Pitágoras para calcular sua distância do centro.
Certifique-se de não tentar colorir pixels que estão fora dos limites da imagem.
Linhas corretas
Este é um exercício mais avançado que os três anteriores, e vai exigir que você projete uma solução para um problema não trivial. Certifique-se de ter bastante tempo e paciência antes de começar a trabalhar neste exercício, e não desanime com falhas iniciais.
Na maioria dos navegadores, quando você seleciona a ferramenta draw e arrasta rapidamente pela imagem, não obtém uma linha fechada. Em vez disso, obtém pontos com espaços entre eles porque os eventos "mousemove" ou "touchmove" não dispararam rápido o suficiente para atingir cada pixel.
Melhore a ferramenta draw para fazê-la desenhar uma linha completa. Isso significa que você tem que fazer a função manipuladora de movimento lembrar a posição anterior e conectá-la à atual.
Para fazer isso, já que os pixels podem estar a uma distância arbitrária, você terá que escrever uma função geral de desenho de linha.
Uma linha entre dois pixels é uma cadeia conectada de pixels, tão reta quanto possível, indo do início ao fim. Pixels diagonalmente adjacentes contam como conectados. Uma linha inclinada deve parecer com a imagem da esquerda, não a imagem da direita.
Finalmente, se temos código que desenha uma linha entre dois pontos arbitrários, podemos também usá-lo para definir uma ferramenta line, que desenha uma linha reta entre o início e o fim de um arraste.
<div></div> <script> // A ferramenta de desenho antiga. Reescreva isso. function draw(pos, state, dispatch) { function drawPixel({x, y}, state) { let drawn = {x, y, color: state.color}; dispatch({picture: state.picture.draw([drawn])}); } drawPixel(pos, state); return drawPixel; } function line(pos, state, dispatch) { // Seu código aqui } let dom = startPixelEditor({ tools: {draw, line, fill, rectangle, pick} }); document.querySelector("div").appendChild(dom); </script>
Display hints...
O problema de desenhar uma linha pixelada é que na verdade são quatro problemas semelhantes mas ligeiramente diferentes. Desenhar uma linha horizontal da esquerda para a direita é fácil — você itera sobre as coordenadas x e colore um pixel a cada passo. Se a linha tem uma inclinação leve (menos de 45 graus ou ¼π radianos), você pode interpolar a coordenada y ao longo da inclinação. Você ainda precisa de um pixel por posição x, com a posição y desses pixels determinada pela inclinação.
Mas assim que sua inclinação ultrapassa 45 graus, você precisa trocar a forma como trata as coordenadas. Agora você precisa de um pixel por posição y, já que a linha sobe mais do que vai para o lado. E então, quando você ultrapassa 135 graus, tem que voltar a iterar sobre as coordenadas x, mas da direita para a esquerda.
Na verdade, você não precisa escrever quatro loops. Como desenhar uma linha de A a B é o mesmo que desenhar uma linha de B a A, você pode trocar as posições inicial e final para linhas que vão da direita para a esquerda e tratá-las como indo da esquerda para a direita.
Então você precisa de dois loops diferentes. A primeira coisa que sua função de desenho de linha deve fazer é verificar se a diferença entre as coordenadas x é maior que a diferença entre as coordenadas y. Se for, esta é uma linha mais horizontalizada, e se não, uma mais verticalizada.
Certifique-se de comparar os valores absolutos das diferenças x e y, que você pode obter com Math.abs.
Uma vez que sabe ao longo de qual eixo estará iterando, pode verificar se o ponto inicial tem uma coordenada maior ao longo desse eixo que o ponto final e trocá-los se necessário. Uma forma sucinta de trocar os valores de duas variáveis em JavaScript usa atribuição por desestruturação assim:
[start, end] = [end, start];
Então você pode calcular a inclinação da linha, que determina a quantidade que a coordenada no outro eixo muda para cada passo que você dá ao longo do eixo principal. Com isso, pode rodar um loop ao longo do eixo principal enquanto também rastreia a posição correspondente no outro eixo, e pode desenhar pixels a cada iteração. Certifique-se de arredondar as coordenadas do eixo não principal, já que provavelmente serão fracionárias e o método draw não responde bem a coordenadas fracionárias.