17 KiB
十九、项目:像素艺术编辑器
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 应用所需的所有元素。 在本章中,我们将实现一个。
我们的应用将是像素绘图程序,您可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 您可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:
在电脑上绘画很棒。 你不需要担心材料,技能或天赋。 你只需要开始涂画。
组件
应用的界面在顶部显示大的<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
方法,接受更新后的像素(具有x
,y
和color
属性的对象)的数组,并创建一个覆盖这些像素的新图像。 此方法使用不带参数的slice
来复制整个像素数组 - 切片的起始位置默认为 0,结束位置为数组的长度。
empty
方法使用我们以前没有见过的两个数组功能。 可以使用数字调用Array
构造器来创建给定长度的空数组。 然后fill
方法可以用于使用给定值填充数组。 这些用于创建一个数组,所有像素具有相同颜色。
颜色存储为字符串,包含传统 CSS 颜色代码 - 一个井号(#
),后跟六个十六进制数字,两个用于红色分量,两个用于绿色分量,两个用于蓝色分量。这是一种有点神秘而不方便的颜色编写方法,但它是 HTML 颜色输入字段使用的格式,并且可以在canva
s绘图上下文的fillColor
属性中使用,所以对于我们在程序中使用颜色的方式,它足够实用。
所有分量都为零的黑色写成"#000000"
,亮粉色看起来像#ff00ff"
,其中红色和蓝色分量的最大值为 255,以十六进制数字写为ff
(a
到f
用作数字 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
来查找画布在屏幕上的位置,所以可以将鼠标事件坐标(clientX
和clientY
)转换为图片坐标。 它们总是向下取舍,以便它们指代特定的像素。
对于触摸事件,我们必须做类似的事情,但使用不同的事件,并确保我们在"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);
};
对于触摸事件,clientX
和clientY
不能直接在事件对象上使用,但我们可以在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;
}