プロジェクト: ピクセルアートエディター

目の前にはたくさんの色がある。私の目の前には空白のキャンバスがある。そして私は、詩を形作る言葉のように、音楽を形作る音符のように、色を適用しようとする。

ジョアン・ミロ
Illustration showing a mosaic of black tiles, with jars of other tiles next to it

前の章の教材は、基本的なWebアプリケーションを構築するために必要なすべての要素を提供します。この章では、それを実際に行います。

私たちのアプリケーションは、拡大表示された色のついた正方形のグリッドとして表示される、ピクセル単位で画像を修正できるピクセル描画プログラムになります。このプログラムを使用して、画像ファイルを開き、マウスなどのポインティングデバイスでスクリブルしたり、保存したりできます。これは、それがどのようなものかを示しています。

Screenshot of the pixel editor interface, with a grid of colored pixels at the top and a number of controls, in the form of HTML fields and buttons, below that

コンピューターでのペイントは素晴らしいです。材料、スキル、才能を心配する必要はありません。あなたはただ塗り始め、どこにたどり着くかを見ます。

コンポーネント

アプリケーションのインターフェースには、上部に大きな<canvas>要素が表示され、その下にいくつかのフォームフィールドが表示されます。ユーザーは、<select>フィールドからツールを選択し、キャンバスをクリック、タッチ、またはドラッグして、画像に描画します。単一のピクセルまたは長方形を描画するためのツール、領域を塗りつぶすためのツール、および画像から色を選択するためのツールがあります。

エディターのインターフェースを、DOMの一部を担当し、内部に他のコンポーネントを含めることができるオブジェクトである、いくつかのコンポーネントとして構造化します。

アプリケーションの状態は、現在の画像、選択されたツール、および選択された色で構成されます。状態が単一の値に存在し、インターフェイスコンポーネントが常に現在の状態に基づいて表示されるように設定します。

これがなぜ重要なのかを確認するために、代替案、つまりインターフェイス全体に状態の一部を分散することを検討しましょう。ある時点までは、これがプログラミングしやすくなります。色フィールドに配置して、現在の色を知る必要があるときにその値を読み取るだけです。

しかし、次にカラーピッカーを追加します。これは、画像をクリックして特定のピクセルの色を選択できるツールです。カラーフィールドに正しい色を表示し続けるには、そのツールはカラーフィールドが存在することを知っており、新しい色を選択するたびに更新する必要があります。色を表示する別の場所(マウスカーソルで色を表示するなど)を追加する場合は、その同期を維持するために色変更コードを更新する必要があります。

事実上、これにより、インターフェイスの各部分が他のすべての部分について知る必要があるという問題が生じ、モジュール性が低くなります。この章のような小さなアプリケーションでは、それは問題にならないかもしれません。より大きなプロジェクトでは、それは本当の悪夢になる可能性があります。

原則として、この悪夢を回避するために、データフローについて厳密になります。状態があり、インターフェイスはその状態に基づいて描画されます。インターフェイスコンポーネントは、状態を更新することによりユーザーアクションに応答する場合があります。その時点で、コンポーネントはこの新しい状態に自身を同期させる機会を得ます。

実際には、各コンポーネントは、新しい状態が与えられると、更新する必要がある限り、子コンポーネントにも通知するように設定されています。これを設定するのは少し面倒です。これをより便利にすることは、多くのブラウザプログラミングライブラリの主なセールスポイントです。しかし、このような小さなアプリケーションの場合、そのようなインフラストラクチャなしで実行できます。

状態の更新はオブジェクトとして表され、これをアクションと呼びます。コンポーネントはこのようなアクションを作成してディスパッチすることができます。つまり、中央の状態管理関数にそれらを与えることができます。その関数は次の状態を計算し、その後、インターフェイスコンポーネントはこの新しい状態に自身を更新します。

