JavaScript 编程精解 中文第三版 十9、项目:像素艺术编辑器

来源: ApacheCN『JavaScript 编程精解 中文第三版』翻译项目

原文:Project: A Pixel Art Editorjavascript

译者:飞龙html

协议:CC BY-NC-SA 4.0java

自豪地采用谷歌翻译git

我看着眼前的许多颜色。 我看着个人空白画布。 而后,我尝试使用颜色,就像造成诗歌的词语,就像塑造音乐的音符。程序员

Joan Mirogithub

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

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

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

组件

应用的界面在顶部显示大的<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属性中使用第一个触摸对象的坐标。

应用

为了可以逐步构建应用,咱们将主要组件实现为画布周围的外壳,以及一组动态工具和控件,咱们将其传递给其构造器。

控件是出如今图片下方的界面元素。 它们为组件构造器的数组而提供。

工具是绘制像素或填充区域的东西。 该应用将一组可用工具显示为<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), []));
  }
  setState(state) {
    this.state = state;
    this.canvas.setState(state.picture);
    for (let ctrl of this.controls) ctrl.setState(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);
  }
  setState(state) { this.select.value = state.tool; }
}

经过将标签文本和字段包装在<label>元素中,咱们告诉浏览器该标签属于该字段,例如,你能够点击标签来聚焦该字段。

咱们还须要可以改变颜色 - 因此让咱们添加一个控件。 type属性为颜色的 HTML <input>元素为咱们提供了专门用于选择颜色的表单字段。 这种字段的值始终是"#RRGGBB"格式(红色,绿色和蓝色份量,每种颜色两位数字)的 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);
  }
  setState(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函数,但也会返回它,以便在用户在图片上拖动或滑动时,再次为新的所触摸的像素调用。

为了绘制较大的形状,能够快速建立矩形。 矩形工具在开始拖动的点和拖动到的点之间画一个矩形。

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

此实现中的一个重要细节是,拖动时,矩形将从原始状态从新绘制在图片上。 这样,你能够在建立矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的缘由之一 - 稍后咱们会看到另外一个缘由。

实现洪水填充涉及更多东西。 这是一个工具,填充和指针下的像素,和颜色相同的全部相邻像素。 “相邻”是指水平或垂直直接相邻,而不是对角线。 此图片代表,在标记像素处使用填充工具时,着色的一组像素:

有趣的是,咱们的实现方式看起来有点像第 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)});
}

绘制完成的像素的数组能够兼做函数的工做列表。 对于每一个到达的像素,咱们必须看看任何相邻的像素是否颜色相同,而且还没有覆盖。 随着新像素的添加,循环计数器落后于绘制完成的数组的长度。 任何前面的像素仍然须要探索。 当它遇上长度时,没有剩下未探测的像素,而且该函数就完成了。

最终的工具是一个颜色选择器,它容许你指定图片中的颜色,来将其用做当前的绘图颜色。

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.setState(state);
    }
  });
  document.querySelector("div").appendChild(app.dom);
</script>

保存和加载

当咱们画出咱们的杰做时,咱们会想要保存它以备后用。 咱们应该添加一个按钮,用于将当前图片下载为图片文件。 这个控件提供了这个按钮:

class SaveButton {
  constructor(state) {
    this.picture = state.picture;
    this.dom = elt("button", {
      onclick: () => this.save()
    }, "\u{1f4be} 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();
  }
  setState(state) { this.picture = state.picture; }
}

组件会跟踪当前图片,以便在保存时能够访问它。 为了建立图像文件,它使用<canvas>元素来绘制图片(一比一的像素比例)。

canvas元素上的toDataURL方法建立一个以data:开头的 URL。 与http:https:的 URL 不一样,数据 URL 在 URL 中包含整个资源。 它们一般很长,但它们容许咱们在浏览器中,建立任意图片的可用连接。

为了让浏览器真正下载图片,咱们将建立一个连接元素,指向此 URL 并具备download属性。 点击这些连接后,浏览器将显示一个文件保存对话框。 咱们将该连接添加到文档,模拟点击它,而后再将其删除。

你可使用浏览器技术作不少事情,但有时候作这件事的方式很奇怪。

而且状况变得更糟了。 咱们也但愿可以将现有的图像文件加载到咱们的应用中。 为此,咱们再次定义一个按钮组件。

class LoadButton {
  constructor(_, {dispatch}) {
    this.dom = elt("button", {
      onclick: () => startLoad(dispatch)
    }, "\u{1f4c1} Load");
  }
  setState() {}
}

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>元素。 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×100像素,由于任何更大的图像在咱们的显示器上看起来都很大,而且可能会拖慢界面。

getImageData返回的对象的data属性,是一个颜色份量的数组。 对于由参数指定的矩形中的每一个像素,它包含四个值,分别表示像素颜色的红色,绿色,蓝色和 alpha 份量,数字介于 0 和 255 之间。alpha 份量表示不透明度 - 当它是零时像素是彻底透明的,当它是 255 时,它是彻底不透明的。出于咱们的目的,咱们能够忽略它。

在咱们的颜色符号中,为每一个份量使用的两个十六进制数字,正好对应于 0 到 255 的范围 - 两个十六进制数字能够表示16**2 = 256个不一样的数字。 数字的toString方法能够传入进制做为参数,因此n.toString(16)将产生十六进制的字符串表示。咱们必须确保每一个数字都占用两位数,因此十六进制的辅助函数调用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);
  }
}

