Projeto: Um Editor de Pixel Art

Eu olho para as muitas cores diante de mim. Eu olho para minha tela em branco. Então, tento aplicar cores como palavras que moldam poemas, como notas que moldam música.

Joan Miró
Ilustração mostrando um mosaico de azulejos pretos, com recipientes de outros azulejos ao lado

O material dos capítulos anteriores fornece todos os elementos de que você precisa para construir uma aplicação web básica. Neste capítulo, faremos exatamente isso.

Nossa aplicação será um programa de desenho de pixel que permite modificar uma imagem pixel por pixel, manipulando uma visualização ampliada dela, exibida 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 será:

Captura de tela da interface do editor de pixels, com uma grade de pixels coloridos no topo e vários controles, na forma de campos HTML e botões, abaixo

Pintar em um computador é ótimo. Você não precisa se preocupar com materiais, habilidade ou talento. Basta começar a espalhar tinta e ver onde vai dar.

Componentes

A interface da aplicação mostra um grande elemento <canvas> na parte superior, com vários campos de formulário abaixo. O usuário desenha na imagem selecionando uma ferramenta em um campo <select> e depois clicando, tocando ou arrastando pelo canvas. Há ferramentas para desenhar pixels individuais ou retângulos, para preencher uma área e para selecionar uma cor da imagem.

Estruturaremos a interface do editor como vários componentes, objetos responsáveis por uma parte do DOM e que podem conter outros componentes dentro deles.

O estado da aplicação consiste na imagem atual, na ferramenta selecionada e na cor selecionada. Vamos organizar as coisas de modo que o estado fique em um único valor e que os componentes da interface sempre baseiem sua aparência no estado atual.

Para entender por que isso é importante, considere 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 cores — 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 teria que saber que o campo existe e atualizá-lo sempre que escolhesse uma nova cor. Se você adicionar outro lugar que mostre a cor (talvez o cursor do mouse), também terá que atualizar seu código de mudança de cor para manter tudo sincronizado.

Na prática, isso cria um problema em que cada parte da interface precisa conhecer todas as outras, o que não é nada modular. Para aplicações pequenas como a deste capítulo, isso pode não ser um problema. Para projetos maiores, pode virar um verdadeiro pesadelo.

Para evitar esse pesadelo por princípio, vamos ser rigorosos quanto ao 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 esse novo estado.

Na prática, cada componente é configurado de modo que, ao receber um novo estado, ele também notifica seus componentes filhos, na medida em que precisem ser atualizados. Configurar isso dá um pouco de trabalho. Tornar isso mais conveniente é o principal atrativo de muitas bibliotecas de programação para navegador. Mas, para uma aplicação pequena como esta, podemos fazer sem essa infraestrutura.

Atualizações do estado são representadas como objetos, que chamaremos de ações. Componentes podem criar essas ações e despachá-las — entregá-las a uma função central de gerenciamento de estado. Essa função calcula o próximo estado, após o qual os componentes da interface se atualizam para esse novo estado.

Estamos pegando a tarefa bagunçada 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 como o DOM se parece, e a única forma de eventos do DOM alterarem o estado é despachando ações para ele.

Existem muitas variações dessa 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 por toda parte.

Nossos componentes serão classes que seguem uma interface. Seu construtor recebe um estado — que pode ser o estado completo da aplicação ou um valor menor, se não precisar acessar tudo — e usa isso para construir uma propriedade dom. Esse é o elemento DOM que representa o componente. A maioria dos construtores também receberá 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 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 do construtor.

O estado

O estado da aplicação será um objeto com propriedades picture, tool e color. A imagem é ela própria um objeto que armazena a largura, a altura e o 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 que veremos mais adiante neste capítulo. Mas também precisamos, às vezes, atualizar um monte de pixels de uma vez. Para isso, a classe possui 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. Esse método usa slice sem argumentos para copiar todo o array de pixels — o início do slice é 0 por padrão, e o final é o comprimento do array.

O método empty usa duas funcionalidades de array que ainda não vimos. O construtor Array pode ser chamado com um número para criar um array vazio com o comprimento especificado. O método fill pode então ser usado para preencher esse array com um valor dado. Eles são usados para criar um array em que todos os pixels têm a mesma cor.