ユーザーインターフェースを実行するという面倒な作業を取り、それに構造を適用しています。DOM関連の要素はまだ副作用でいっぱいですが、概念的に単純なバックボーンである状態更新サイクルによって支えられています。状態はDOMがどのように見えるかを決定し、DOMイベントが状態を変更できる唯一の方法は、状態にアクションをディスパッチすることです。

このアプローチには多くのバリエーションがあり、それぞれに独自の利点と問題がありますが、その中心となる考え方は同じです。状態の変更は、単一の明確に定義されたチャネルを介して行われる必要があり、あらゆる場所で発生してはなりません。

私たちのコンポーネントは、インターフェースに準拠するクラスになります。それらのコンストラクターには状態(アプリケーション全体の状態、またはすべてにアクセスする必要がない場合はそれより小さい値)が与えられ、それを使用してdomプロパティを構築します。これは、コンポーネントを表すDOM要素です。ほとんどのコンストラクターは、アクションをディスパッチするために使用できる関数など、時間とともに変化しない他のいくつかの値も取得します。

各コンポーネントには、新しい状態値に同期するために使用されるsyncStateメソッドがあります。このメソッドは、コンストラクターへの最初の引数と同じタイプである状態である1つの引数を取ります。

状態

アプリケーションの状態は、picturetool、およびcolorプロパティを持つオブジェクトになります。画像自体は、画像の幅、高さ、ピクセルコンテンツを格納するオブジェクトです。ピクセルは、上から下へ、行ごとに単一の配列に格納されます。

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);
  }
}

画像の扱いを、この章の後半で説明する理由により、不変の値として扱えるようにしたいと考えています。しかし、一度に大量のピクセルを更新する必要がある場合もあります。そのため、クラスには更新されたピクセルの配列(xy、およびcolorプロパティを持つオブジェクト)を期待し、それらのピクセルで上書きされた新しい画像を作成するdrawメソッドがあります。このメソッドは、引数なしでsliceを使用して、ピクセル配列全体をコピーします。スライスの開始はデフォルトで0、終了はデフォルトで配列の長さになります。

emptyメソッドは、これまで見たことのない2つの配列機能を使用します。Arrayコンストラクターは、指定された長さの空の配列を作成するために、数値とともに呼び出すことができます。次に、fillメソッドを使用して、この配列を指定された値で塗りつぶすことができます。これらは、すべてのピクセルが同じ色を持つ配列を作成するために使用されます。

色は、ハッシュ記号(#)に続いて6つの16進数(base-16)の数字が続く、従来のCSSカラーコードを含む文字列として格納されます。赤色成分に2つ、緑色成分に2つ、青色成分に2つです。これは、色を記述するためのやや不可解で不便な方法ですが、HTMLカラー入力フィールドが使用する形式であり、キャンバス描画コンテキストのfillStyleプロパティで使用できるため、このプログラムで色を使用する方法にとっては、実用的です。

すべてのコンポーネントがゼロである黒は、"#000000"と記述され、鮮やかなピンクは"#ff00ff"のように見え、赤と青のコンポーネントは、16進数の数字(10から15までの数字を表すためにaからfを使用します)でffと書かれた255の最大値を持っています。

インターフェイスが、プロパティが前の状態のプロパティを上書きするオブジェクトとしてアクションをディスパッチできるようにします。ユーザーが変更した場合、カラーフィールドは、{color: field.value}のようなオブジェクトをディスパッチできます。これにより、この更新関数は新しい状態を計算できます。

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

オブジェクトスプレッドを使用して最初に既存のオブジェクトのプロパティを追加し、次にそれらのプロパティの一部をオーバーライドするこのパターンは、不変オブジェクトを使用するJavaScriptコードでは一般的です。

DOMの構築

インターフェイスコンポーネントが行う主なことの1つは、DOM構造を作成することです。ここでも、冗長なDOMメソッドを直接使用したくないため、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;
}

