第4版が利用可能です。こちらで読んでください

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

目の前にある多くの色を見ます。空白のキャンバスを見ます。そして、詩を形作る言葉のように、音楽を形作る音符のように、色を適用しようとします。

Joan Miro
Picture of a tiled mosaic

前の章の資料は、基本的なウェブアプリケーションを構築するために必要なすべての要素を提供します。この章では、まさにそれを行います。

私たちのアプリケーションはピクセルドローイングプログラムであり、拡大表示されたビュー(着色された正方形のグリッドとして表示)を操作することで、ピクセル単位で画像を修正できます。このプログラムを使用して、画像ファイルを開き、マウスやその他のポインターデバイスで書き込み、保存することができます。これがその見た目です。

The pixel editor interface, with colored pixels at the top and a number of controls below that

コンピューターで絵を描くのは素晴らしいことです。材料、スキル、才能を心配する必要はありません。ただ塗り始めるだけです。

コンポーネント

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

エディターインターフェースは、いくつかのコンポーネント、つまりDOMの一部を担当し、内部に他のコンポーネントを含む可能性のあるオブジェクトとして構成します。

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

これが重要な理由を理解するために、状態の部分をインターフェース全体に分散するという代替案を考えてみましょう。ある程度までは、これはプログラミングが容易です。色フィールドを配置し、現在の色を知る必要があるときにその値を読み取ることができます。

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

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

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

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

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

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

このアプローチには多くのバリエーションがあり、それぞれに独自の利点と問題がありますが、中心となる考え方は同じです。状態の変更は、あちこちで起こるのではなく、単一の明確に定義されたチャネルを経由する必要があります。

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

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

状態

アプリケーションの状態は、picturetoolcolorプロパティを持つオブジェクトになります。画像は、画像の幅、高さ、ピクセルコンテンツを格納するオブジェクトでもあります。ピクセルは、第6章の行列クラスと同じ方法で、配列に格納されます(上から下へ、行ごとに)。

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

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

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

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

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

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

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

このやや面倒なパターンでは、Object.assignを使用して最初にstateのプロパティを空のオブジェクトに追加し、次にactionのプロパティでそれらのいくつかを上書きします。これは、不変のオブジェクトを使用するJavaScriptコードでは一般的です。3つのドット演算子を使用して、オブジェクト式に別のオブジェクトからのすべてのプロパティを含めることができる、このためのより便利な表記法は、標準化の最終段階にあります。その追加により、{...state, ...action}と書くことができます。執筆時点では、これはまだすべてのブラウザで動作しません。

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×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つです(別の理由については後で説明します)。

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

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}];
  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 &&
          state.picture.pixel(x, y) == targetColor &&
          !drawn.some(p => p.x == x && p.y == y)) {
        drawn.push({x, y, color: state.color});
      }
    }
  }
  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ピクセルのスケールで)<canvas>要素を使用します。

キャンバス要素のtoDataURLメソッドは、data:で始まるURLを作成します。http:およびhttps: URLとは異なり、data 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を使用してその内容にアクセスできます。これもdata 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ヘルパー関数は必要に応じてpadStartを呼び出して先頭に0を追加します。

これで読み込みと保存ができるようになりました!完了する前に、もう1つの機能が残っています。

アンドゥ履歴

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

変更をアンドゥするには、画像の以前のバージョンを保存する必要があります。不変の値なので、これは簡単です。しかし、アプリケーションの状態に追加のフィールドが必要になります。

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

しかし、すべての変更を保存したいわけではなく、一定の時間間隔をあけた変更のみを保存したいです。そのためには、履歴に画像を最後に保存した時間を追跡するdoneAtという2番目のプロパティが必要です。

function historyUpdateState(state, action) {
  if (action.undo == true) {
    if (state.done.length == 0) return state;
    return Object.assign({}, state, {
      picture: state.done[0],
      done: state.done.slice(1),
      doneAt: 0
    });
  } else if (action.picture &&
             state.doneAt < Date.now() - 1000) {
    return Object.assign({}, state, action, {
      done: [state.picture, ...state.done],
      doneAt: Date.now()
    });
  } else {
    return Object.assign({}, state, action);
  }
}

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

そうでない場合、アクションに新しい画像が含まれており、最後に保存した時点から1秒(1000ミリ秒)以上経過している場合、donedoneAtプロパティは更新され、前の画像が保存されます。

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

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>

何かを描画してみてください。待ちます。

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

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

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

状況は確実に改善していますが、主に欠点を解消するために追加される要素の形式で改善されています。これにより、さらに複雑さが増します。100万のウェブサイトで使用されている機能を本当に置き換えることはできません。たとえ可能であっても、何で置き換えるべきかを決定するのは困難です。

テクノロジーは決して真空の中で存在するものではありません。私たちはツールと、それらを生成した社会的、経済的、歴史的要因によって制約されています。これは迷惑な場合がありますが、既存の技術的現実がどのように機能しているか、そしてなぜそれがそのようなものであるかを理解しようと努力することの方が、それに反対したり、別の現実を期待したりするよりも一般的に生産的です。

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

演習

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

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

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

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

キーボードイベントには、これらのキーが押されているかどうかを確認するために使用できるctrlKeymetaKey(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>

shiftキーが押されていない場合、文字キーのイベントのkeyプロパティは小文字自体になります。shiftキーを押したキーイベントはここでは考慮しません。

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

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

効率的な描画

描画中、アプリケーションが実行する作業の大部分はdrawPictureで行われます。新しい状態を作成し、DOMの残りの部分を更新することは非常に高価ではありませんが、キャンバス上のすべてのピクセルを再描画するのはかなりの作業です。

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

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

また、widthまたはheightプロパティを設定することで<canvas>要素のサイズを変更すると、クリアされ、完全に透明になります。

<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: Object.assign({}, baseTools, {circle})
  });
  document.querySelector("div").appendChild(dom);
</script>

`rectangle`ツールを参考にできます。そのツールと同様に、ポインターが移動したときには、現在の画像ではなく、開始画像に描き続ける必要があります。

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

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

適切な直線

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

ほとんどのブラウザでは、`draw`ツールを選択して画像をすばやくドラッグすると、閉じた線は得られません。`mousemove`イベントまたは`touchmove`イベントが速すぎてすべてのピクセルにヒットしなかったため、間に隙間のある点が得られます。

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

そのためには、ピクセル間の距離が任意であるため、汎用的な直線描画関数を作成する必要があります。

2つのピクセル間の線は、開始点から終了点まで可能な限りまっすぐな、接続されたピクセルの鎖です。対角線上に隣接するピクセルは接続されたものとしてカウントされます。したがって、斜めの線は右側の画像ではなく、左側の画像のように見えるはずです。

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`を使用して取得できる、xとyの差の絶対値を比較してください。

どの軸に沿ってループするかを認識したら、開始点のその軸に沿った座標が終了点よりも高いかどうかを確認し、必要に応じてそれらを交換できます。JavaScriptで2つのバインディングの値を交換する簡潔な方法は、次のようなデストラクチャリング代入を使用することです。

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

次に、線の傾きを計算できます。これは、主軸に沿って取る各ステップに対して、他の軸の座標が変化する量を決定します。これにより、他の軸の対応する位置を追跡しながら、主軸に沿ってループを実行し、各反復でピクセルを描画できます。非主軸座標は分数である可能性が高く、`draw`メソッドは分数座標に適切に応答しないため、非主軸座標を丸めるようにしてください。