# 十九、项目:像素艺术编辑器 > 原文:[Project: A Pixel Art Editor](http://eloquentjavascript.net/19_paint.html) > > 译者:[飞龙](https://github.com/wizardforcel) > > 协议:[CC BY-NC-SA 4.0](http://creativecommons.org/licenses/by-nc-sa/4.0/) > > 自豪地采用[谷歌翻译](https://translate.google.cn/) > 我看着眼前的许多颜色。 我看着我的空白画布。 然后,我尝试使用颜色,就像形成诗歌的词语,就像塑造音乐的音符。 > > Joan Miro ![](img/19-0.jpg) 前面几章的内容为你提供了构建基本的 Web 应用所需的所有元素。 在本章中,我们将实现一个。 我们的应用将是像素绘图程序,你可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 你可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子: ![](img/19-1.png) 在电脑上绘画很棒。 你不需要担心材料,技能或天赋。 你只需要开始涂画。 ## 组件 应用的界面在顶部显示大的``元素,在它下面有许多表单字段。 用户通过从``字段。 当前选择的工具决定了,当用户使用指针设备与图片交互时,发生的事情。 它们作为一个对象而提供,该对象将出现在下拉字段中的名称,映射到实现这些工具的函数。 这个函数接受图片位置,当前应用状态和`dispatch`函数作为参数。 它们可能会返回一个移动处理器,当指针移动到另一个像素时,使用新位置和当前状态调用该函数。 ```js 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 元素之间引入空格。 这样他们看起来并不那么密集。 第一个控件是工具选择菜单。 它创建``元素为我们提供了专门用于选择颜色的表单字段。 这种字段的值始终是`"#RRGGBB"`格式(红色,绿色和蓝色分量,每种颜色两位数字)的 CSS 颜色代码。 当用户与它交互时,浏览器将显示一个颜色选择器界面。 该控件创建这样一个字段,并将其连接起来,与应用状态的`color`属性保持同步。 ```js 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; } } ``` ## 绘图工具 在我们绘制任何东西之前,我们需要实现一些工具,来控制画布上的鼠标或触摸事件的功能。 最基本的工具是绘图工具,它可以将你点击或轻触的任何像素,更改为当前选定的颜色。 它分派一个动作,将图片更新为一个版本,其中所指的像素赋为当前选定的颜色。 ```js 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`函数,但也会返回它,以便在用户在图片上拖动或滑动时,再次为新的所触摸的像素调用。 为了绘制较大的形状,可以快速创建矩形。 矩形工具在开始拖动的点和拖动到的点之间画一个矩形。 ```js 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; } ``` 此实现中的一个重要细节是,拖动时,矩形将从原始状态重新绘制在图片上。 这样,你可以在创建矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的原因之一 - 稍后我们会看到另一个原因。 实现洪水填充涉及更多东西。 这是一个工具,填充和指针下的像素,和颜色相同的所有相邻像素。 “相邻”是指水平或垂直直接相邻,而不是对角线。 此图片表明,在标记像素处使用填充工具时,着色的一组像素: ![](img/19-2.svg) 有趣的是,我们的实现方式看起来有点像第 7 章中的寻路代码。那个代码搜索图来查找路线,但这个代码搜索网格来查找所有“连通”的像素。 跟踪一组可能的路线的问题是类似的。 ```js 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)}); } ``` 绘制完成的像素的数组可以兼作函数的工作列表。 对于每个到达的像素,我们必须看看任何相邻的像素是否颜色相同,并且尚未覆盖。 随着新像素的添加,循环计数器落后于绘制完成的数组的长度。 任何前面的像素仍然需要探索。 当它赶上长度时,没有剩下未探测的像素,并且该函数就完成了。 最终的工具是一个颜色选择器,它允许你指定图片中的颜色,来将其用作当前的绘图颜色。 ```js function pick(pos, state, dispatch) { dispatch({color: state.picture.pixel(pos.x, pos.y)}); } ``` 我们现在可以测试我们的应用了! ```html
``` ## 保存和加载 当我们画出我们的杰作时,我们会想要保存它以备后用。 我们应该添加一个按钮,用于将当前图片下载为图片文件。 这个控件提供了这个按钮: ```js 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`元素上的`toDataURL`方法创建一个以`data:`开头的 URL。 与`http:`和`https:`的 URL 不同,数据 URL 在 URL 中包含整个资源。 它们通常很长,但它们允许我们在浏览器中,创建任意图片的可用链接。 为了让浏览器真正下载图片,我们将创建一个链接元素,指向此 URL 并具有`download`属性。 点击这些链接后,浏览器将显示一个文件保存对话框。 我们将该链接添加到文档,模拟点击它,然后再将其删除。 你可以使用浏览器技术做很多事情,但有时候做这件事的方式很奇怪。 并且情况变得更糟了。 我们也希望能够将现有的图像文件加载到我们的应用中。 为此,我们再次定义一个按钮组件。 ```js 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 可用于创建``元素,但由于我们无法直接访问此类图像中的像素,因此我们无法从中创建`Picture`对象。 ```js 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`对象。 ```js 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`,跟踪我们上次在历史中存储图片的时间。 ```js 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 毫秒),会更新`done`和`doneAt`属性来存储上一张图片。 撤消按钮组件不会做太多事情。 它在点击时分派撤消操作,并在没有任何可以撤销的东西时禁用自身。 ```js 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`构造器来创建主要组件。 由于我们需要在练习中创建多个编辑器,因此我们首先定义一些绑定。 ```js 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`。 这就是我们在屏幕上获得实际的编辑器的方式: ```js
``` 来吧,画一些东西。 我会等着你。 ## 为什么这个很困难 浏览器技术是惊人的。 它提供了一组强大的界面积木,排版和操作方法,以及检查和调试应用的工具。 你为浏览器编写的软件可以在几乎所有电脑和手机上运行。 与此同时,浏览器技术是荒谬的。 你必须学习大量愚蠢的技巧和难懂的事实才能掌握它,而它提供的默认编程模型非常棘手,大多数程序员喜欢用几层抽象来封装它,而不是直接处理它。 虽然情况肯定有所改善,但它以增加更多元素来解决缺点的方式,改善了它 - 也创造了更多复杂性。 数百万个网站使用的特性无法真正被取代。 即使可能,也很难决定它应该由什么取代。 技术从不存在于真空中 - 我们受到我们的工具,以及产生它们的社会,经济和历史因素的制约。 这可能很烦人,但通常更加有效的是,试图理解现有的技术现实如何发挥作用,以及为什么它是这样 - 而不是对抗它,或者转向另一个现实。 新的抽象可能会有所帮助。 我在本章中使用的组件模型和数据流约定,是一种粗糙的抽象。 如前所述,有些库试图使用户界面编程更愉快。 在编写本文时,React 和 Angular 是主流选择,但是这样的框架带有整个全家桶。 如果你对编写 Web 应用感兴趣,我建议调查其中的一些内容,来了解它们的原理,以及它们提供的好处。 ## 练习 我们的程序还有提升空间。让我们添加一些更多特性作为练习。 ### 键盘绑定 将键盘快捷键添加到应用。 工具名称的第一个字母用于选择工具,而`control-Z`或`command-Z`激活撤消工作。 通过修改`PixelEditor`组件来实现它。 为`
`元素包装添加`tabIndex`属性 0,以便它可以接收键盘焦点。 请注意,与`tabindex`属性对应的属性称为`tabIndex`,`I`大写,我们的`elt`函数需要属性名称。 直接在该元素上注册键盘事件处理器。 这意味着你必须先单击,触摸或按下 TAB 选择应用,然后才能使用键盘与其交互。 请记住,键盘事件具有`ctrlKey`和`metaKey`(用于 Mac 上的`Command`键)属性,你可以使用它们查看这些键是否被按下。 ```html
``` ### 高效绘图 绘图过程中,我们的应用所做的大部分工作都发生在`drawPicture`中。 创建一个新状态并更新 DOM 的其余部分的开销并不是很大,但重新绘制画布上的所有像素是相当大的工作量。 找到一种方法,通过重新绘制实际更改的像素,使`PictureCanvas`的`setState`方法更快。 请记住,`drawPicture`也由保存按钮使用,所以如果你更改它,请确保更改不会破坏旧用途,或者使用不同名称创建新版本。 另请注意,通过设置其`width`或`height`属性来更改``元素的大小,将清除它,使其再次完全透明。 ```html
``` ### 圆 定义一个名为`circle`的工具,当你拖动时绘制一个实心圆。 圆的中心位于拖动或触摸手势开始的位置,其半径由拖动的距离决定。 ```html
``` ### 合适的直线 这是比前两个更高级的练习,它将要求你设计一个有意义的问题的解决方案。 在开始这个练习之前,确保你有充足的时间和耐心,并且不要因最初的失败而感到气馁。 在大多数浏览器上,当你选择绘图工具并快速在图片上拖动时,你不会得到一条闭合直线。 相反,由于`"mousemove"`或`"touchmove"`事件没有快到足以命中每个像素,因此你会得到一些点,在它们之间有空隙。 改进绘制工具,使其绘制完整的直线。 这意味着你必须使移动处理器记住前一个位置,并将其连接到当前位置。 为此,由于像素可以是任意距离,所以你必须编写一个通用的直线绘制函数。 两个像素之间的直线是连接像素的链条,从起点到终点尽可能直。对角线相邻的像素也算作连接。 所以斜线应该看起来像左边的图片,而不是右边的图片。 ![](img/19-3.svg) 如果我们有了代码,它在两个任意点间绘制一条直线,我们不妨继续,并使用它来定义`line`工具,它在拖动的起点和终点之间绘制一条直线。 ```js
```