mirror of
https://github.com/apachecn/eloquent-js-3e-zh.git
synced 2025-05-23 20:02:20 +00:00
19.
This commit is contained in:
parent
70e1e2aa82
commit
8e47d083e8
225
19.md
225
19.md
@ -276,7 +276,7 @@ class ToolSelect {
|
||||
|
||||
我们还需要能够改变颜色 - 所以让我们添加一个控件。 `type`属性为颜色的 HTML `<input>`元素为我们提供了专门用于选择颜色的表单字段。 这种字段的值始终是`"#RRGGBB"`格式(红色,绿色和蓝色分量,每种颜色两位数字)的 CSS 颜色代码。 当用户与它交互时,浏览器将显示一个颜色选择器界面。
|
||||
|
||||
该控件创建这样一个字段,并将其连接起来,与应用程序状态的`color`属性保持同步。
|
||||
该控件创建这样一个字段,并将其连接起来,与应用状态的`color`属性保持同步。
|
||||
|
||||
```js
|
||||
class ColorSelect {
|
||||
@ -332,3 +332,226 @@ function rectangle(start, state, dispatch) {
|
||||
return drawRectangle;
|
||||
}
|
||||
```
|
||||
|
||||
此实现中的一个重要细节是,拖动时,矩形将从原始状态重新绘制在图片上。 这样,您可以在创建矩形时将矩形再次放大和缩小,中间的矩形不会在最终图片中残留。 这是不可变图片对象实用的原因之一 - 稍后我们会看到另一个原因。
|
||||
|
||||
实现洪水填充涉及更多东西。 这是一个工具,填充和指针下的像素,和颜色相同的所有相邻像素。 “相邻”是指水平或垂直直接相邻,而不是对角线。 此图片表明,在标记像素处使用填充工具时,着色的一组像素:
|
||||
|
||||
![]()
|
||||
|
||||
有趣的是,我们的实现方式看起来有点像第 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
|
||||
<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>
|
||||
```
|
||||
|
||||
## 保存和加载
|
||||
|
||||
当我们画出我们的杰作时,我们会想要保存它以备后用。 我们应该添加一个按钮,用于将当前图片下载为图片文件。 这个控件提供了这个按钮:
|
||||
|
||||
```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>`元素来绘制图片(一比一的像素比例)。
|
||||
|
||||
`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 可用于创建`<img>`元素,但由于我们无法直接访问此类图像中的像素,因此我们无法从中创建`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>`元素。 `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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user