提交 f533a446 编写于 作者: W wizardforcel

19.

上级 8e47d083
# 十九、项目:像素艺术编辑器
> 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 应用所需的所有元素。 在本章中,我们将实现一个。
前面几章的内容为提供了构建基本的 Web 应用所需的所有元素。 在本章中,我们将实现一个。
我们的应用将是像素绘图程序,您可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 您可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:
我们的应用将是像素绘图程序,你可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 你可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:
![]()
......@@ -24,7 +24,7 @@
为了明白为什么这很重要,让我们考虑替代方案:将状态片段分配给整个界面。 直到某个时期,这更容易编写。 我们可以放入颜色字段,并在需要知道当前颜色时读取其值。
但是,我们添加了颜色选择器。它是一种工具,可让单击图片来选择给定像素的颜色。 为了保持颜色字段显示正确的颜色,该工具必须知道它存在,并在每次选择新颜色时对其进行更新。 如果你添加了另一个让颜色可见的地方(也许鼠标光标可以显示它),你必须更新你的改变颜色的代码来保持同步。
但是,我们添加了颜色选择器。它是一种工具,可让单击图片来选择给定像素的颜色。 为了保持颜色字段显示正确的颜色,该工具必须知道它存在,并在每次选择新颜色时对其进行更新。 如果你添加了另一个让颜色可见的地方(也许鼠标光标可以显示它),你必须更新你的改变颜色的代码来保持同步。
实际上,这会让你遇到一个问题,即界面的每个部分都需要知道所有其他部分,它们并不是非常模块化的。 对于本章中的小应用,这可能不成问题。 对于更大的项目,它可能变成真正的噩梦。
......@@ -272,7 +272,7 @@ class ToolSelect {
}
```
通过将标签文本和字段包装在`<label>`元素中,我们告诉浏览器该标签属于该字段,例如,可以点击标签来聚焦该字段。
通过将标签文本和字段包装在`<label>`元素中,我们告诉浏览器该标签属于该字段,例如,可以点击标签来聚焦该字段。
我们还需要能够改变颜色 - 所以让我们添加一个控件。 `type`属性为颜色的 HTML `<input>`元素为我们提供了专门用于选择颜色的表单字段。 这种字段的值始终是`"#RRGGBB"`格式(红色,绿色和蓝色分量,每种颜色两位数字)的 CSS 颜色代码。 当用户与它交互时,浏览器将显示一个颜色选择器界面。
......@@ -333,7 +333,7 @@ function rectangle(start, state, dispatch) {
}
```
此实现中的一个重要细节是,拖动时,矩形将从原始状态重新绘制在图片上。 这样,可以在创建矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的原因之一 - 稍后我们会看到另一个原因。
此实现中的一个重要细节是,拖动时,矩形将从原始状态重新绘制在图片上。 这样,可以在创建矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的原因之一 - 稍后我们会看到另一个原因。
实现洪水填充涉及更多东西。 这是一个工具,填充和指针下的像素,和颜色相同的所有相邻像素。 “相邻”是指水平或垂直直接相邻,而不是对角线。 此图片表明,在标记像素处使用填充工具时,着色的一组像素:
......@@ -365,7 +365,7 @@ function fill({x, y}, state, dispatch) {
绘制完成的像素的数组可以兼作函数的工作列表。 对于每个到达的像素,我们必须看看任何相邻的像素是否颜色相同,并且尚未覆盖。 随着新像素的添加,循环计数器落后于绘制完成的数组的长度。 任何前面的像素仍然需要探索。 当它赶上长度时,没有剩下未探测的像素,并且该函数就完成了。
最终的工具是一个颜色选择器,它允许指定图片中的颜色,来将其用作当前的绘图颜色。
最终的工具是一个颜色选择器,它允许指定图片中的颜色,来将其用作当前的绘图颜色。
```js
function pick(pos, state, dispatch) {
......@@ -428,7 +428,7 @@ class SaveButton {
为了让浏览器真正下载图片,我们将创建一个链接元素,指向此 URL 并具有`download`属性。 点击这些链接后,浏览器将显示一个文件保存对话框。 我们将该链接添加到文档,模拟点击它,然后再将其删除。
可以使用浏览器技术做很多事情,但有时候做这件事的方式很奇怪。
可以使用浏览器技术做很多事情,但有时候做这件事的方式很奇怪。
并且情况变得更糟了。 我们也希望能够将现有的图像文件加载到我们的应用中。 为此,我们再次定义一个按钮组件。
......@@ -555,3 +555,205 @@ class UndoButton {
}
```
## 让我们绘图吧
为了建立应用,我们需要创建一个状态,一组工具,一组控件和一个分派函数。 我们可以将它们传递给`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
<div></div>
<script>
document.querySelector("div")
.appendChild(startPixelEditor({}));
</script>
```
来吧,画一些东西。 我会等着你。
## 为什么这个很困难
浏览器技术是惊人的。 它提供了一组强大的界面积木,排版和操作方法,以及检查和调试应用的工具。 你为浏览器编写的软件可以在几乎所有电脑和手机上运行。
与此同时,浏览器技术是荒谬的。 你必须学习大量愚蠢的技巧和难懂的事实才能掌握它,而它提供的默认编程模型非常棘手,大多数程序员喜欢用几层抽象来封装它,而不是直接处理它。
虽然情况肯定有所改善,但它以增加更多元素来解决缺点的方式,改善了它 - 也创造了更多复杂性。 数百万个网站使用的特性无法真正被取代。 即使可能,也很难决定它应该由什么取代。
技术从不存在于真空中 - 我们受到我们的工具,以及产生它们的社会,经济和历史因素的制约。 这可能很烦人,但通常更加有效的是,试图理解现有的技术现实如何发挥作用,以及为什么它是这样 - 而不是对抗它,或者转向另一个现实。
新的抽象可能会有所帮助。 我在本章中使用的组件模型和数据流约定,是一种粗糙的抽象。 如前所述,有些库试图使用户界面编程更愉快。 在编写本文时,React 和 Angular 是主流选择,但是这样的框架带有整个全家桶。 如果你对编写 Web 应用感兴趣,我建议调查其中的一些内容,来了解它们的原理,以及它们提供的好处。
## 练习
我们的程序还有提升空间。让我们添加一些更多特性作为练习。
### 键盘绑定
将键盘快捷键添加到应用。 工具名称的第一个字母用于选择工具,而`control-Z``command-Z`激活撤消工作。
通过修改`PixelEditor`组件来实现它。 为`<div>`元素包装添加`tabIndex`属性 0,以便它可以接收键盘焦点。 请注意,与`tabindex`属性对应的属性称为`tabIndex``I`大写,我们的`elt`函数需要属性名称。 直接在该元素上注册键盘事件处理器。 这意味着你必须先单击,触摸或按下 TAB 选择应用,然后才能使用键盘与其交互。
请记住,键盘事件具有`ctrlKey``metaKey`(用于 Mac 上的`Command`键)属性,你可以使用它们查看这些键是否被按下。
```html
<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 的其余部分的开销并不是很大,但重新绘制画布上的所有像素是相当大的工作量。
找到一种方法,通过重新绘制实际更改的像素,使`PictureCanvas``setState`方法更快。
请记住,`drawPicture`也由保存按钮使用,所以如果你更改它,请确保更改不会破坏旧用途,或者使用不同名称创建新版本。
另请注意,通过设置其`width``height`属性来更改`<canvas>`元素的大小,将清除它,使其再次完全透明。
```html
<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`的工具,当你拖动时绘制一个实心圆。 圆的中心位于拖动或触摸手势开始的位置,其半径由拖动的距离决定。
```html
<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`工具,它在拖动的起点和终点之间绘制一条直线。
```js
<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>
```
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册