当动做是撤消动做时,该函数将从历史中获取最近的图片,并生成当前图片。

或者,若是动做包含新图片,而且上次存储东西的时间超过了一秒(1000 毫秒),会更新donedoneAt属性来存储上一张图片。

撤消按钮组件不会作太多事情。 它在点击时分派撤消操做,并在没有任何能够撤销的东西时禁用自身。

class UndoButton {
  constructor(state, {dispatch}) {
    this.dom = elt("button", {
      onclick: () => dispatch({undo: true}),
      disabled: state.done.length == 0
    }, "⮪ Undo");
  }
  setState(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.setState(state);
    }
  });
  return app.dom;
}

解构对象或数组时,能够在绑定名称后面使用=,来为绑定指定默认值,该属性在缺失或未定义时使用。 startPixelEditor函数利用它来接受一个对象,包含许多可选属性做为参数。 例如,若是你未提供tools属性,则tools将绑定到baseTools

这就是咱们在屏幕上得到实际的编辑器的方式:

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

来吧,画一些东西。 我会等着你。

为何这个很困难

浏览器技术是惊人的。 它提供了一组强大的界面积木,排版和操做方法,以及检查和调试应用的工具。 你为浏览器编写的软件能够在几乎全部电脑和手机上运行。

与此同时,浏览器技术是荒谬的。 你必须学习大量愚蠢的技巧和难懂的事实才能掌握它,而它提供的默认编程模型很是棘手,大多数程序员喜欢用几层抽象来封装它,而不是直接处理它。

虽然状况确定有所改善,但它以增长更多元素来解决缺点的方式,改善了它 - 也创造了更多复杂性。 数百万个网站使用的特性没法真正被取代。 即便可能,也很难决定它应该由什么取代。

技术从不存在于真空中 - 咱们受到咱们的工具,以及产生它们的社会,经济和历史因素的制约。 这可能很烦人,但一般更加有效的是,试图理解现有的技术现实如何发挥做用,以及为何它是这样 - 而不是对抗它,或者转向另外一个现实。

新的抽象可能会有所帮助。 我在本章中使用的组件模型和数据流约定,是一种粗糙的抽象。 如前所述,有些库试图使用户界面编程更愉快。 在编写本文时,React 和 Angular 是主流选择,可是这样的框架带有整个全家桶。 若是你对编写 Web 应用感兴趣,我建议调查其中的一些内容,来了解它们的原理,以及它们提供的好处。

练习

咱们的程序还有提高空间。让咱们添加一些更多特性做为练习。

键盘绑定

将键盘快捷键添加到应用。 工具名称的第一个字母用于选择工具,而control-Zcommand-Z激活撤消工做。

经过修改PixelEditor组件来实现它。 为<div>元素包装添加tabIndex属性 0,以便它能够接收键盘焦点。 请注意,与tabindex属性对应的属性称为tabIndexI大写,咱们的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), []));
    }
    setState(state) {
      this.state = state;
      this.canvas.setState(state.picture);
      for (let ctrl of this.controls) ctrl.setState(state);
    }
  }

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

高效绘图

绘图过程当中,咱们的应用所作的大部分工做都发生在drawPicture中。 建立一个新状态并更新 DOM 的其他部分的开销并非很大,但从新绘制画布上的全部像素是至关大的工做量。

找到一种方法,经过从新绘制实际更改的像素,使PictureCanvassetState方法更快。

请记住,drawPicture也由保存按钮使用,因此若是你更改它,请确保更改不会破坏旧用途,或者使用不一样名称建立新版本。

另请注意,经过设置其widthheight属性来更改<canvas>元素的大小,将清除它,使其再次彻底透明。

<div></div>
<script>
  // Change this method
  PictureCanvas.prototype.setState = 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>

定义一个名为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>

合适的直线

这是比前两个更高级的练习,它将要求你设计一个有意义的问题的解决方案。 在开始这个练习以前,确保你有充足的时间和耐心,而且不要因最初的失败而感到气馁。

在大多数浏览器上,当你选择绘图工具并快速在图片上拖动时,你不会获得一条闭合直线。 相反,因为"mousemove""touchmove"事件没有快到足以命中每一个像素,所以你会获得一些点,在它们之间有空隙。

改进绘制工具,使其绘制完整的直线。 这意味着你必须使移动处理器记住前一个位置,并将其链接到当前位置。

为此,因为像素能够是任意距离,因此你必须编写一个通用的直线绘制函数。

两个像素之间的直线是链接像素的链条,从起点到终点尽量直。对角线相邻的像素也算做链接。 因此斜线应该看起来像左边的图片,而不是右边的图片。

若是咱们有了代码,它在两个任意点间绘制一条直线,咱们不妨继续,并使用它来定义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>
相关文章
相关标签/搜索