As cores são armazenadas como strings contendo códigos de cor do CSS tradicionais, formados por um sinal de cerquilha (#) seguido de seis dígitos hexadecimais (base 16) — dois para o componente vermelho, dois para o componente verde e dois para o componente azul. Essa é uma forma um tanto críptica e inconveniente de escrever cores, mas é o formato usado pelo campo de cor do HTML e pode ser usado na propriedade fillStyle de um contexto de desenho de canvas, então, para as formas como usaremos cores neste programa, é suficientemente prático.

Preto, onde todos os componentes são zero, é escrito "#000000", e rosa brilhante fica como "#ff00ff", onde os componentes vermelho e azul têm o valor máximo de 255, escrito ff em dígitos hexadecimais dígitos (que usam a a f para representar os dígitos de 10 a 15).

Permitiremos que a interface despache açãos como objetos cujas propriedades sobrescrevem as propriedades do estado anterior. O campo de cor, quando o usuário o altera, pode despachar um objeto como {color: field.value}, a partir do qual esta função de atualização pode calcular um novo estado.

function updateState(state, action) {
  return {...state, ...action};
}

Esse padrão, em que o spread de objeto é usado primeiro para adicionar as propriedades de um objeto existente e depois sobrescrever algumas delas, é comum em código JavaScript que usa objetos imutáveis.

Construção do DOM

Uma das principais tarefas dos componentes de interface é criar a estrutura do DOM. Novamente, não queremos usar diretamente os métodos verbosos do DOM 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 do 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 definida como uma função para registrar um manipulador de evento de clique.

Isso permite este estilo conveniente de registrar manipuladores de evento:

<body>
  <script>
    document.body.appendChild(elt("button", {
      onclick: () => console.log("click")
    }, "O botão"));
  </script>
</body>

O canvas

O primeiro componente que definiremos é a parte da interface que exibe a imagem como uma grade de caixas coloridas. Esse componente é responsável por duas coisas: mostrar uma imagem e comunicar evento de ponteiros nessa imagem para o restante da aplicação.

Portanto, podemos defini-lo como um componente que conhece apenas a imagem atual, não o estado completo da aplicação. Como ele não sabe como a aplicação como um todo funciona, não pode despachar açãos 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 cuidará das 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 definido pela constante scale. Para evitar trabalho desnecessário, o componente acompanha sua imagem atual e só redesenha quando syncState recebe uma nova imagem.

A função de desenho efetiva 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 cursor está sobre o canvas da imagem, o componente chama o callback pointerDown, passando a posição do pixel clicado — nas coordenadas da imagem. Isso será usado para implementar a interação com o mouse. O callback pode retornar outra função de callback para ser notificada quando o ponteiro se mover para um pixel diferente enquanto o botão estiver 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 conhecemos o tamanho dos pixels e podemos usar getBoundingClientRect para encontrar a posição do canvas na tela, é possível converter coordenadas de eventos do mouse (clientX e clientY) para coordenadas da imagem. Elas são sempre arredondadas para baixo para se referirem a um pixel específico.

Com eventos de toque, precisamos fazer algo semelhante, mas usando eventos diferentes e garantindo que chamamos preventDefault no evento "touchstart" para evitar o deslocamento.

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 de 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, implementaremos o componente principal como uma estrutura envolvendo 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 componente.

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 exibidos no menu suspenso para funções que implementam as ferramentas. Essas funções recebem uma posição na imagem, o estado atual da aplicação e uma função dispatch como argumentos. Elas podem retornar uma função manipuladora de movimento que é chamada com uma nova posição e o 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 fornecido ao PictureCanvas chama a ferramenta atualmente selecionada com os argumentos apropriados e, se ela 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 insere espaços entre os elementos DOM dos controles. Dessa forma, eles não ficam tão grudados.

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>, informamos ao navegador que o rótulo pertence àquele campo, de modo que você pode, por exemplo, clicar no rótulo para focar o campo.

Também precisamos poder alterar a cor, então vamos adicionar um controle para isso. Um elemento <input> do HTML com atributo type igual a color nos fornece um campo de formulário especializado para seleção de cores. O valor desse campo é sempre um código de cor CSS no formato "#RRGGBB" (componentes vermelho, verde e azul, dois dígitos por cor). O navegador exibirá uma interface de seletor de cores quando o usuário interagir com ele.

Este controle cria esse campo e o conecta para permanecer 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 dos eventos de mouse ou toque no canvas.

A ferramenta mais básica é a de desenho, que altera qualquer pixel em que você clica ou toca para a cor atualmente selecionada. Ela despacha uma ação que atualiza a imagem para uma versão em que o pixel apontado recebe a cor atual.

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 drawPixel, mas também a retorna para que seja chamada novamente para novos pixels tocados quando o usuário arrasta ou faz deslizar 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 até onde 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 nessa implementação é que, ao arrastar, o retângulo é redesenhado a partir do estado original. Assim, você pode aumentar e diminuir o retângulo enquanto o cria, sem que os retângulos intermediários fiquem na imagem final. Essa é uma das razões pelas quais objetos de imagem imutável são úteis — veremos outra mais adiante.

Implementar o preenchimento por inundação é um pouco mais complexo. Essa é uma ferramenta que preenche o pixel sob o ponteiro e todos os pixels adjacentes que têm a mesma cor. “Adjacente” significa diretamente na horizontal ou vertical, não na diagonal. Esta imagem ilustra o conjunto de pixels coloridos quando a ferramenta é usada no pixel marcado:

Diagrama de uma grade de pixels mostrando a área preenchida por uma operação de preenchimento por inundação

Curiosamente, a forma como faremos isso se parece um pouco com o código de busca de caminho do Capítulo 7. Enquanto aquele código buscava em um grafo para encontrar uma rota, este percorre uma grade para encontrar todos os pixels “conectados”. O problema de acompanhar um conjunto ramificado de possíveis caminhos é 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 também funciona como a lista de trabalho da função. Para cada pixel alcançado, precisamos verificar se algum pixel adjacente tem a mesma cor e ainda não foi pintado. O contador do loop fica atrás do comprimento do array drawn à medida que novos pixels são adicionados. Quaisquer pixels à frente dele ainda precisam ser explorados. Quando ele alcança o comprimento, não restam pixels a explorar e a função termina.

A última ferramenta é um seletor de cores, que permite apontar para uma cor na imagem para usá-la como cor atual de desenho.

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 um 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 controle da imagem atual para poder acessá-la ao salvar. Para criar o arquivo de imagem, ele usa um elemento <canvas> no qual desenha a imagem (em escala de um pixel por pixel).

O método toDataURL em um elemento canvas cria uma URL que usa o esquema data:. Diferentemente de URLs http: e https:, URLs de dados contêm todo o recurso na própria URL. Elas geralmente são muito longas, mas nos permitem criar links funcionais para imagens arbitrárias, diretamente no navegador.

Para fazer o navegador baixar a imagem, criamos um elemento de link que aponta para essa URL e possui um atributo download. Esses links, quando clicados, fazem o navegador mostrar uma caixa de diálogo para salvar arquivo. Adicionamos o link ao documento, simulamos um clique nele e depois o removemos. Dá para fazer muita coisa com tecnologia de navegador, mas às vezes o jeito de fazer é meio estranho.

E fica ainda mais estranho. Também queremos poder carregar arquivos de imagem existentes na aplicação. Para isso, definimos outro 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 ele selecione o arquivo por meio de um campo de entrada de arquivo. Mas não queremos que o botão de carregar pareça um campo desses, então criamos o campo quando o botão é clicado e fingimos que ele próprio foi clicado.

Quando o usuário seleciona um arquivo, podemos usar FileReader para acessar seu conteúdo, novamente como uma data URL. Essa URL pode ser usada para criar um elemento <img>, mas como não conseguimos acessar diretamente os pixels dessa 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, primeiro precisamos desenhar a imagem em um elemento <canvas>. O contexto do canvas tem um método getImageData que permite que um script leia seus pixels. Assim, depois que a imagem estiver 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);
}

