第四版现已发布。在此阅读

第 19 章项目:像素画编辑器

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

Joan Miro
Picture of a tiled mosaic

前几章中的内容为您提供了构建基本 Web 应用程序所需的所有元素。在本章中,我们将实现这一点。

我们的应用程序将是一个像素绘制程序,您可以在其中通过操作一个放大的视图(以彩色方格网格显示)来逐像素地修改图像。您可以使用该程序打开图像文件,用鼠标或其他指针设备在图像上涂鸦,以及保存图像。这就是它的外观

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

状态

应用程序状态将是一个具有 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);
  }
}

我们希望能够将图片视为一个不可变的值,原因将在本章稍后解释。但我们有时也需要一次更新大量像素。为了能够做到这一点,该类具有一个 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 Object.assign({}, state, action);
}

这种相当繁琐的模式,其中使用 Object.assign 首先将 state 的属性添加到一个空对象中,然后用 action 中的属性覆盖其中一些属性,在使用不可变对象的 JavaScript 代码中很常见。一种更方便的表示法,其中使用三点运算符将另一个对象中的所有属性包含在对象表达式中,正处于标准化阶段的最后阶段。有了这个补充,您可以写 {...state, ...action} 而不是。在撰写本文时,这在所有浏览器中尚无法使用。

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

我们用 scale 常量定义的 10x10 正方形绘制每个像素。为了避免不必要的操作,组件会跟踪其当前的图片,并且只有在 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 找到画布在屏幕上的位置,因此可以将鼠标事件坐标(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> 元素为我们提供了一个专门用于选择颜色的表单字段。这样的字段的值始终是 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;
}

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

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

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

组件会跟踪当前图片,以便在保存时可以访问它。为了创建图像文件,它会使用一个 <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);
}

我们将图像的大小限制为 100x100 像素,因为任何更大的图像在我们显示器上看起来都会很大,并且可能会降低界面的速度。

getImageData 返回的对象的 data 属性是一个颜色分量的数组。对于由参数指定的矩形中的每个像素,它包含四个值,分别表示像素颜色的红色、绿色、蓝色和alpha分量,以 0 到 255 之间的数字表示。alpha 部分代表不透明度 - 当它为零时,像素完全透明,当它为 255 时,它完全不透明。为了我们的目的,我们可以忽略它。

每个组件的两个十六进制数字,正如我们在颜色符号中使用的,与 0 到 255 范围精确对应 - 两个 16 进制数字可以表示 162 = 256 个不同的数字。数字的 toString 方法可以接受一个基数作为参数,因此 n.toString(16) 将生成一个 16 进制的字符串表示。我们必须确保每个数字都占用两位,因此 hex 辅助函数调用 padStart 在必要时添加一个前导零。

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

撤销历史

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

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

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

但是我们不想存储所有更改,而只存储相隔一定时间的更改。为了能够做到这一点,我们需要第二个属性 doneAt,跟踪我们上次在历史记录中存储图片的时间。

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 设置为零,这样下一个更改保证将图片保存回历史记录中,允许你在需要时再次恢复到它。

否则,如果操作包含一张新图片,并且我们上次存储图片的时间超过一秒(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>

继续画点什么吧。我会等着。

为什么这么难?

浏览器技术令人惊叹。它提供了一套强大的界面构建块,用于设置样式和操作它们的方法,以及用于检查和调试应用程序的工具。你为浏览器编写的软件几乎可以在全球范围内的每台电脑和手机上运行。

与此同时,浏览器技术也很荒谬。你必须学习大量的愚蠢技巧和晦涩的事实才能掌握它,而且它提供的默认编程模型非常有问题,以至于大多数程序员宁愿用几层抽象来覆盖它,而不愿直接处理它。

虽然情况肯定在好转,但它主要以向其添加更多元素来解决缺陷的形式进行 - 创造了更大的复杂性。一个被数百万个网站使用的功能不能真正被替换。即使它可以被替换,也很难决定应该用什么来替换它。

技术永远不会存在于真空中 - 我们受到工具以及产生它们的社会、经济和历史因素的限制。这可能令人讨厌,但通常来说,试图对现有技术现实如何运作以及为什么它会这样 - 比反对它或等待另一个现实更有成效。

新的抽象可以有所帮助。我在本章中使用的组件模型和数据流约定是这种抽象的粗略形式。如前所述,有一些库试图使用户界面编程更愉快。在撰写本文时,ReactAngular 是流行的选择,但还有整个行业都在开发此类框架。如果你有兴趣编写 Web 应用程序,我建议你调查一下这些框架,了解它们是如何工作的以及它们提供了什么好处。

练习

我们的程序还有改进的空间。让我们添加一些额外的功能作为练习。

键盘绑定

向应用程序添加键盘快捷键。工具名称的首字母选择工具,control-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 中。创建新的状态并更新 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 接受一个额外的参数,该参数可以是 undefined 或之前的图片。对于每个像素,该函数检查是否传递了在该位置具有相同颜色的之前的图片,并在该情况下跳过该像素。

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

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

合适的线条

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

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

改进draw工具使其绘制完整线条。这意味着你必须让运动处理函数记住上一个位置并将它连接到当前位置。

要做到这一点,由于像素可以是任意距离,你需要编写一个通用的线绘制函数。

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

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方法对小数坐标的响应并不好。