このバージョンと第16章で使用したバージョンとの主な違いは、属性ではなくDOMノードにプロパティを割り当てることです。つまり、任意の属性を設定するために使用することはできませんが、クリックイベントハンドラーを登録する関数に設定できるonclickなど、値が文字列ではないプロパティを設定するために使用できます

これにより、イベントハンドラーを登録するためのこの便利なスタイルが可能になります。

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

キャンバス

最初に定義するコンポーネントは、画像を色付きのボックスのグリッドとして表示するインターフェースの部分です。このコンポーネントは、画像を​​表示することと、その画像のポインターイベントをアプリケーションの残りの部分に伝えるという2つの役割を担っています。

したがって、アプリケーション全体の状態ではなく、現在の画像のみを認識するコンポーネントとして定義できます。アプリケーション全体の仕組みを把握していないため、アクションを直接ディスパッチすることはできません。むしろ、ポインターイベントに応答するときは、それを作成したコードによって提供されるコールバック関数を呼び出します。これにより、アプリケーション固有の部分が処理されます。

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);
  }
}

各ピクセルを、scale定数によって決定される10 x 10の正方形として描画します。不要な作業を避けるために、コンポーネントは現在の画像を追跡し、syncStateに新しい画像が与えられた場合にのみ再描画を行います。

実際の描画関数は、スケールと画像サイズに基づいてキャンバスのサイズを設定し、各ピクセルに1つずつ、一連の正方形で塗りつぶします。

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);
    }
  }
}

マウスが画像キャンバス上にあるときにマウスの左ボタンが押されると、コンポーネントはpointerDownコールバックを呼び出し、クリックされたピクセルの位置を画像座標で渡します。これは、画像とのマウスインタラクションを実装するために使用されます。コールバックは、ボタンが押されたままポインタが別のピクセルに移動したときに通知される別のコールバック関数を返す場合があります。

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)};
}

ピクセルのサイズがわかっており、getBoundingClientRect を使って画面上のキャンバスの位置を特定できるため、マウスイベントの座標(clientXclientY)から画像の座標に変換できます。これらの座標は常に切り捨てられ、特定のピクセルを参照するようにします。

タッチイベントでも同様のことを行う必要がありますが、異なるイベントを使用し、パン操作を防止するために "touchstart" イベントで preventDefault を呼び出す必要があります。

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);
};

タッチイベントの場合、clientXclientY はイベントオブジェクトで直接利用できませんが、touches プロパティの最初のタッチオブジェクトの座標を使用できます。

アプリケーション

アプリケーションを段階的に構築できるように、メインコンポーネントを、画像のキャンバスと、コンストラクタに渡す動的なツールとコントロールのセットを囲むシェルとして実装します。

コントロール は、画像の下に表示されるインターフェース要素です。これらはコンポーネントコンストラクタの配列として提供されます。

ツール は、ピクセルの描画や領域の塗りつぶしなどの操作を行います。アプリケーションは、利用可能なツールを <select> フィールドとして表示します。現在選択されているツールは、ユーザーがポインティングデバイスで画像とやり取りするときに何が起こるかを決定します。利用可能なツールのセットは、ドロップダウンフィールドに表示される名前を、ツールを実装する関数にマッピングするオブジェクトとして提供されます。このような関数は、画像の座標、現在のアプリケーションの状態、および dispatch 関数を引数として受け取ります。それらは、ポインターが別のピクセルに移動したときに、新しい位置と現在の状態で呼び出される移動ハンドラー関数を返す場合があります。

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);
  }
}

PictureCanvas に渡されるポインターハンドラーは、現在選択されているツールを適切な引数で呼び出し、それが移動ハンドラーを返す場合は、そのハンドラーが状態も受け取るように適応させます。

すべてのコントロールは構築され、this.controls に格納され、アプリケーションの状態が変化したときに更新できるようにします。reduce の呼び出しは、コントロールの DOM 要素の間にスペースを挿入します。そうすることで、それらが密集して見えることを防ぎます。

