保存和加载

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

  1. class SaveButton {
  2. constructor(state) {
  3. this.picture = state.picture;
  4. this.dom = elt("button", {
  5. onclick: () => this.save()
  6. }, "\u{1f4be} Save");
  7. }
  8. save() {
  9. let canvas = elt("canvas");
  10. drawPicture(this.picture, canvas, 1);
  11. let link = elt("a", {
  12. href: canvas.toDataURL(),
  13. download: "pixelart.png"
  14. });
  15. document.body.appendChild(link);
  16. link.click();
  17. link.remove();
  18. }
  19. setState(state) { this.picture = state.picture; }
  20. }

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

canvas元素上的toDataURL方法创建一个以data:开头的 URL。 与http:https:的 URL 不同,数据 URL 在 URL 中包含整个资源。 它们通常很长,但它们允许我们在浏览器中,创建任意图片的可用链接。

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

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

并且情况变得更糟了。 我们也希望能够将现有的图像文件加载到我们的应用中。 为此,我们再次定义一个按钮组件。

  1. class LoadButton {
  2. constructor(_, {dispatch}) {
  3. this.dom = elt("button", {
  4. onclick: () => startLoad(dispatch)
  5. }, "\u{1f4c1} Load");
  6. }
  7. setState() {}
  8. }
  9. function startLoad(dispatch) {
  10. let input = elt("input", {
  11. type: "file",
  12. onchange: () => finishLoad(input.files[0], dispatch)
  13. });
  14. document.body.appendChild(input);
  15. input.click();
  16. input.remove();
  17. }

为了访问用户计算机上的文件,我们需要用户通过文件输入字段选择文件。 但我不希望加载按钮看起来像文件输入字段,所以我们在单击按钮时创建文件输入,然后假装它自己被单击。

当用户选择一个文件时,我们可以使用FileReader访问其内容,并再次作为数据 URL。 该 URL 可用于创建<img>元素,但由于我们无法直接访问此类图像中的像素,因此我们无法从中创建Picture对象。

  1. function finishLoad(file, dispatch) {
  2. if (file == null) return;
  3. let reader = new FileReader();
  4. reader.addEventListener("load", () => {
  5. let image = elt("img", {
  6. onload: () => dispatch({
  7. picture: pictureFromImage(image)
  8. }),
  9. src: reader.result
  10. });
  11. });
  12. reader.readAsDataURL(file);
  13. }

为了访问像素,我们必须先将图片绘制到<canvas>元素。 canvas上下文有一个getImageData方法,允许脚本读取其像素。 所以一旦图片在画布上,我们就可以访问它并构建一个Picture对象。

  1. function pictureFromImage(image) {
  2. let width = Math.min(100, image.width);
  3. let height = Math.min(100, image.height);
  4. let canvas = elt("canvas", {width, height});
  5. let cx = canvas.getContext("2d");
  6. cx.drawImage(image, 0, 0);
  7. let pixels = [];
  8. let {data} = cx.getImageData(0, 0, width, height);
  9. function hex(n) {
  10. return n.toString(16).padStart(2, "0");
  11. }
  12. for (let i = 0; i < data.length; i += 4) {
  13. let [r, g, b] = data.slice(i, i + 3);
  14. pixels.push("#" + hex(r) + hex(g) + hex(b));
  15. }
  16. return new Picture(width, height, pixels);
  17. }

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

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

在我们的颜色符号中,为每个分量使用的两个十六进制数字,正好对应于 0 到 255 的范围 - 两个十六进制数字可以表示16**2 = 256个不同的数字。 数字的toString方法可以传入进制作为参数,所以n.toString(16)将产生十六进制的字符串表示。我们必须确保每个数字都占用两位数,所以十六进制的辅助函数调用padStart,在必要时添加前导零。

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