如何使用 JavaScript、HTML 和 CSS 构建 Sketch 插件(第 2 部分)

已发表: 2022-03-10
快速总结↬在我们关于构建 Sketch 插件的教程的第二部分中,我们将继续构建我们的用户界面,然后我们将继续讨论实际生成图层马赛克和优化最终插件代码。

如第 1 部分所述,本教程适用于了解和使用 Sketch 应用程序并且不害怕涉足代码的人。 要从中获得最大收益,您至少需要具备一些编写 JavaScript(以及可选的 HTML/CSS)的基本经验。

在本教程的前一部分中,我们了解了组成插件的基本文件,以及如何创建插件的用户界面。 在第二部分也是最后一部分,我们将学习如何将用户界面连接到核心插件代码以及如何实现插件的主要功能。 最后但同样重要的是,我们还将学习如何优化代码以及插件的工作方式。

构建插件的用户界面:让我们的 Web 界面和 Sketch 插件代码相互“对话”

接下来我们需要做的是在我们的 Web 界面和 Sketch 插件之间建立通信。

当我们点击网页界面中的“应用”按钮时,我们需要能够从我们的网页界面向 Sketch 插件发送消息。 此消息需要告诉我们用户输入了哪些设置——例如步数、旋转量、要创建的副本数等。

WKWebView使这项任务对我们来说更容易一些:我们可以使用window.webkit.messageHandlers API 从 Web 界面的 JavaScript 代码向 Sketch 插件发送消息。

在我们的 Sketch 代码方面,我们可以使用另一种方法addScriptMessageHandler:name: (或addScriptMessageHandler_name )来注册一个消息处理程序,当它接收到从我们的插件 Web 界面发送的消息时将调用该消息处理程序。