最初のコントロールは、ツール選択メニューです。これは、各ツールのオプションを含む <select> 要素を作成し、ユーザーが別のツールを選択したときにアプリケーションの状態を更新する "change" イベントハンドラーを設定します。

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; }
}

ラベルテキストとフィールドを <label> 要素でラップすることで、ブラウザにラベルがそのフィールドに属していることを伝え、例えば、ラベルをクリックしてフィールドにフォーカスを当てることができます。

色の変更もできるようにする必要があるため、そのためのコントロールを追加しましょう。type 属性が color の HTML <input> 要素は、色の選択に特化したフォームフィールドを提供します。このようなフィールドの値は常に、"#RRGGBB" 形式(赤、緑、青のコンポーネント、色ごとに2桁)の CSS カラーコードです。ユーザーが操作すると、ブラウザはカラーピッカーインターフェースを表示します。

このコントロールは、そのようなフィールドを作成し、アプリケーション状態の color プロパティと同期するように配線します。

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; }
}

描画ツール

何かを描画する前に、キャンバス上のマウスまたはタッチイベントの機能を制御するツールを実装する必要があります。

最も基本的なツールは描画ツールで、クリックまたはタップしたピクセルを現在選択されている色に変更します。これは、指し示されたピクセルに現在選択されている色が与えられたバージョンに画像を更新するアクションをディスパッチします。

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;
}

この関数はすぐに drawPixel 関数を呼び出しますが、ユーザーが画像をドラッグまたはスワイプしたときに、新たにタッチされたピクセルに対して再度呼び出されるように、それを返します。

より大きな図形を描画するには、長方形をすばやく作成できると便利です。rectangle ツールは、ドラッグを開始した点とドラッグした点の間に長方形を描画します。

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;
}

この実装の重要な点は、ドラッグ中に、長方形が元の状態から画像に再描画されることです。このようにして、長方形を作成中に大きくしたり小さくしたりすることができ、中間の長方形が最終的な画像に残らないようにできます。これが、不変の画像オブジェクトが役立つ理由の1つです。そのもう1つの理由については後で説明します。

塗りつぶし(フラッドフィル)の実装はやや複雑です。これは、ポインターの下にあるピクセルと、同じ色を持つ隣接するすべてのピクセルを塗りつぶすツールです。「隣接」とは、斜めではなく、直接水平または垂直に隣接していることを意味します。この画像は、マークされたピクセルで塗りつぶしツールが使用されたときに色付けされるピクセルのセットを示しています。

Diagram of a pixel grid showing the area filled by a flood fill operation

興味深いことに、これを行う方法は、第7章の経路探索コードと少し似ています。そのコードはルートを見つけるためにグラフを検索しましたが、このコードはすべての「接続された」ピクセルを見つけるためにグリッドを検索します。分岐する可能性のあるルートのセットを追跡するという問題は似ています。

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)});
}

描画されたピクセルの配列は、関数の作業リストとしても機能します。到達した各ピクセルについて、隣接するピクセルに同じ色があり、まだ塗りつぶされていないかどうかを確認する必要があります。ループカウンターは、新しいピクセルが追加されるにつれて、drawn 配列の長さに遅れます。その前のピクセルはまだ調査する必要があります。長さと一致した場合、調査されていないピクセルは残らず、関数は完了します。

最後のツールはカラーピッカーで、画像の色を指して現在の描画色として使用することができます。

function pick(pos, state, dispatch) {
  dispatch({color: state.picture.pixel(pos.x, pos.y)});
}

これでアプリケーションをテストできます!

<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>

保存と読み込み

傑作を描いたら、後で使用するために保存したいでしょう。現在の画像をイメージファイルとしてダウンロードするためのボタンを追加する必要があります。このコントロールはそのボタンを提供します。

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; }
}