Limitaremos o tamanho das imagens a 100 por 100 pixels, já que qualquer coisa maior ficará enorme na nossa tela e pode deixar a interface lenta.

A propriedade data do objeto retornado por getImageData é um array de componentes de cor. Para cada pixel no retângulo especificado, ele 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 a 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 exatamente ao intervalo de 0 a 255 — dois dígitos em base 16 podem expressar 162 = 256 números diferentes. O método toString de números pode receber uma base como argumento, então n.toString(16) produz uma representação em base 16. Precisamos garantir que cada número tenha dois dígitos, então a função auxiliar hex chama padStart para adicionar um zero à esquerda quando necessário.

Agora podemos carregar e salvar! Falta apenas mais um recurso antes de terminarmos.

Histórico de desfazer

Como metade do processo de edição consiste em cometer pequenos erros e corrigi-los, um recurso importante em um programa de desenho é um histórico de desfazer.

Para poder desfazer alterações, precisamos armazenar versões anteriores da imagem. Como imagens são valores imutáveles, 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 essa propriedade exige uma função de atualização de estado mais complexa que adiciona imagens ao array.

Não queremos armazenar toda mudança — apenas mudanças separadas por um certo intervalo de tempo. Para isso, precisamos de uma segunda propriedade, doneAt, para registrar 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 é 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 garantir que a próxima mudança seja armazenada novamente no histórico, permitindo reverter a ela mais tarde, se desejar.

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 de botão de desfazer não faz muito. Ele despacha ações de desfazer quando clicado e se desativa 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 dispatch. Podemos passá-los ao construtor de PixelEditor para criar o componente principal. Como precisaremos criar vários editores nos exercícios, primeiro definimos algumas associações.

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 fazer destructuring de um objeto ou array, você pode usar = após o nome de uma variável para fornecer um valor padrão, usado quando a propriedade está ausente ou contém undefined. A função startPixelEditor usa isso para aceitar um objeto com várias propriedades opcionais como argumento. Se você não fornecer uma propriedade tools, por exemplo, tools será associada a baseTools.

