第 19 章项目:绘画程序
前几章的材料提供了构建简单 Web 应用程序所需的所有元素。本章将演示如何做到这一点。
我们的应用程序将是一个基于 Web 的绘图程序,类似于 Microsoft Paint。您可以使用它打开图像文件,用鼠标在上面涂鸦,以及保存它们。它看起来像这样
在计算机上绘画很棒。您无需担心材料、技巧或才能。您只需开始涂抹。
实现
绘画程序的界面在顶部显示一个大的<canvas>
元素,下面是一些表单字段。用户通过从<select>
字段中选择工具,然后在画布上单击或拖动来绘制图片。有一些工具用于绘制线条、擦除图片部分、添加文本等等。
单击画布会将"mousedown"
事件传递给当前选定的工具,该工具可以根据需要处理该事件。例如,线条绘制工具将监听"mousemove"
事件,直到鼠标按钮被释放,并使用当前颜色和画笔大小沿鼠标路径绘制线条。
颜色和画笔大小通过其他表单字段选择。这些字段与画布绘图上下文中的fillStyle
、strokeStyle
和lineWidth
相关联,只要这些字段发生变化,它们就会更新。
您可以通过两种方式将图像加载到程序中。第一种使用文件字段,用户可以在自己的文件系统中选择文件。第二种方法要求提供 URL,并将从 Web 上获取图像。
图像以一种不太典型的形式保存。右侧的保存链接指向当前图像。可以跟踪、共享或保存它。我将在稍后解释如何实现这一点。
构建 DOM
我们程序的界面由 30 多个 DOM 元素构成。我们需要以某种方式构建这些元素。
HTML 是定义复杂 DOM 结构的最明显格式。但是,由于许多 DOM 元素需要事件处理程序或需要以其他方式被脚本触碰,因此将程序分成 HTML 代码和脚本会变得很困难。因此,我们的脚本必须进行大量的querySelector
(或类似的)调用,以便找到它需要操作的 DOM 元素。
如果我们能够在与驱动它的 JavaScript 代码相近的位置定义界面各个部分的 DOM 结构,那就太好了。因此,我选择在 JavaScript 中完成所有 DOM 节点的创建。正如我们在第 13 章中看到的,用于构建 DOM 结构的内置接口极其冗长。如果我们要进行大量的 DOM 构建,我们需要一个辅助函数。
这个辅助函数是第 13 章中elt
函数的扩展版本。它创建一个具有给定名称和属性的元素,并将所有后续参数附加为子节点,自动将字符串转换为文本节点。
function elt(name, attributes) { var node = document.createElement(name); if (attributes) { for (var attr in attributes) if (attributes.hasOwnProperty(attr)) node.setAttribute(attr, attributes[attr]); } for (var i = 2; i < arguments.length; i++) { var child = arguments[i]; if (typeof child == "string") child = document.createTextNode(child); node.appendChild(child); } return node; }
这使我们能够轻松地创建元素,而不会使我们的源代码像企业最终用户协议那样冗长而乏味。
基础
我们程序的核心是createPaint
函数,该函数将绘画界面附加到作为参数传递给它的 DOM 元素。因为我们想要逐个构建我们的程序,所以我们定义了一个名为controls
的对象,它将保存用于初始化图像下方各种控件的函数。
var controls = Object.create(null); function createPaint(parent) { var canvas = elt("canvas", {width: 500, height: 300}); var cx = canvas.getContext("2d"); var toolbar = elt("div", {class: "toolbar"}); for (var name in controls) toolbar.appendChild(controls[name](cx)); var panel = elt("div", {class: "picturepanel"}, canvas); parent.appendChild(elt("div", null, panel, toolbar)); }
每个控件都可以访问画布绘图上下文,以及通过该上下文的canvas
属性访问<canvas>
元素。程序的大多数状态都保存在这个画布中——它包含当前图片,以及选定的颜色(在其fillStyle
属性中)和画笔大小(在其lineWidth
属性中)。
我们将画布和控件包装在带有类的<div>
元素中,这样我们就可以添加一些样式,例如在图片周围添加灰色边框。
工具选择
我们添加的第一个控件是<select>
元素,它允许用户选择绘制工具。与controls
类似,我们将使用一个对象来收集各种工具,这样我们就不必将它们全部硬编码在一个地方,并且以后可以添加更多工具。这个对象将工具的名称与其在选择工具并单击画布时应该调用的函数关联起来。
var tools = Object.create(null); controls.tool = function(cx) { var select = elt("select"); for (var name in tools) select.appendChild(elt("option", null, name)); cx.canvas.addEventListener("mousedown", function(event) { if (event.which == 1) { tools[select.value](event, cx); event.preventDefault(); } }); return elt("span", null, "Tool: ", select); };
工具字段由为所有已定义的工具创建的<option>
元素填充,并且画布元素上的"mousedown"
处理程序负责调用当前工具的函数,并将事件对象和绘图上下文作为参数传递给它。它还会调用preventDefault
,以防止按住鼠标按钮并拖动会导致浏览器选择页面部分。
最基本的工具是线条工具,它允许用户用鼠标绘制线条。为了将线条的端点放在正确的位置,我们需要能够找到与给定鼠标事件相对应的画布相对坐标。在第 13 章中简要提到的getBoundingClientRect
方法可以帮助我们。它告诉我们元素相对于屏幕左上角的位置。鼠标事件中的clientX
和clientY
属性也是相对于这个角的,所以我们可以从中减去画布的左上角,以获得相对于该角的位置。
function relativePos(event, element) { var rect = element.getBoundingClientRect(); return {x: Math.floor(event.clientX - rect.left), y: Math.floor(event.clientY - rect.top)}; }
一些绘图工具需要在按住鼠标按钮的情况下监听"mousemove"
事件。trackDrag
函数负责在这种情况下的事件注册和取消注册。
function trackDrag(onMove, onEnd) { function end(event) { removeEventListener("mousemove", onMove); removeEventListener("mouseup", end); if (onEnd) onEnd(event); } addEventListener("mousemove", onMove); addEventListener("mouseup", end); }
此函数接受两个参数。一个是针对每个"mousemove"
事件要调用的函数,另一个是在鼠标按钮被释放时要调用的函数。如果不需要,可以省略任一参数。
tools.Line = function(event, cx, onEnd) { cx.lineCap = "round"; var pos = relativePos(event, cx.canvas); trackDrag(function(event) { cx.beginPath(); cx.moveTo(pos.x, pos.y); pos = relativePos(event, cx.canvas); cx.lineTo(pos.x, pos.y); cx.stroke(); }, onEnd); };
该函数首先将绘图上下文的lineCap
属性设置为"round"
,这将导致描边的路径的两端都是圆形,而不是默认的方形。这是一个技巧,可以确保响应不同事件绘制的多条独立线看起来像一条连续的线。对于更大的线条宽度,如果您使用默认的平直线帽,您将在拐角处看到间隙。
然后,对于在鼠标按钮按下期间发生的每个"mousemove"
事件,都会使用当前设置的任何strokeStyle
和lineWidth
在鼠标的旧位置和新位置之间绘制一条简单的线段。
tools.Line
的onEnd
参数只是传递给trackDrag
。正常运行工具不会传递第三个参数,因此在使用线条工具时,该参数将保存undefined
,并且在鼠标拖动结束时不会发生任何事情。该参数是为了让我们能够在线条工具的基础上用很少的代码实现擦除工具。
tools.Erase = function(event, cx) { cx.globalCompositeOperation = "destination-out"; tools.Line(event, cx, function() { cx.globalCompositeOperation = "source-over"; }); };
globalCompositeOperation
属性会影响画布上绘图操作更改其所触碰像素颜色的方式。默认情况下,该属性的值为"source-over"
,这意味着绘制的颜色将覆盖该位置的现有颜色。如果颜色是不透明的,它将简单地替换旧的颜色,但如果它部分透明,那么两者将混合在一起。
擦除工具将globalCompositeOperation
设置为"destination-out"
,这将使我们触碰到的像素变透明,从而达到擦除效果。
这给了我们在绘画程序中两个工具。我们可以绘制宽度为 1 像素的黑色线条(画布的默认strokeStyle
和lineWidth
),然后再次擦除它们。它是一个可用的绘画程序,尽管它非常有限。
颜色和画笔大小
假设用户想要使用除了黑色以外的颜色并使用不同的画笔大小,那么让我们添加这两个设置的控件。
在第 18 章中,我讨论了多种不同的表单字段。颜色字段不在其中。传统上,浏览器没有内置的颜色选择器支持,但是在过去几年中,许多新的表单字段类型已经标准化。其中之一是<input type="color">
。其他类型包括"date"
、"email"
、"url"
和"number"
。并不是所有浏览器都支持它们——在撰写本文时,任何版本的 Internet Explorer 都不支持颜色字段。<input>
标签的默认类型是"text"
,当使用不支持的类型时,浏览器会将其视为文本字段。这意味着运行我们绘画程序的 Internet Explorer 用户必须键入他们想要的颜色的名称,而不是从方便的小部件中选择它。
controls.color = function(cx) { var input = elt("input", {type: "color"}); input.addEventListener("change", function() { cx.fillStyle = input.value; cx.strokeStyle = input.value; }); return elt("span", null, "Color: ", input); };
只要颜色字段的值发生变化,绘图上下文的fillStyle
和strokeStyle
就会更新为包含新值。
controls.brushSize = function(cx) { var select = elt("select"); var sizes = [1, 2, 3, 5, 8, 12, 25, 35, 50, 75, 100]; sizes.forEach(function(size) { select.appendChild(elt("option", {value: size}, size + " pixels")); }); select.addEventListener("change", function() { cx.lineWidth = select.value; }); return elt("span", null, "Brush size: ", select); };
该代码从一个画笔大小数组中生成选项,并再次确保在选择画笔大小后更新画布的lineWidth
。
保存
为了解释保存链接的实现,我必须先向您介绍数据 URL。数据 URL 是一个以data:作为协议的 URL。与常规的http:和https: URL 不同,数据 URL 不会指向资源,而是包含其中的整个资源。这是一个包含简单 HTML 文档的数据 URL
data:text/html,<h1 style="color:red">Hello!</h1>
数据 URL 可用于各种任务,例如直接在样式表文件中包含小图片。它们还允许我们将我们在客户端(在浏览器中)创建的文件链接到文件,而无需先将它们移动到某个服务器。
Canvas 元素有一个名为 toDataURL
的便捷方法,它将返回一个包含 canvas 上图片作为图像文件的数据 URL。但是,我们不想在每次图片更改时都更新保存链接。对于大图片来说,这涉及将大量数据移动到链接中,而且速度明显会很慢。相反,我们调整链接,使其在用键盘聚焦或鼠标悬停在链接上时更新其 href
属性。
controls.save = function(cx) { var link = elt("a", {href: "/"}, "Save"); function update() { try { link.href = cx.canvas.toDataURL(); } catch (e) { if (e instanceof SecurityError) link.href = "javascript:alert(" + JSON.stringify("Can't save: " + e.toString()) + ")"; else throw e; } } link.addEventListener("mouseover", update); link.addEventListener("focus", update); return link; };
因此,链接只是静静地待在那里,指向错误的东西,但当用户接近它时,它会神奇地更新自己以指向当前的图片。
如果您加载一个大图片,一些浏览器会因该方法生成的巨大数据 URL 而崩溃。对于小图片来说,这种方法可以正常工作。
但是,我们再次遇到了浏览器沙箱的细微之处。当从另一个域的 URL 加载图片时,如果服务器的响应中没有包含告诉浏览器该资源可以从其他域使用(参见 第 17 章)的标题,那么 canvas 将包含用户可以查看但脚本可能无法查看的信息。
我们可能已经使用用户的会话请求了一张包含私人信息的图片(例如,显示用户银行账户余额的图表)。如果脚本可以从该图片中获取信息,它们就可以以用户不希望的方式窥探用户。
为了防止此类信息泄露,当将脚本可能无法看到的图片绘制到 canvas 上时,浏览器会将 canvas 标记为污染。不能从污染的 canvas 中提取像素数据,包括数据 URL。您可以写入它,但不能再读取它。
这就是为什么我们需要保存链接的 update
函数中的 try/catch
语句。当 canvas 被污染时,调用 toDataURL
将引发一个 SecurityError
实例的异常。发生这种情况时,我们将链接设置为指向另一种类型的 URL,使用javascript:协议。此类链接在被跟随时,只是简单地执行冒号后的脚本,这样当用户点击链接时,链接将显示一个 alert
窗口,通知用户问题。
加载图片文件
最后两个控件用于从本地文件和 URL 加载图片。我们需要以下辅助函数,它尝试从 URL 加载图片文件并将 canvas 的内容替换为它
function loadImageURL(cx, url) { var image = document.createElement("img"); image.addEventListener("load", function() { var color = cx.fillStyle, size = cx.lineWidth; cx.canvas.width = image.width; cx.canvas.height = image.height; cx.drawImage(image, 0, 0); cx.fillStyle = color; cx.strokeStyle = color; cx.lineWidth = size; }); image.src = url; }
我们希望更改 canvas 的大小以精确地适合图片。出于某种原因,更改 canvas 的大小将导致其绘图上下文忘记配置属性,例如 fillStyle
和 lineWidth
,因此该函数保存这些属性并在更新 canvas 大小后恢复它们。
用于加载本地文件的控件使用 第 18 章 中的 FileReader
技术。除了我们在那里使用的 readAsText
方法之外,此类读取器对象还具有一个名为 readAsDataURL
的方法,这正是我们这里需要的。我们将用户选择的文件加载为数据 URL,并将其传递给 loadImageURL
以将其放入 canvas 中。
controls.openFile = function(cx) { var input = elt("input", {type: "file"}); input.addEventListener("change", function() { if (input.files.length == 0) return; var reader = new FileReader(); reader.addEventListener("load", function() { loadImageURL(cx, reader.result); }); reader.readAsDataURL(input.files[0]); }); return elt("div", null, "Open file: ", input); };
从 URL 加载文件更简单。但是使用文本字段,用户何时完成 URL 的编写并不那么清楚,因此我们不能仅仅监听 "change"
事件。相反,我们将字段包装在一个表单中,并在表单提交时做出响应,无论是用户按了 Enter 键还是点击了加载按钮。
controls.openURL = function(cx) { var input = elt("input", {type: "text"}); var form = elt("form", null, "Open URL: ", input, elt("button", {type: "submit"}, "load")); form.addEventListener("submit", function(event) { event.preventDefault(); loadImageURL(cx, input.value); }); return form; };
我们现在已经定义了我们的简单绘图程序所需的所有控件,但它仍然可以使用一些其他工具。
收尾
我们可以轻松地添加一个文本工具,使用 prompt
向用户询问它应该绘制哪个字符串。
tools.Text = function(event, cx) { var text = prompt("Text:", ""); if (text) { var pos = relativePos(event, cx.canvas); cx.font = Math.max(7, cx.lineWidth) + "px sans-serif"; cx.fillText(text, pos.x, pos.y); } };
您可以添加额外的字段用于字体大小和字体,但为了简单起见,我们始终使用无衬线字体,并将字体大小基于当前笔刷大小。最小尺寸为 7 像素,因为小于此尺寸的文本不可读。
绘制业余计算机图形的另一个必不可少的工具是喷漆工具。只要鼠标按下,它就在刷子下的随机位置绘制点,根据鼠标移动的速度快慢,创建更密集或更稀疏的斑点。
tools.Spray = function(event, cx) { var radius = cx.lineWidth / 2; var area = radius * radius * Math.PI; var dotsPerTick = Math.ceil(area / 30); var currentPos = relativePos(event, cx.canvas); var spray = setInterval(function() { for (var i = 0; i < dotsPerTick; i++) { var offset = randomPointInRadius(radius); cx.fillRect(currentPos.x + offset.x, currentPos.y + offset.y, 1, 1); } }, 25); trackDrag(function(event) { currentPos = relativePos(event, cx.canvas); }, function() { clearInterval(spray); }); };
喷漆工具使用 setInterval
只要鼠标按钮按下,每隔 25 毫秒喷出彩色点。trackDrag
函数用于使 currentPos
指向当前鼠标位置,并在鼠标按钮释放时关闭间隔。
为了确定每次间隔触发时绘制的点数量,该函数计算当前刷子的面积,然后将其除以 30。为了找到刷子下的随机位置,使用 randomPointInRadius
函数。
function randomPointInRadius(radius) { for (;;) { var x = Math.random() * 2 - 1; var y = Math.random() * 2 - 1; if (x * x + y * y <= 1) return {x: x * radius, y: y * radius}; } }
该函数在 (-1,-1) 和 (1,1) 之间的正方形内生成点。使用勾股定理,它测试生成的点是否位于半径为 1 的圆内。一旦函数找到这样的点,它就返回乘以 radius
参数的点。
循环对于点均匀分布是必要的。在圆内生成随机点的直接方法是使用随机角度和距离,并调用 Math.sin
和 Math.cos
来创建相应的点。但使用这种方法,点更有可能出现在圆的中心附近。还有其他方法可以解决这个问题,但它们比前面的循环更复杂。
<link rel="stylesheet" href="css/paint.css"> <body> <script>createPaint(document.body);</script> </body>
练习
这个程序还有很多改进的空间。让我们添加一些其他功能作为练习。
矩形
定义一个名为 Rectangle
的工具,它使用当前颜色填充矩形(参见 第 16 章 中的 fillRect
方法)。矩形应从用户按下鼠标按钮的点开始,到他们释放鼠标按钮的点结束。请注意,后者可能在前面点的上方或左侧。
一旦它工作起来,您会注意到,在拖动鼠标以选择矩形大小时,没有看到矩形,这有点令人讨厌。你能想出一个方法,在拖动过程中显示某种矩形,而不用在鼠标按钮释放之前实际绘制到 canvas 上吗?
如果您没有想到,请回顾 第 13 章 中讨论的 position: absolute
样式,该样式可用于将节点覆盖在文档的其余部分上。鼠标事件的 pageX
和 pageY
属性可用于将元素精确地放置在鼠标下方,方法是将 left
、top
、width
和 height
样式设置为正确的像素值。
<script> tools.Rectangle = function(event, cx) { // Your code here. }; </script> <link rel="stylesheet" href="css/paint.css"> <body> <script>createPaint(document.body);</script> </body>
您可以使用 relativePos
找到对应于鼠标拖动开始的角点。确定拖动结束的位置可以使用 trackDrag
或通过注册您自己的事件处理程序来完成。
当您有两个矩形的角点时,您必须以某种方式将其转换为 fillRect
预期的参数:矩形的左上角、宽度和高度。Math.min
可用于找到最左边的 x 坐标和最上面的 y 坐标。要获取宽度或高度,可以在两边之间的差值上调用 Math.abs
(绝对值)。
在鼠标拖动过程中显示矩形需要一组类似的数字,但在整个页面的上下文中而不是相对于 canvas。考虑编写一个名为 findRect
的函数,它将两个点转换为一个具有 top
、left
、width
和 height
属性的对象,这样您就不必两次编写相同的逻辑。
然后,您可以创建一个 <div>
节点,并将它的 style.position
设置为 absolute
。在设置定位样式时,不要忘记在数字后面追加 "px"
。该节点必须添加到文档中(您可以将其附加到 document.body
),并且在拖动结束并绘制实际的矩形到 canvas 上时,也必须将其删除。
颜色选择器
图形程序中常用的另一个工具是颜色选择器,它允许用户点击图片并选择鼠标指针下的颜色。构建这个。
对于此工具,我们需要一种访问 canvas 内容的方法。toDataURL
方法或多或少地做到了这一点,但从这样的数据 URL 中获取像素信息很困难。相反,我们将使用绘图上下文上的 getImageData
方法,该方法将图片的矩形部分作为具有 width
、height
和 data
属性的对象返回。data
属性保存一个从 0 到 255 的数字数组,使用四个数字来表示每个像素的红色、绿色、蓝色和 alpha(不透明度)分量。
此示例在 canvas 为空白时(所有像素都是透明的黑色)和像素已变为红色时检索单个像素的数字。
function pixelAt(cx, x, y) { var data = cx.getImageData(x, y, 1, 1); console.log(data.data); } var canvas = document.createElement("canvas"); var cx = canvas.getContext("2d"); pixelAt(cx, 10, 10); // → [0, 0, 0, 0] cx.fillStyle = "red"; cx.fillRect(10, 10, 1, 1); pixelAt(cx, 10, 10); // → [255, 0, 0, 255]
getImageData
的参数指示我们要检索的矩形的起始 x 和 y 坐标,以及它的宽度和高度。
在此练习中忽略透明度,只查看给定像素的前三个值。此外,不要担心在用户选择颜色时更新颜色字段。只需确保绘图上下文的 fillStyle
和 strokeStyle
设置为鼠标光标下的颜色。
请记住,这些属性接受 CSS 理解的任何颜色,包括您在 第 15 章 中看到的 rgb(R, G, B)
样式。
getImageData
方法受与 toDataURL
相同的限制——当 canvas 包含来自另一个域的像素时,它会引发错误。使用 try/catch
语句使用 alert
对话框报告此类错误。
<script> tools["Pick color"] = function(event, cx) { // Your code here. }; </script> <link rel="stylesheet" href="css/paint.css"> <body> <script>createPaint(document.body);</script> </body>
洪水填充
这比前面的两个练习更高级,它需要您为一个棘手的问题设计一个非平凡的解决方案。在开始着手这个练习之前,请确保您有充足的时间和耐心,不要被最初的失败所沮丧。
洪水填充工具会将鼠标下的像素及其周围相同颜色的像素着色。为了进行此练习,我们将认为这样的组包含从起始像素开始,通过以单个像素的水平和垂直步长(非对角线)移动,且永远不接触与起始像素颜色不同的像素,可以到达的所有像素。
洪水填充不会通过对角线间隙泄漏,也不会触及不可到达的像素,即使它们的色调与目标像素相同。
您将再次需要 `getImageData` 来找出每个像素的颜色。可能最好一次性获取整个图像,然后从生成的数组中挑选像素数据。像素在这个数组中的组织方式类似于 第 7 章 中的网格元素,一次一行,除了每个像素由四个值表示。像素 (x,y) 的第一个值位于位置 (x + y × width) × 4。
这次请包含第四个(alpha)值,因为我们希望能够区分空像素和黑色像素。
查找所有具有相同颜色的相邻像素需要您在像素表面上“行走”,一个像素向上、向下、向左或向右,只要能找到新的相同颜色的像素。但您不会在第一次行走中找到组中的所有像素。相反,您必须做一些类似于正则表达式匹配器所做的回溯,如 第 9 章 所述。只要看到多个可能的进行方向,您就必须存储您没有立即采取的所有方向,并在完成当前行走后查看它们。
在一张普通大小的图片中,有很多像素。因此,您必须注意只做最少的工作量,否则您的程序将需要很长时间才能运行。例如,每次行走都必须忽略先前行走所看到的像素,以便它不会重复已经完成的工作。
我建议在找到应该着色的像素时,为单个像素调用 `fillRect`,并保留一些数据结构来告诉您所有已经查看过的像素。
<script> tools["Flood fill"] = function(event, cx) { // Your code here. }; </script> <link rel="stylesheet" href="css/paint.css"> <body> <script>createPaint(document.body);</script> </body>