コンポーネントは、保存時にアクセスできるように、現在の画像を追跡します。イメージファイルを作成するために、画像を(1ピクセルあたり1ピクセルのスケールで)描画する <canvas> 要素を使用します。

キャンバス要素の toDataURL メソッドは、data: スキームを使用する URL を作成します。http:https: URL とは異なり、データ URL には URL 内のリソース全体が含まれています。通常は非常に長いですが、ブラウザで任意の画像への作業リンクをここで作成できます。

実際にブラウザに画像をダウンロードさせるには、この URL を指し、download 属性を持つリンク要素を作成します。このようなリンクは、クリックすると、ブラウザにファイル保存ダイアログを表示させます。そのリンクをドキュメントに追加し、そのリンクのクリックをシミュレートして、再度削除します。ブラウザテクノロジーで多くのことができますが、その方法は奇妙な場合があります。

そしてさらに悪化します。既存のイメージファイルをアプリケーションに読み込むことも必要になります。そのためには、再度ボタンコンポーネントを定義します。

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();
}

ユーザーのコンピュータ上のファイルにアクセスするには、ユーザーがファイル入力フィールドを介してファイルを選択する必要があります。ただし、ロードボタンをファイル入力フィールドのように見せたくないため、ボタンがクリックされたときにファイル入力を作成し、このファイル入力自体がクリックされたふりをします。

ユーザーがファイルを選択したら、FileReader を使用して、その内容にデータ URL としてアクセスできます。その URL を使用して <img> 要素を作成できますが、そのような画像のピクセルに直接アクセスすることはできないため、そこから Picture オブジェクトを作成することはできません。

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);
}

ピクセルにアクセスするには、最初に <canvas> 要素に画像を描画する必要があります。キャンバスコンテキストには、スクリプトがピクセルを読み取ることができる getImageData メソッドがあります。したがって、画像がキャンバス上にある場合、それにアクセスして 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);
}

画像サイズは100 x 100ピクセルに制限します。それよりも大きいものは、ディスプレイに巨大に見え、インターフェースを遅くする可能性があるためです。

getImageData によって返されるオブジェクトの data プロパティは、色の構成要素の配列です。引数で指定された矩形内の各ピクセルについて、ピクセルの色の赤、緑、青、およびアルファ成分を表す4つの値が、0から255の数値で格納されています。アルファ部分は不透明度を表します。0の場合はピクセルが完全に透明で、255の場合は完全に不透明です。ここでは、このアルファ部分は無視できます。

私たちの色の表記で使用されている、コンポーネントごとの2桁の16進数は、まさに0から255の範囲に対応しています。2つの16進数は、162 = 256の異なる数値を表現できます。数値の toString メソッドには、基数を引数として渡すことができるため、n.toString(16) は基数16での文字列表現を生成します。各数値が2桁になるように、hex ヘルパー関数は必要に応じて先頭に0を追加するために padStart を呼び出します。

これで、ロードと保存ができるようになりました! 残りは、完成する前に必要な機能が1つだけです。

アンドゥ履歴

編集の過程の半分は、小さな間違いを犯して修正することであるため、描画プログラムで重要な機能は、アンドゥ履歴です。

変更をアンドゥできるようにするには、前のバージョンの絵を保存する必要があります。絵は不変の値であるため、これは簡単です。ただし、アプリケーションの状態にフィールドを追加する必要があります。

以前のバージョンの絵を保持するために、done 配列を追加します。このプロパティを維持するには、絵を配列に追加する、より複雑な状態更新関数が必要です。

ただし、すべての変更を保存するのではなく、一定の間隔で発生した変更だけを保存したいと考えています。これを実現するには、最後に履歴に絵を保存した時刻を追跡するための2番目のプロパティ doneAt が必要です。

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};
  }
}

