项目:像素画编辑器

我看着眼前的各种颜色。我看着我的空白画布。然后,我尝试将颜色像塑造诗歌的词语一样应用,像塑造音乐的音符一样应用。

Joan Miró
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 方法,用于将它与一个新的状态值同步。该方法接受一个参数,即状态,其类型与构造函数的第一个参数相同。

状态

应用程序状态将是一个带有 picturetoolcolor 属性的对象。图像本身是一个对象,它存储图像的宽度、高度和像素内容。像素存储在一个数组中,从上到下按行排列。

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

我们希望能够将图像视为一个不可变的值,原因将在本章后面解释。但我们有时也需要一次更新大量像素。为了能够做到这一点,该类有一个 draw 方法,它期望一个更新像素的数组——带有 xycolor 属性的对象——并创建一个用这些像素覆盖的新图像。此方法使用不带参数的 slice 来复制整个像素数组——切片的起点默认为 0,终点默认为数组的长度。

empty 方法使用了我们之前没有见过的两个数组功能。Array 构造函数可以调用一个数字来创建一个指定长度的空数组。然后可以使用 fill 方法用给定的值填充此数组。这些用于创建一个数组,其中所有像素都具有相同的颜色。

颜色存储为字符串,其中包含传统的 CSS 颜色代码,这些代码由一个井号 (#) 后跟六个十六进制 (base-16) 数字组成——两个用于红色分量,两个用于绿色分量,两个用于蓝色分量。这是一种有点神秘且不方便的写颜色方式,但它是 HTML 颜色输入字段使用的格式,并且可以在画布绘制上下文的 fillStyle 属性中使用,因此对于我们在该程序中使用颜色方式来说,它足够实用。

黑色,所有分量均为零,写为 "#000000",亮粉色写为 "#ff00ff",其中红色和蓝色分量具有最大值 255,用十六进制数字 (ff) 表示(它们使用 af 来表示数字 10 到 15)。

我们将允许界面将操作作为对象分发,这些对象的属性会覆盖先前状态的属性。颜色字段,当用户更改它时,可以分发一个类似于 {color: field.value} 的对象,从该对象中,此更新函数可以计算一个新的状态。

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

这种模式,其中对象展开用于首先添加现有对象的属性,然后覆盖其中的一些属性,在使用不可变对象的 JavaScript 代码中很常见。

DOM 构建

界面组件的主要功能之一是创建 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>

画布

我们将定义的第一个组件是界面的一部分,它将图像显示为彩色方块网格。此组件负责两件事:显示图像并将该图像上的指针事件传达给应用程序的其他部分。

因此,我们可以将它定义为一个只了解当前图像,而不了解整个应用程序状态的组件。因为它不知道应用程序整体的运行方式,所以它无法直接分发操作。相反,当响应指针事件时,它会调用创建它的代码提供的回调函数,该函数将处理应用程序特定的部分。

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

我们将每个像素绘制为一个 10x10 的正方形,由 scale 常量决定。为了避免不必要的操作,该组件会跟踪其当前图像,并且仅在 syncState 接收了一个新图像时才会重新绘制。

实际的绘制函数根据比例和图像大小设置画布的大小,并用一系列正方形填充它,每个像素一个正方形。

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` 找到画布在屏幕上的位置,因此可以将鼠标事件坐标(`clientX` 和 `clientY`)转换为图片坐标。这些坐标始终向下取整,以便它们引用特定的像素。

对于触摸事件,我们必须执行类似的操作,但要使用不同的事件,并确保在 `“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);
};

对于触摸事件,`clientX` 和 `clientY` 无法直接在事件对象上获取,但我们可以使用 `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>` 元素为我们提供了一个专门用于选择颜色的表单字段。此类字段的值始终是 CSS 颜色代码,格式为 `“#RRGGBB”`(红色、绿色和蓝色分量,每个颜色两位数字)。当用户与它交互时,浏览器将显示一个颜色选择器界面。