É assim que colocamos um editor real na tela:

<div></div>
<script>
  document.querySelector("div")
    .appendChild(startPixelEditor({}));
</script>

Vá em frente e desenhe algo.

Por que isso é tão difícil?

A tecnologia de navegador é incrível. Ela fornece um poderoso conjunto de blocos para construção de interfaces, 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 todos os computadores e telefones do planeta.

Ao mesmo tempo, a tecnologia de navegador é ridícula. Você precisa aprender um grande número de truques estranhos e fatos obscuros para dominá-la, e o modelo de programação padrão que ela oferece é 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 melhorando, isso acontece principalmente na forma de mais elementos sendo adicionados para resolver deficiências — criando ainda mais complexidade. Um recurso usado por um milhão de sites não pode simplesmente ser substituído. Mesmo que pudesse, seria difícil decidir por qual 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 uma boa compreensão de como a realidade técnica existente funciona — e por que ela é assim — do que lutar contra ela ou esperar por outra realidade.

Novas abstraçãos podem ser úteis. O modelo de componentes e a convenção de fluxo de dados que usei neste capítulo são uma forma rudimentar disso. Como mencionado, existem bibliotecas que tentam tornar a programação de interfaces mais agradável. No momento em que escrevo, React e Svelte são escolhas populares, mas há toda uma indústria de frameworks desse tipo. Se você tem interesse em programar aplicações web, recomendo investigar alguns deles para entender como funcionam e quais benefícios oferecem.

Exercícios

Ainda há espaço para melhorar nosso programa. Vamos adicionar alguns recursos a mais 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 desfazer.

Faça isso modificando o componente PixelEditor. Adicione uma propriedade tabIndex com valor 0 ao elemento <div> que envolve tudo, para que ele possa receber foco do teclado. Observe que a propriedade correspondente ao atributo tabindex se chama tabIndex, com I maiúsculo, e nossa função elt espera nomes de propriedades. Registre os manipuladores de evento de teclado diretamente nesse elemento. Isso significa que você precisa clicar, tocar ou usar tab na aplicação antes de poder interagir com ela com o teclado.

Lembre-se de que eventos de teclado têm propriedades ctrlKey e metaKey (para command no Mac) que você pode usar para verificar se essas teclas estão pressionadas.

<div></div>
<script>
  // A classe original PixelEditor. 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>
Mostrar dicas...

A propriedade key de eventos para teclas de letras será a própria letra minúscula, se shift não estiver pressionado. Não estamos interessados em eventos 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 das primeiras letras a partir do objeto tools, para não precisar escrevê-las manualmente.

Quando o evento corresponder 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 restante do DOM não é muito caro, mas redesenhar todos os pixels no canvas dá bastante trabalho.

Encontre uma maneira 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 é usado pelo botão de salvar, então, se você alterá-lo, garanta que as mudanças não quebrem o uso anterior ou crie uma nova versão com outro nome.

Observe também que alterar o tamanho de um elemento <canvas>, definindo suas propriedades width ou height, limpa seu conteúdo, tornando-o totalmente transparente novamente.

<div></div>
<script>
  // Altere 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 alterar isto 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>
Mostrar dicas...

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% do trabalho de desenho na maioria dos casos.