アクションがアンドゥアクションの場合、関数は履歴から最新の絵を取得し、それを現在の絵にします。次の変更で絵が確実に履歴に保存されるように、doneAt をゼロに設定します。これにより、必要に応じて別の時間に元に戻すことができます。

それ以外の場合、アクションに新しい絵が含まれており、最後に保存してから1秒(1000ミリ秒)以上経過している場合は、以前の絵を保存するように done および doneAt プロパティが更新されます。

アンドゥボタンのコンポーネントはあまり多くのことを行いません。クリックするとアンドゥアクションをディスパッチし、アンドゥするものが何もない場合は自身を無効にします。

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;
  }
}

描いてみましょう

アプリケーションをセットアップするには、状態、ツールのセット、コントロールのセット、ディスパッチ関数を作成する必要があります。これらを PixelEditor コンストラクターに渡して、メインコンポーネントを作成できます。演習で複数のエディターを作成する必要があるため、最初にいくつかのバインディングを定義します。

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;
}

オブジェクトまたは配列を分割代入するとき、バインディング名の後に = を使用して、プロパティがない場合や undefined を保持している場合に使用されるデフォルト値をバインディングに与えることができます。startPixelEditor 関数は、これを利用して、オプションのプロパティを多数持つオブジェクトを引数として受け取ります。たとえば、tools プロパティを提供しない場合、toolsbaseTools にバインドされます。

これが画面上に実際のエディターを表示する方法です。

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

さあ、何か描いてみましょう。

なぜこんなに難しいのでしょうか?

ブラウザ技術は素晴らしいものです。強力なインターフェースの構成要素、それらをスタイル設定および操作する方法、アプリケーションを検査およびデバッグするためのツールを提供しています。ブラウザ用に作成したソフトウェアは、地球上のほぼすべてのコンピューターと電話で実行できます。

同時に、ブラウザ技術はとんでもないものです。習得するには、多数のばかげたトリックや曖昧な事実を学ぶ必要があり、提供されるデフォルトのプログラミングモデルは非常に問題があるため、ほとんどのプログラマーは直接対処するよりも、いくつかの抽象化の層でそれを覆うことを好みます。

状況は確実に改善されていますが、ほとんどの場合、欠点に対処するためにより多くの要素が追加されるという形で改善されているため、さらに複雑さが増しています。数百万のウェブサイトで使用されている機能を実際に置き換えることはできません。できたとしても、何に置き換えるかを決めるのは難しいでしょう。

技術は決して真空状態には存在しません。私たちはツールや、それらを生み出した社会的、経済的、歴史的要因によって制約されています。これは厄介なことですが、それに対して怒ったり、別の現実を期待したりするよりも、既存の技術的な現実がどのように機能するのか、そしてなぜそうなっているのかをよく理解しようとすることの方が一般的に生産的です。

新しい抽象化は役立つ場合があります。この章で使用したコンポーネントモデルとデータフローの規約は、その粗雑な形です。前述のように、ユーザーインターフェイスのプログラミングをより快適にするためのライブラリがあります。執筆時点では、ReactSvelte が人気のある選択肢ですが、このようなフレームワークの小さな業界全体があります。ウェブアプリケーションのプログラミングに興味がある場合は、それらがどのように機能し、どのようなメリットがあるのかを理解するために、いくつかのフレームワークを調査することをお勧めします。

演習

私たちのプログラムにはまだ改善の余地があります。演習としてさらにいくつかの機能を追加してみましょう。

キーボードバインディング

アプリケーションにキーボードショートカットを追加します。ツールの名前の最初の文字でツールを選択し、Ctrl-Z または command-Z でアンドゥをアクティブにします。

これを行うには、PixelEditor コンポーネントを変更します。キーボードフォーカスを受け取ることができるように、ラップしている <div> 要素に tabIndex プロパティを0で追加します。tabindex 属性に対応するプロパティは、大文字のIを使用して tabIndex と呼ばれ、elt 関数はプロパティ名を期待していることに注意してください。キーイベントハンドラーをその要素に直接登録します。つまり、キーボードで操作する前に、クリック、タッチ、またはタブでアプリケーションに移動する必要があります。