让我们首先确保我们可以从我们的 Web UI 接收消息。 转到我们的ui.js文件的createWebView函数,并添加以下内容:

 function createWebView(pageURL){ const webView = WKWebView.alloc().init(); // Set handler for messages from script const userContentController = webView.configuration().userContentController(); const ourMessageHandler = ... userContentController.addScriptMessageHandler_name( ourMessageHandler, "sketchPlugin" ); // Load page into web view webView.loadFileURL_allowingReadAccessToURL(pageURL, pageURL.URLByDeletingLastPathComponent()); return webView; };

在这里,我们使用 web 视图的userContentController属性来添加我们命名为“sketchPlugin”的消息处理程序。 这个“用户内容控制器”是确保消息从我们的 Web 视图中传递出来的桥梁。

跳跃后更多! 继续往下看↓

您可能已经注意到上述代码的一些奇怪之处:我们作为消息处理程序添加的对象ourMessageHandler还不存在! 不幸的是,我们不能只使用常规的 JavaScript 对象或函数作为处理程序,因为这种方法需要某种原生对象。

对我们来说幸运的是,我们可以通过使用MochaJSDelegate来解决这个限制,这是我编写的一个迷你库,可以使用常规的 JavaScript 创建我们需要的原生对象。 您需要手动下载并将其保存在Sketch/MochaJSDelegate.js下的插件包中。

为了使用它,我们需要先将它导入ui.js 。 在文件顶部添加以下内容:

 const MochaJSDelegate = require("./MochaJSDelegate");

现在我们可以使用MochaJSDelegate创建消息处理程序的类型addScriptMessageHandler:name:期望:

 function createWebView(pageURL){ const webView = WKWebView.alloc().init(); // Set handler for messages from script const userContentController = webView.configuration().userContentController(); const scriptMessageHandler = new MochaJSDelegate({ "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { /* handle message here */ } }).getClassInstance(); userContentController.addScriptMessageHandler_name( scriptMessageHandler, "sketchPlugin" ); // Load page into web view webView.loadFileURL_allowingReadAccessToURL(pageURL, pageURL.URLByDeletingLastPathComponent()); return webView; };

我们刚刚添加的代码创建了我们需要的原生对象。 它还在该对象上定义了一个名为userContentController:didReceiveScriptMessage:的方法——然后使用我们想要的消息作为第二个参数调用此方法。 由于我们实际上还没有发送任何消息,所以我们稍后必须回到这里并添加一些代码来实际解析和处理我们收到的消息。

接下来,我们需要在 Web 界面中添加一些代码来向我们发送这些消息。 前往/Resources/web-ui/script.js 。 您会发现我已经编写了大部分代码来处理检索用户将在其中输入选项的 HTML <inputs />的值。

我们还剩下要做的是将实际发送值的代码添加到我们的 Sketch 代码中:

找到apply函数并将以下内容添加到它的末尾:

 // Send user inputs to sketch plugin window.webkit.messageHandlers.sketchPlugin.postMessage(JSON.stringify({ stepCount, startingOptions, stepOptions }));

这里我们使用前面提到的window.webkit.messageHandlers API 来访问我们上面注册为sketchPlugin的消息处理程序。 然后使用包含用户输入的 JSON 字符串向其发送消息。

让我们确保一切设置正确。 回到/Sketch/ui.js 。 为了确保我们收到预期的消息,我们将修改我们之前定义的方法,以便在收到消息时显示一个对话框:

 function createWebView(pageURL){ // ... const scriptMessageHandler = new MochaJSDelegate({ "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { const UI = require("sketch/ui"); UI.alert("Hey, a message!", wkMessage.body()); } }).getClassInstance(); userContentController.addScriptMessageHandler_name( scriptMessageHandler, "sketchPlugin" ); // ... };

现在运行插件(您可能需要先关闭已打开的任何现有 Mosaic 窗口),输入一些值,然后单击“应用”。 您应该会看到如下所示的警报 - 这意味着一切都已正确连接并且我们的消息已成功通过! 如果没有,请返回前面的步骤并确保一切都按照描述完成。

图像显示了单击插件 UI 中的“应用”按钮后应该看到的对话框。
单击应用后,您应该看到的对话框出现。 (大预览)

现在我们能够从我们的界面向我们的插件发送消息,我们可以继续编写代码,该代码实际上对这些信息有用:生成我们的图层马赛克。

生成图层马赛克

让我们盘点一下为了实现这一目标需要做些什么。 稍微简化一下,我们的代码需要做的是:

  1. 查找当前文档。
  2. 查找当前文档的选定图层。
  3. 复制所选图层(我们将其称为模板图层)x 次。
  4. 对于每个副本,通过用户设置的特定值(数量)调整其位置、旋转、不透明度等。

现在我们有了一个合理的计划,让我们继续写。 坚持我们模块化代码的模式,让我们在Sketch/文件夹中创建一个新文件, mosaic.js ,并向其中添加以下代码:

 function mosaic(options){ }; module.export = mosaic;

我们将使用这个函数作为这个模块的唯一导出,因为一旦我们导入它,它就可以使用更简单的 API ——我们可以使用从 Web 界面获得的任何选项来调用mosaic()

我们需要采取的前两个步骤是获取当前文档,然后是其选定层。 Sketch API 有一个用于文档操作的内置库,我们可以通过导入sketch/dom模块来访问它。 我们现在只需要Document对象,所以我们将显式地取出它。 在文件顶部,添加:

 const { Document } = require("sketch/dom");

Document对象有一个专门用于访问我们可以使用的当前文档的方法,称为getSelectedDocument() 。 一旦我们有了当前的文档实例,我们就可以通过文档的selectedLayers属性访问用户选择的任何层。 但是,在我们的例子中,我们只关心单层选择,所以我们只会抓取用户选择的第一层:

 function mosaic(options){ const document = Document.getSelectedDocument(); const selectedLayer = document.selectedLayers.layers[0]; }; module.export = mosaic;

注意:您可能一直期望selectedLayers本身是一个数组,但事实并非如此。 相反,它是Selection类的一个实例。 这是有原因的: Selection类包含一堆有用的帮助方法,用于操作选择,如 clear、map、reduce 和 forEach。 它通过layer属性暴露了实际的层数组。

我们还添加一些警告反馈,以防用户忘记打开文档或选择某些内容:

 const UI = require("sketch/ui"); function mosaic(options){ const document = Document.getSelectedDocument(); // Safety check: if(!document){ UI.alert("Mosaic", "️ Please select/focus a document."); return; } // Safety check: const selectedLayer = document.selectedLayers.layers[0]; if(!selectedLayer){ UI.alert("Mosaic", "️ Please select a layer to duplicate."); return; } }; module.export = mosaic;

现在我们已经编写了第 1 步和第 2 步的代码(查找当前文档和选定层),我们需要处理第 3 步和第 4 步:

  • 复制模板层 x 次。
  • 对于每个副本,通过用户设置的特定值调整其位置、旋转、不透明度等。

让我们从options中提取我们需要的所有相关信息开始:​​复制的次数、开始选项和步骤选项。 我们可以再次使用解构(就像我们之前对Document所做的那样)将这些属性从options中提取出来:

 function mosaic(options) { // ... // Destructure options: var { stepCount, startingOptions, stepOptions } = options; }

接下来,让我们清理输入并确保步数始终至少为 1:

 function mosaic(options) { // ... // Destructure options: var { stepCount, startingOptions, stepOptions } = options; stepCount = Math.max(1, stepCount); }

现在我们需要确保模板层的不透明度、旋转等都与用户期望的起始值相匹配。 由于将用户的选项应用于图层将是我们要做的很多事情,因此我们将把这项工作转移到它自己的方法中:

 function configureLayer(layer, options, shouldAdjustSpacing){ const { opacity, rotation, direction, spacing } = options; layer.style.opacity = opacity / 100; layer.transform.rotation = rotation; if(shouldAdjustSpacing){ const directionAsRadians = direction * (Math.PI / 180); const vector = { x: Math.cos(directionAsRadians), y: Math.sin(directionAsRadians) }; layer.frame.x += vector.x * spacing; layer.frame.y += vector.y * spacing; } };

而且因为间距只需要在副本之间而不是模板层之间应用,我们添加了一个特定的标志, shouldAdjustSpacing ,我们可以设置为truefalse取决于我们是否将选项应用于模板层或不是。 这样我们可以确保将旋转和不透明度应用于模板,而不是间距。

回到mosaic方法,现在让我们确保将起始选项应用于模板层:

 function mosaic(options){ // ... // Configure template layer var layer = group.layers[0]; configureLayer(layer, startingOptions, false); }

接下来,我们需要创建我们的副本。 首先,让我们创建一个变量,我们可以使用它来跟踪当前副本的选项是什么:

 function mosaic(options){ // ... var currentOptions; // ... }

由于我们已经将起始选项应用到模板层,我们需要采用我们刚刚应用的那些选项并添加stepOptions的相对值,以便将选项应用到下一层。 由于我们还将在循环中多次执行此操作,因此我们还将将此工作移至特定方法stepOptionsBy

 function stepOptionsBy(start, step){ const newOptions = {}; for(let key in start){ newOptions[key] = start[key] + step[key]; } return newOptions; };

之后,我们需要编写一个循环来复制前一层,将当前选项应用于它,然后偏移(或“步进”)当前选项以获得下一个副本的选项:

 function mosaic(options) { // ... var currentOptions = stepOptionsBy(startingOptions, stepOptions); for(let i = 0; i < (stepCount - 1); i++){ let duplicateLayer = layer.duplicate(); configureLayer(duplicateLayer, currentOptions, true); currentOptions = stepOptionsBy(currentOptions, stepOptions); layer = duplicateLayer; } }

一切都完成了——我们已经成功编写了插件应该做的核心! 现在,我们需要连接起来,这样当用户真正点击“应用”按钮时,我们的马赛克代码就会被调用。

让我们回到ui.js并调整我们的消息处理代码。 我们需要做的是解析我们得到的选项的 JSON 字符串,以便将它们变成我们可以实际使用的对象。 一旦我们有了这些选项,我们就可以用它们调用mosaic函数。

首先,解析。 我们需要更新我们的消息处理函数来解析我们得到的 JSON 消息:

 function createWebView(pageURL){ // ... const scriptMessageHandler = new MochaJSDelegate({ "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { const message = JSON.parse(wkMessage.body()); } }); }

接下来,我们需要将其传递给我们的mosaic函数。 然而,这并不是我们在ui.js中的代码真正应该做的事情——它应该主要关注在屏幕上显示与界面相关的东西所必需的东西——而不是创建马赛克本身。 为了将这些职责分开,我们将向createWebView添加第二个参数,该参数接受一个函数,每当我们从 Web 界面接收选项时,我们都会调用该函数。

让我们将此参数命名为onApplyMessage

 function createWebView(pageURL, onApplyMessage){ // ... const scriptMessageHandler = new MochaJSDelegate({ "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { const message = JSON.parse(wkMessage.body()); onApplyMessage(message); } }); }

我们还需要修改我们导出的方法loadAndShow ,以获取这个onApplyMessage参数并将其传递给createWebView

 function loadAndShow(baseURL, onApplyMessage){ // ... const webView = createWebView(pageURL, onApplyMessage); }

最后,转到main.js 。 我们现在需要导入我们的mosaic函数,并使用我们从插件用户界面收到的选项调用它:

 const mosaic = require("./mosaic"); function onRun(context){ UI.loadAndShow(context.scriptURL, options => { mosaic(options); }); };

我们快完成了!

但是,如果我们现在运行代码并单击插件界面中的“应用”按钮,则不会发生任何事情。 为什么? 原因在于 Sketch 脚本的运行方式:默认情况下,它们“存活”到脚本的底部,之后 Sketch 会销毁它并释放它正在使用的任何资源。

这对我们来说是个问题,因为这意味着我们需要异步发生的任何事情(在这种情况下,就是在到达我们代码的底部之后),比如接收消息,因为我们的脚本已经被破坏了,所以不能。 这意味着我们不会从 Web 界面收到任何消息,因为我们无法接收和回复它们!

有一种方法可以使用Fibers向 Sketch 发出信号,我们需要我们的脚本在此之后保持活力。 通过创建一个 Fiber,我们告诉 Sketch 正在发生一些异步的事情,它需要保留我们的脚本。 然后 Sketch 只会在绝对必要的时候销毁我们的脚本(比如用户关闭 Sketch,或者当 Mosaic 插件需要更新时):

 // ... const Async = require("sketch/async"); var fiber; function onRun(context){ if(!fiber){ fiber = Async.createFiber(); fiber.onCleanup(() => { UI.cleanup(); }); } UI.loadAndShow(context.scriptURL, options => { mosaic(options); }); };

瞧! 现在让我们试试我们的插件。 在 Sketch 中选择一个图层,输入一些设置,然后单击应用:

现在让我们试试我们的插件——在 Sketch 中选择一个图层,输入一些设置,然后单击“应用”。

最终改进

现在我们已经实现了插件的大部分功能,我们可以尝试“缩小”一点,看看大图。

改善用户体验

如果您在当前状态下使用过插件,您可能已经注意到,当您尝试编辑马赛克时,会出现最大的摩擦点之一。 创建一个后,您必须点击撤消,调整选项,然后单击“应用”(或按 Enter)。 在您离开文档并稍后返回它之后,它也使得编辑马赛克变得更加困难,因为您的撤消/重做历史将被清除,让您自己手动删除重复的图层。

在更理想的流程中,用户只需选择一个 Mosaic 组,调整选项并观看 Mosaic 更新,直到他们得到他们正在寻找的确切排列。 为了实现这一点,我们有两个问题需要解决:

  1. 首先,我们需要一种方法将构成马赛克的副本组合在一起。 Sketch 提供了 Groups 的概念,我们可以用它来解决这个问题。
  2. 其次,我们需要一种方法来区分普通的、用户创建的组和 Mosaic 组。 Sketch 的 API 还为我们提供了一种在任何给定层上存储信息的方法,我们可以将其用作方式标记,然后将组标识为我们的“特殊”马赛克组之一。

让我们重新审视我们在上一节中编写的逻辑来解决这个问题。 我们的原始代码遵循以下步骤:

  1. 查找当前文档。
  2. 查找当前文档的选定图层。
  3. 复制所选图层(我们将其称为模板图层)x 次。
  4. 对于每个副本,通过用户设置的特定值(数量)调整其位置、旋转、不透明度等。

为了使我们的新用户流成为可能,我们需要将这些步骤更改为:

  1. 抓取当前文档。
  2. 抓取当前文档的选定图层。
  3. 确定所选图层是否为马赛克组。
    • 如果是其他图层,则将其用作模板图层并转到步骤 4。
    • 如果Mosaic 组,则将其中的第一层视为模板层,然后转到步骤 5。
  4. 将模板层包裹在一个组内,并将该组标记为马赛克组。
  5. 从组内删除除模板层之外的所有层。
  6. 复制模板层 x 次。
  7. 对于每个副本,通过用户设置的特定值调整其位置、旋转、不透明度等。

我们有了三个新步骤。 对于第一个新步骤,第 3 步,我们将创建一个名为findOrMakeSpecialGroupIfNeeded的函数,该函数将查看传递给它的图层以确定它是否是一个马赛克组。 如果是,我们就退货。 由于用户可能会选择嵌套在 Mosaic 组深处的子图层,因此我们还需要检查所选图层的父图层以判断它们是否也是我们的 Mosaic 组之一:

 function findOrMakeSpecialGroupIfNeeded(layer){ // Loop up through the parent hierarchy, looking for a special group var layerToCheck = layer; while(layerToCheck){ if(/* TODO: is mosaic layer? */){ return layerToCheck; } layerToCheck = layerToCheck.parent; } };

如果我们无法找到 Mosaic 组,我们只需将传入的图层包裹在Group中,然后将其标记为 Mosaic 组。

回到文件顶部,我们现在也需要拉出 Group 类:

 const { Document, Group } = require("sketch/dom");
 function findOrMakeSpecialGroupIfNeeded(layer){ // Loop up through the parent hierarchy, looking for a special group var layerToCheck = layer; while(layerToCheck){ if(/* TODO: is mosaic layer? */){ return layerToCheck; } layerToCheck = layerToCheck.parent; } // Group const destinationParent = layer.parent; const group = new Group({ name: "Mosaic Group", layers: [ layer ], parent: destinationParent }); /* TODO: mark group as mosaic layer */ return group; };

现在我们需要填补空白(待办事项)。 首先,我们需要一种方法来确定一个群体是否属于我们的特殊群体之一。 在这里,Sketch 库的Settings模块来帮助我们。 我们可以使用它在特定层上存储自定义信息,也可以将其读回。

一旦我们在文件顶部导入模块:

 const Settings = require("sketch/settings");

然后我们可以使用它提供的两个关键方法setLayerSettingForKeylayerSettingForKey来设置和读取图层的数据:

 function findOrMakeSpecialGroupIfNeeded(layer){ const isSpecialGroupKey = "is-mosaic-group"; // Loop up through the parent hierarchy, looking for a special group var layerToCheck = layer; while(layerToCheck){ let isSpecialGroup = Settings.layerSettingForKey(layerToCheck, isSpecialGroupKey); if(isSpecialGroup) return layerToCheck; layerToCheck = layerToCheck.parent; } // Group const destinationParent = layer.parent; layer.remove(); // explicitly remove layer from it's existing parent before adding it to group const group = new Group({ name: "Mosaic Group", layers: [ layer ], parent: destinationParent }); Settings.setLayerSettingForKey(group, isSpecialGroupKey, true); return group; };

现在我们已经有了一个方法来处理将图层包装到马赛克组中(或者,如果已经是马赛克组,则返回它),我们现在可以在安全检查之后将其插入主mosaic方法:

 function mosaic(options){ // ... safety checks ... // Group selection if needed: const group = findOrMakeSpecialGroupIfNeeded(selectedLayer); }

接下来,我们将添加一个循环以从组中删除除模板层(这是第一个)之外的所有层:

 function mosaic(options) { // ... // Remove all layers except the first: while(group.layers.length > 1){ group.layers[group.layers.length - 1].remove(); } }

最后,我们将确保组的大小适合其新内容,因为用户可能最初选择了嵌套在旧组中的层(我们可能已删除的层)。

我们还需要确保将当前选择设置为我们的马赛克组本身。 这将确保如果用户对同一个马赛克组进行大量快速更改,它不会被取消选择。 在我们已经编写的复制图层的代码之后,添加:

 function mosaic(options) { // ... // Fit group to duplicates group.adjustToFit(); // Set selection to the group document.selectedLayers.clear(); group.selected = true; }

再次尝试插件。 您应该会发现现在编辑马赛克更加顺畅!

改进界面

您可能会注意到的另一件事是显示窗口和其中的界面之间缺乏同步,因为它们都同时变得可见。 这是因为当我们显示窗口时,Web 界面并不能保证加载完成,所以有时它会在之后“弹出”或“闪入”。

解决此问题的一种方法是侦听 Web 界面何时完成加载,然后才显示我们的窗口。 有一个方法webView:didFinishNavigation: ,当当前页面完成加载时,WKWebView 将调用该方法。 我们可以使用它来准确获取我们正在寻找的通知。

回到ui.js ,我们将扩展我们创建的MochaJSDelegate实例来实现这个方法,它会依次调用我们将传递给createWebViewonLoadFinish参数:

 function createWebView(pageURL, onApplyMessage, onLoadFinish){ const webView = WKWebView.alloc().init(); // Create delegate const delegate = new MochaJSDelegate({ "webView:didFinishNavigation:": (webView, navigation) => { onLoadFinish(); }, "userContentController:didReceiveScriptMessage:": (_, wkMessage) => { const message = JSON.parse(wkMessage.body()); onApplyMessage(message); } }).getClassInstance(); // Set load complete handler webView.navigationDelegate = delegate; // Set handler for messages from script const userContentController = webView.configuration().userContentController(); userContentController.addScriptMessageHandler_name(delegate, "sketchPlugin"); // Load page into web view webView.loadFileURL_allowingReadAccessToURL(pageURL, pageURL.URLByDeletingLastPathComponent()); return webView; };

回到loadAndShow方法,我们将对其进行调整,使其仅在 Web 视图加载后才显示窗口:

 function loadAndShow(baseURL, onApplyMessage){ // ... const window = createWindow(); const webView = createWebView(pageURL, onApplyMessage, () => { showWindow(window); }); window.contentView = webView; _window = window; };

答对了! 现在我们的窗口仅在 web 视图完成加载时显示,避免了烦人的视觉闪烁。

结论

恭喜,你已经构建了你的第一个 Sketch 插件!

如果您想安装和使用 Mosaic,可以从 GitHub 下载完整的插件。 在您出发之前,这里有一些资源可能会在您接下来的旅程中派上用场:

  • developer.sketchapp.com 关于 Sketch 插件开发的官方资源。 包含几个有用的指南,以及 Sketch JavaScript 库的 API 参考。
  • sketchplugins.com 一个很棒且有用的 Sketch 插件开发者社区。 非常适合回答您所有迫切的问题。
  • github.com/sketchplugins/plugin-directory 官方,Sketch 插件的中央 GitHub 存储库。 你可以在这里提交你的插件并与 Sketch 社区的其他人分享!