Você pode escrever uma nova função updatePicture ou fazer com que drawPicture receba um argumento extra, que pode ser indefinido ou a imagem anterior. Para cada pixel, a função verifica se foi passada uma imagem anterior com a mesma cor nessa posição e ignora o pixel nesse caso.

Como o canvas é limpo quando mudamos seu tamanho, você também deve evitar mexer nas 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 for carregada, você pode definir a variável que guarda a imagem antiga como null após alterar o tamanho do canvas, já que não deve pular nenhum pixel depois disso.

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>
Mostrar dicas...

Você pode se inspirar na ferramenta rectangle. Assim como nela, você vai querer continuar desenhando sobre a 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, calcule a distância entre a posição atual do ponteiro e a posição inicial, tirando a raiz quadrada (Math.sqrt) da soma do quadrado (x ** 2) da diferença nas coordenadas x e do quadrado da diferença nas coordenadas y. Em seguida, percorra um quadrado de pixels ao redor da posição inicial, cujos lados tenham pelo menos duas vezes o raio, e pinte aqueles que estiverem dentro do raio do círculo, novamente usando a fórmula de Pitágoras para calcular sua distância até o centro.

Certifique-se de não tentar colorir pixels fora dos limites da imagem.

Linhas adequadas

Este é um exercício mais avançado do que os três anteriores e 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 e não se desanime com falhas iniciais.

Na maioria dos navegadores, quando você seleciona a ferramenta draw e arrasta rapidamente pela imagem, você não obtém uma linha contínua. Em vez disso, aparecem pontos com espaços entre eles porque os eventos "mousemove" ou "touchmove" não são disparados com rapidez suficiente para atingir cada pixel.

Melhore a ferramenta draw para que ela desenhe uma linha completa. Isso significa que você deve fazer a função manipuladora de movimento lembrar a posição anterior e conectá-la à atual.

Para fazer isso, como os pixels podem estar a qualquer distância, você terá que escrever uma função geral de desenho de linha.

Uma linha entre dois pixels é uma cadeia conectada de pixels, o mais reta possível, indo do início ao fim. Pixels adjacentes diagonalmente contam como conectados. Uma linha inclinada deve se parecer com a imagem à esquerda, não com a da direita.

Diagrama de duas linhas pixeladas, uma clara, pulando diagonalmente entre pixels, e outra mais espessa, com todos os pixels conectados horizontal ou verticalmente

Por fim, se tivermos código que desenha uma linha entre dois pontos arbitrários, podemos usá-lo também para definir uma ferramenta line, que desenha uma linha reta entre o início e o fim de um arrasto.

<div></div>
<script>
  // A ferramenta draw antiga. Reescreva isto.
  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>
Mostrar dicas...

A questão do desenho de 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ê percorre as coordenadas x e colore um pixel a cada passo. Se a linha tiver uma leve inclinação (menos de 45 graus ou ¼π radianos), você pode interpolar a coordenada y ao longo da inclinação. Ainda assim, precisa de um pixel por posição em x, com a posição em y determinada pela inclinação.

Mas assim que a inclinação ultrapassa 45 graus, você precisa mudar a forma como trata as coordenadas. Agora você precisa de um pixel por posição em y, já que a linha sobe mais do que avança lateralmente. E então, quando ultrapassa 135 graus, você precisa voltar a percorrer as coordenadas x, mas da direita para a esquerda.

Você não precisa necessariamente escrever quatro loops. Como desenhar uma linha de A até B é o mesmo que de B até 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.

Assim, 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 do que a diferença entre as coordenadas y. Se for, trata-se de uma linha mais horizontal; caso contrário, mais vertical.

Certifique-se de comparar os valores absolutos das diferenças em x e y, o que você pode obter com Math.abs.

Depois de saber ao longo de qual eixo você irá iterar, você pode verificar se o ponto inicial tem uma coordenada maior nesse eixo do que o ponto final e trocá-los, se necessário. Uma forma concisa de trocar os valores de duas variáveis em JavaScript usa atribuição por destructuring assim:

[start, end] = [end, start];

Então você pode calcular a inclinação da linha, que determina quanto a coordenada no outro eixo muda a cada passo ao longo do eixo principal. Com isso, você pode executar um loop ao longo do eixo principal enquanto acompanha a posição correspondente no outro eixo, desenhando pixels a cada iteração. Certifique-se de arredondar as coordenadas do eixo secundário, pois elas provavelmente serão fracionárias e o método draw não lida bem com coordenadas fracionárias.