キーボードイベントには、それらのキーが押されているかどうかを確認するために使用できる ctrlKey および metaKey(Macでのcommandの場合)プロパティがあることを覚えておいてください。

<div></div>
<script>
  // The original PixelEditor class. Extend the constructor.
  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>
ヒントを表示...

文字キーのイベントの key プロパティは、Shift が押されていない場合は、小文字になります。ここでは Shift を伴うキーイベントには関心がありません。

"keydown" ハンドラーは、イベントオブジェクトを調べて、ショートカットのいずれかと一致するかどうかを確認できます。tools オブジェクトから最初の文字のリストを自動的に取得できるため、それらを書き出す必要はありません。

キーイベントがショートカットと一致する場合、そのイベントに対して preventDefault を呼び出し、適切なアクションをディスパッチします。

効率的な描画

描画中、アプリケーションが行う作業の大部分は drawPicture で発生します。新しい状態を作成して残りのDOMを更新することはそれほどコストがかかりませんが、キャンバス上のすべてのピクセルを再描画することはかなりの作業になります。

実際に変更されたピクセルのみを再描画することで、PictureCanvassyncState メソッドを高速化する方法を見つけてください。

drawPicture は保存ボタンでも使用されるため、変更する場合は、変更によって以前の使用法が壊れないようにするか、異なる名前で新しいバージョンを作成してください。

また、<canvas> 要素の width または height プロパティを設定してサイズを変更すると、要素がクリアされ、完全に透明になることにも注意してください。

<div></div>
<script>
  // Change this method
  PictureCanvas.prototype.syncState = function(picture) {
    if (this.picture == picture) return;
    this.picture = picture;
    drawPicture(this.picture, this.dom, scale);
  };

  // You may want to use or change this as well
  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>
ヒントを表示...

この演習は、不変のデータ構造がコードを高速化できる良い例です。古い絵と新しい絵の両方があるため、それらを比較して、色が変わったピクセルのみを再描画でき、ほとんどの場合、描画作業の99%以上を節約できます。

新しい関数 updatePicture を作成するか、drawPicture に追加の引数を渡すことができます。これは未定義であるか、以前の絵である可能性があります。ピクセルごとに、この位置で同じ色の以前の絵が渡されたかどうかを関数が確認し、そうである場合はピクセルをスキップします。

キャンバスのサイズを変更するとキャンバスがクリアされるため、古い絵と新しい絵のサイズが同じ場合は、width および height プロパティに触れないようにする必要があります。サイズが異なる場合(新しい絵がロードされたときに発生します)、キャンバスサイズを変更した後はピクセルをスキップしない必要があるため、キャンバスサイズを変更した後、古い絵を保持するバインディングを null に設定できます。

ドラッグすると塗りつぶされた円を描画する circle というツールを定義します。円の中心はドラッグまたはタッチジェスチャーが開始された点にあり、その半径はドラッグされた距離によって決まります。

<div></div>
<script>
  function circle(pos, state, dispatch) {
    // Your code here
  }

  let dom = startPixelEditor({
    tools: {...baseTools, circle}
  });
  document.querySelector("div").appendChild(dom);
</script>
ヒントを表示...

rectangle ツールからヒントを得ることができます。そのツールと同様に、ポインターが移動するときは、現在の絵ではなく、開始した絵を描画し続ける必要があります。

どのピクセルを色付けするかを判断するには、ピタゴラスの定理を使用できます。まず、現在のポインタ位置と開始位置の間の距離を求めます。これは、x座標の差の二乗 (x ** 2) とy座標の差の二乗の合計の平方根 (Math.sqrt) を取ることで計算できます。次に、開始位置を中心とする正方形のピクセルをループ処理します。この正方形の辺は少なくとも半径の2倍であり、円の半径内にあるピクセルを色付けします。ここでも、ピタゴラスの公式を使用して中心からの距離を計算します。

