1
0
mirror of https://github.com/apachecn/eloquent-js-3e-zh.git synced 2025-05-23 20:02:20 +00:00
wizardforcel 39306cd144 19.
2018-05-13 11:34:04 +08:00

12 KiB
Raw Blame History

十九、项目:像素艺术编辑器

I look at the many colors before me. I look at my blank canvas. Then, I try to apply colors like words that shape poems, like notes that shape music.

Joan Miro

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

我们的应用将是像素绘图程序,您可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 您可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:

在电脑上绘画很棒。 你不需要担心材料,技能或天赋。 你只需要开始涂画。

组件

应用的界面在顶部显示大的<canvas>元素,在它下面有许多表单字段。 用户通过从<select>字段中选择工具,然后单击,触摸或拖动画布来绘制图片。 有用于绘制单个像素或矩形,填充区域以及从图片中选取颜色的工具。

我们将编辑器界面构建为多个组件和对象,负责 DOM 的一部分,并可能在其中包含其他组件。

应用的状态由当前图片,所选工具和所选颜色组成。 我们将建立一些东西,以便状态存在于单一的值中,并且界面组件总是基于当前状态下他们看上去的样子。

为了明白为什么这很重要,让我们考虑替代方案:将状态片段分配给整个界面。 直到某个时期,这更容易编写。 我们可以放入颜色字段,并在需要知道当前颜色时读取其值。

但是,我们添加了颜色选择器。它是一种工具,可让您单击图片来选择给定像素的颜色。 为了保持颜色字段显示正确的颜色,该工具必须知道它存在,并在每次选择新颜色时对其进行更新。 如果你添加了另一个让颜色可见的地方(也许鼠标光标可以显示它),你必须更新你的改变颜色的代码来保持同步。

实际上,这会让你遇到一个问题,即界面的每个部分都需要知道所有其他部分,它们并不是非常模块化的。 对于本章中的小应用,这可能不成问题。 对于更大的项目,它可能变成真正的噩梦。

所以为了在原则上避免这种噩梦,我们将对数据流非常严格。 存在一个状态,界面根据该状态绘制。 界面组件可以通过更新状态来响应用户动作,此时组件有机会与新的状态进行同步。

在实践中,每个组件的建立,都是为了在给定一个新的状态时,它还会通知它的子组件,只要这些组件需要更新。 建立这个有点麻烦。 让这个更方便是许多浏览器编程库的主要卖点。 但对于像这样的小应用,我们可以在没有这种基础设施的情况下完成。

状态更新表示为对象,我们将其称为动作。 组件可以创建这样的动作并分派它们 - 将它们给予中央状态管理函数。 该函数计算下一个状态,之后界面组件将自己更新为这个新状态。

我们正在执行一个混乱的任务,运行一个用户界面并对其应用一些结构。 尽管与 DOM 相关的部分仍然充满了副作用,但它们由一个概念上简单的主干支撑 - 状态更新循环。 状态决定了 DOM 的外观,而 DOM 事件可以改变状态的唯一方法,是向状态分派动作。

这种方法有许多变种,每个变种都有自己的好处和问题,但它们的中心思想是一样的:状态变化应该通过明确定义的渠道,而不是遍布整个地方。

我们的组件将是与界面一致的类。 他们的构造函数被赋予一个状态,它可能是整个应用状态,或者如果它不需要访问所有东西,是一些较小的值,并使用它构建一个dom属性,也就是表示组件的 DOM。 大多数构造函数还会接受一些其他值,这些值不会随着时间而改变,例如它们可用于分派操作的函数。

每个组件都有一个setState方法,用于将其同步到新的状态值。 该方法接受一个参数,该参数的类型与构造函数的第一个参数的类型相同。

状态

应用状态将是一个带有图片,工具和颜色属性的对象。 图片本身就是一个对象,存储图片的宽度,高度和像素内容。 像素逐行存储在一个数组中,方式与第 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 颜色代码 - 一个井号(#),后跟六个十六进制数字,两个用于红色分量,两个用于绿色分量,两个用于蓝色分量。这是一种有点神秘而不方便的颜色编写方法,但它是 HTML 颜色输入字段使用的格式,并且可以在canvas绘图上下文的fillColor属性中使用,所以对于我们在程序中使用颜色的方式,它足够实用。

所有分量都为零的黑色写成"#000000",亮粉色看起来像#ff00ff",其中红色和蓝色分量的最大值为 255以十六进制数字写为ffaf用作数字 10 到 15

我们将允许界面将动作分派为对象,它是属性覆盖先前状态的属性。当用户改变颜色字段时,颜色字段可以分派像{color: field.value}这样的对象,从这个对象可以计算出一个新的状态。

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

这是相当麻烦的模式,其中Object.assign用于首先将状态属性添加到空对象,然后使用来自动作的属性覆盖其中的一些属性,这在使用不可变对象的 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 章中使用的版本之间的主要区别在于它将属性property分配给 DOM 节点而不是属性attribute。 这意味着我们不能用它来设置任意属性attribute但是我们可以用它来设置值不是字符串的属性property比如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)
    });
    drawPicture(picture, this.dom, scale);
  }
  setState(picture) {
    if (this.picture == picture) return;
    this.picture = picture;
    drawPicture(this.picture, this.dom, scale);
  }
}

我们将每个像素绘制成一个10x10的正方形,由比例常数决定。 为了避免不必要的工作,该组件会跟踪其当前图片,并且仅当将setState赋予新图片时才会重绘。

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

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属性中使用第一个触摸对象的坐标。