此控件会创建这样一个字段,并将其连接起来,使其与应用程序状态的 `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;
}

此实现中一个重要的细节是,在拖动时,矩形会从原始状态开始在图片上重新绘制。这样,您就可以在创建矩形时将其放大和缩小,而不会使中间矩形保留在最终图片中。这是不可变图片对象有用的原因之一——我们将在后面看到另一个原因。

实现洪水填充稍微复杂一些。这是一个工具,可以填充指针下的像素以及所有具有相同颜色的相邻像素。“相邻”是指直接水平或垂直相邻,而不是对角线相邻。这幅图片说明了在标记像素处使用洪水填充工具时着色的像素集。

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

该组件会跟踪当前图片,以便它在保存时可以访问它。为了创建图像文件,它使用了一个 `<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` 属性是一个颜色分量数组。对于由参数指定矩形中的每个像素,它包含四个值,表示像素颜色的红色、绿色、蓝色和alpha 分量,以 0 到 255 之间的数字表示。alpha 部分表示不透明度——当它为 0 时,像素完全透明,当它为 255 时,像素完全不透明。为了我们的目的,我们可以忽略它。

每个分量使用的两位十六进制数字与 0 到 255 的范围完全对应——两位十六进制数字可以表示 162 = 256 个不同的数字。数字的 `toString` 方法可以接收基数作为参数,因此 `n.toString(16)` 将生成一个以 16 为基数的字符串表示形式。我们必须确保每个数字都占用两位数字,因此 `hex` 辅助函数调用 `padStart` 以在必要时添加前导 0。

我们现在可以加载和保存了!在完成之前,只剩下一个功能了。

撤销历史记录

因为编辑过程的一半是犯小错误并纠正它们,所以绘图程序中的一个重要功能是撤销历史记录。

为了能够撤销更改,我们需要存储图片的先前版本。由于图片是不可变的值,因此很容易。但这确实需要应用程序状态中的一个额外字段。

我们将添加一个 done 数组来保存图片的先前版本。维护此属性需要一个更复杂的 state 更新函数,该函数将图片添加到数组中。

但是,我们不想存储所有更改——只存储相隔一定时间的更改。为了能够做到这一点,我们需要一个第二个属性 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 设置为零,以便保证下次更改将图片存储回历史记录中,允许您在需要时再次恢复到该图片。

否则,如果操作包含一张新图片,而我们上次存储内容的时间距离现在超过一秒(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 属性,则 tools 将绑定到 baseTools

这是我们在屏幕上获得实际编辑器的方式

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

继续,画点东西吧。

为什么这么难?

浏览器技术令人惊叹。它提供了一套强大的界面构建块、对它们进行样式化和操作的方法,以及检查和调试应用程序的工具。您为浏览器编写的软件可以在地球上几乎所有计算机和手机上运行。

同时,浏览器技术也很荒谬。您必须学习大量愚蠢的技巧和晦涩的知识才能掌握它,而它提供的默认编程模型存在如此严重的问题,以至于大多数程序员更愿意用几层抽象来掩盖它,而不是直接处理它。

虽然情况确实在改善,但它主要以添加更多元素来解决缺点的形式改善——这会带来更多复杂性。一个被一百万个网站使用的功能不能真正被替换。即使可以替换,也很难决定用什么替换它。

技术永远不会在真空中存在——我们受到工具以及产生这些工具的社会、经济和历史因素的制约。这可能会令人厌烦,但通常,努力建立对现有技术现实如何运作——以及为什么它就是这样——的良好理解,比对它进行愤怒或坚持另一种现实更有成效。

新的抽象可以是有用的。我在本章中使用的组件模型和数据流约定是这种抽象的一种粗略形式。如前所述,有一些库试图使用户界面编程更令人愉快。在撰写本文时,ReactSvelte 是流行的选择,但还有整个框架行业。如果您有兴趣编写 Web 应用程序,我建议您调查其中的一些框架,以了解它们的工作原理以及它们提供的优势。

练习

我们的程序仍然有改进的空间。让我们再添加一些功能作为练习。

键盘绑定

向应用程序添加键盘快捷键。工具名称的第一个字母选择该工具,ctrl-Z 或 command-Z 激活撤销。

通过修改 PixelEditor 组件来完成此操作。向包装的 <div> 元素添加一个 tabIndex 属性 0,以便它可以接收键盘焦点。请注意,对应于 tabindex 属性属性称为 tabIndex(带大写 I),而我们的 elt 函数期望属性名称。直接在该元素上注册键盘事件处理程序。这意味着您必须单击、触摸或使用 Tab 键转到应用程序,然后才能使用键盘与它进行交互。

请记住,键盘事件具有 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 中。创建新的 state 并更新 DOM 的其余部分并不昂贵,但重新绘制画布上的所有像素却是一项相当繁重的工作。

找到一种方法来加快 PictureCanvassyncState 方法,方法是只重新绘制实际更改的像素。

请记住,drawPicture 也被保存按钮使用,因此,如果您更改了它,请确保更改不会破坏旧的用法,或者创建具有不同名称的新版本。

还要注意,通过设置 widthheight 属性来更改 <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 接受一个额外的参数,该参数可以是未定义的,也可以是之前的图片。对于每个像素,该函数都会检查是否通过了具有相同颜色的先前图片,并在该情况下跳过该像素。

由于画布在改变大小后会被清除,因此您还应该避免在旧图片和新图片具有相同大小时触摸其 widthheight 属性。如果它们不同(这将在加载新图片时发生),您可以更改画布大小后将保存旧图片的绑定设置为 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 ** 2) 的和的平方根 (Math.sqrt) 来找出当前指针位置与起始位置之间的距离。然后循环遍历起始位置周围的像素正方形,其边长至少是半径的两倍,并对位于圆半径内的像素进行着色,再次使用勾股定理来找出它们到中心的距离。

确保您不要尝试对位于图片边界之外的像素进行着色。

正确的线条

这是一个比前面三个练习更高级的练习,它需要您设计一个非平凡问题的解决方案。在开始做这个练习之前,请确保您有充足的时间和耐心,并且不要因为最初的失败而气馁。

在大多数浏览器中,当您选择 draw 工具并快速拖动图片时,您不会得到一条封闭的线。相反,您会得到带有间隙的点,因为 "mousemove""touchmove" 事件的触发频率不足以命中每个像素。

改进 draw 工具使其绘制完整的线。这意味着您必须让运动处理程序函数记住先前的位置,并将其连接到当前位置。

要做到这一点,由于像素可以任意距离分开,您必须编写一个通用的线绘制函数。

两个像素之间的直线是一条尽可能直的连接像素链,从起点到终点。对角相邻的像素算作连接。倾斜的线条应该像左边的图片一样,而不是右边的图片。

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

最后,如果我们有绘制两个任意点之间直线的代码,我们也可以用它来定义一个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>
显示提示…

绘制像素化直线的问题在于,它实际上是四个相似但略有不同的问题。从左到右绘制水平线很容易——你遍历 x 坐标,并在每一步都给一个像素着色。如果这条直线有轻微的斜率(小于 45 度或 ¼π 弧度),你可以沿斜率插值 y 坐标。你仍然需要每x位置一个像素,这些像素的y位置由斜率决定。

但一旦你的斜率超过 45 度,你需要改变处理坐标的方式。现在你需要每y位置一个像素,因为这条直线向上移动的距离比向左移动的距离多。然后,当你越过 135 度时,你必须回到遍历 x 坐标,但从右到左。

你实际上不需要写四个循环。由于从AB绘制一条直线与从BA绘制一条直线相同,因此你可以交换从右到左的线条的起点和终点位置,并将它们视为从左到右。

所以你需要两个不同的循环。你的直线绘制函数应该做的第一件事是检查 x 坐标之间的差值是否大于 y 坐标之间的差值。如果是,这是一个水平线,否则是一个垂直线。

确保你比较xy差值的绝对值,你可以使用Math.abs获得它们。

一旦你知道将在哪个轴上循环,你可以检查起点在该轴上的坐标是否高于终点,并在必要时交换它们。在 JavaScript 中交换两个绑定值的简洁方法是使用解构赋值,如下所示

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

然后你可以计算直线的斜率,它决定了你沿着主轴每走一步,另一轴上的坐标变化量。有了它,你可以在主轴上运行一个循环,同时跟踪另一轴上的对应位置,并且你可以在每次迭代时绘制像素。确保你对非主轴坐标进行四舍五入,因为它们很可能是小数,而draw方法对小数坐标没有很好的响应。