画像の境界の外側にあるピクセルを色付けしようとしないようにしてください。

適切な線

これは前の3つの課題よりも高度な演習であり、非自明な問題に対する解決策を設計する必要があります。この演習に取り組む前に十分な時間と忍耐力があることを確認し、最初の失敗に落胆しないでください。

ほとんどのブラウザでは、「描画」ツールを選択して画像をすばやくドラッグすると、閉じた線になりません。代わりに、"mousemove"イベントまたは"touchmove"イベントがすべてのピクセルをヒットするのに十分な速さで発火しなかったため、間隔のあるドットが描画されます。

drawツールを改善して、完全な線を描画するようにしてください。つまり、モーションハンドラ関数に前の位置を記憶させ、それを現在の位置に接続する必要があります。

これを行うには、ピクセルが任意の距離だけ離れている可能性があるため、一般的な線描画関数を作成する必要があります。

2つのピクセル間の線は、開始点から終了点まで、できるだけ直線に近い接続されたピクセルの連鎖です。斜めに隣接するピクセルは接続されていると見なされます。斜めの線は、右側の図ではなく、左側の図のように見えるはずです。

Diagram of two pixelated lines, one light, skipping across pixels diagonally, and one heavy, with all pixels connected horizontally or vertically

最後に、2つの任意の点の間で線を描画するコードがある場合は、それを使用してlineツールを定義し、ドラッグの開始点と終了点の間に直線を引くこともできます。

<div></div>
<script>
  // The old draw tool. Rewrite this.
  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) {
    // Your code here
  }

  let dom = startPixelEditor({
    tools: {draw, line, fill, rectangle, pick}
  });
  document.querySelector("div").appendChild(dom);
</script>
ヒントを表示...

ピクセル化された線を描画する問題の本質は、実際には類似しているがわずかに異なる4つの問題であるということです。左から右に水平線を描画するのは簡単です。x座標をループ処理し、すべてのステップでピクセルを色付けします。線がわずかな傾き(45度または¼πラジアン未満)を持っている場合、傾きに沿ってy座標を補間できます。依然として、x位置ごとに1つのピクセルが必要であり、これらのピクセルのy位置は傾きによって決定されます。

ただし、傾きが45度を超えるとすぐに、座標を処理する方法を切り替える必要があります。線が左に移動するよりも上に移動するようになったため、今度はy位置ごとに1つのピクセルが必要になります。そして、135度を超えると、x座標を右から左にループ処理する必要があります。

実際には4つのループを作成する必要はありません。AからBへの線を描画することは、BからAへの線を描画することと同じであるため、右から左に移動する線の開始位置と終了位置を交換し、それらを左から右に移動するものとして扱うことができます。

したがって、2つの異なるループが必要です。線描画関数が最初に行うべきことは、x座標の差がy座標の差よりも大きいかどうかを確認することです。大きい場合は、これはほぼ水平線であり、そうでない場合は、ほぼ垂直線です。

Math.absで取得できるxyの差の絶対値を比較するようにしてください。

どの軸に沿ってループするかを把握したら、開始点の座標が終点よりもその軸に沿って高いかどうかを確認し、必要に応じて交換できます。JavaScriptで2つの束縛の値を交換する簡潔な方法は、次のような分割代入を使用します。

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

次に、線の傾きを計算できます。これは、メイン軸に沿って1ステップ進むごとに他の軸の座標が変化する量を決定します。これにより、メイン軸に沿ってループを実行しながら、他の軸の対応する位置を追跡でき、すべての反復でピクセルを描画できます。非メイン軸の座標は小数になる可能性が高く、drawメソッドは小数の座標にうまく対応しないため、それらを丸めるようにしてください。