摘要:相反,当响应指针事件时,它会调用创建它的代码提供的回调函数,该函数将处理应用的特定部分。回调函数可能会返回另一个回调函数,以便在按下按钮并且将指针移动到另一个像素时得到通知。它们为组件构造器的数组而提供。
来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Project: A Pixel Art Editor
译者:飞龙
协议:CC BY-NC-SA 4.0
自豪地采用谷歌翻译
我看着眼前的许多颜色。 我看着我的空白画布。 然后,我尝试使用颜色,就像形成诗歌的词语,就像塑造音乐的音符。
Joan Miro
前面几章的内容为你提供了构建基本的 Web 应用所需的所有元素。 在本章中,我们将实现一个。
我们的应用将是像素绘图程序,你可以通过操纵放大视图(正方形彩色网格),来逐像素修改图像。 你可以使用它来打开图像文件,用鼠标或其他指针设备在它们上面涂画并保存。 这是它的样子:
在电脑上绘画很棒。 你不需要担心材料,技能或天赋。 你只需要开始涂画。
组件应用的界面在顶部显示大的元素,在它下面有许多表单字段。 用户通过从字段中选择工具,然后单击,触摸或拖动画布来绘制图片。 有用于绘制单个像素或矩形,填充区域以及从图片中选取颜色的工具。
我们将编辑器界面构建为多个组件和对象,负责 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 颜色输入字段使用的格式,并且可以在canvas绘图上下文的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,可以将它设置为一个函数,来注册点击事件处理器。
这允许这种注册事件处理器的方式:
画布
我们要定义的第一个组件是界面的一部分,它将图片显示为彩色框的网格。 该组件负责两件事:显示图片并将该图片上的指针事件传给应用的其余部分。
因此,我们可以将其定义为仅了解当前图片,而不是整个应用状态的组件。 因为它不知道整个应用是如何工作的,所以不能直接发送操作。 相反,当响应指针事件时,它会调用创建它的代码提供的回调函数,该函数将处理应用的特定部分。
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属性中使用第一个触摸对象的坐标。
应用为了能够逐步构建应用,我们将主要组件实现为画布周围的外壳,以及一组动态工具和控件,我们将其传递给其构造器。
控件是出现在图片下方的界面元素。 它们为组件构造器的数组而提供。
工具是绘制像素或填充区域的东西。 该应用将一组可用工具显示为字段。 当前选择的工具决定了,当用户使用指针设备与图片交互时,发生的事情。 它们作为一个对象而提供,该对象将出现在下拉字段中的名称,映射到实现这些工具的函数。 这个函数接受图片位置,当前应用状态和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 元素之间引入空格。 这样他们看起来并不那么密集。
第一个控件是工具选择菜单。 它创建元素,每个工具带有一个选项,并设置"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, "
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/105044.html
摘要:事件与节点每个浏览器事件处理器被注册在上下文中。事件对象虽然目前为止我们忽略了它,事件处理器函数作为对象传递事件对象。若事件处理器不希望执行默认行为通常是因为已经处理了该事件,会调用事件对象的方法。 来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Handling Events 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分...
摘要:来源编程精解中文第三版翻译项目原文译者飞龙协议自豪地采用谷歌翻译部分参考了编程精解第版,这是一本关于指导电脑的书。在可控的范围内编写程序是编程过程中首要解决的问题。我们可以用中文来描述这些指令将数字存储在内存地址中的位置。 来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Introduction 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地...
摘要:来源编程精解中文第三版翻译项目原文译者飞龙协议自豪地采用谷歌翻译部分参考了编程精解第版技能分享会是一个活动,其中兴趣相同的人聚在一起,针对他们所知的事情进行小型非正式的展示。所有接口均以路径为中心。 来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Project: Skill-Sharing Website 译者:飞龙 协议:CC BY-NC-SA 4...
摘要:在其沙箱中提供了将文本转换成文档对象模型的功能。浏览器使用与该形状对应的数据结构来表示文档。我们将这种表示方式称为文档对象模型,或简称。树回想一下第章中提到的语法树。语言的语法树有标识符值和应用节点。元素表示标签的节点用于确定文档结构。 来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:The Document Object Model 译者:飞龙 协议...
摘要:贝塞尔曲线方法可以绘制一种类似的曲线。不同的是贝塞尔曲线需要两个控制点而不是一个,线段的每一个端点都需要一个控制点。下面是描述贝塞尔曲线的简单示例。 来源:ApacheCN『JavaScript 编程精解 中文第三版』翻译项目原文:Drawing on Canvas 译者:飞龙 协议:CC BY-NC-SA 4.0 自豪地采用谷歌翻译 部分参考了《JavaScript 编程精解(第 2...
阅读 2976·2023-04-25 16:50
阅读 835·2021-11-25 09:43
阅读 3445·2021-09-26 10:11
阅读 2487·2019-08-26 13:28
阅读 2500·2019-08-26 13:23
阅读 2392·2019-08-26 11:53
阅读 3543·2019-08-23 18:19
阅读 2950·2019-08-23 16:27