sketch是一款用来制作矢量绘图的软件。sketch插件是按照特定方式管理的一个文件夹,它包含一个或多个脚本,每个脚本定义一个或多个以某种方式扩展 Sketch 的命令,它还可以包含命令用来执行任何操作的任何其他可选资源。
插件实现在sketch中进行控件和图层打标、颜色变量管理的功能。
技术架构方案
插件以WebView的形式进行功能呈现,sketch插件通过sketch-module-web-view创建BrowserWindow实例加载web应用。
sketch插件中使用BrowserWindow的webContents对象与webview进行通信,使用webContents.executeJavaScript方法执行web应用监听函数,使用webContents.on('自定义方法', () => {})监听web应用消息。
web应用通过window.postMessage发送消息到插件、window.[自定义方法]接收插件消息。
通过BrowserWindow完成sketch插件与web应用的桥接,实现web应用的数据展示和对sketch UI的操作。
项目结构
├── app// Web应用
└── src // Web页面
├── package.json
├── sketch // Sketch插件
└── assets // 静态资源
└── resources // Web应用打包后的资源文件存放位置,通过make命令将app/build/拷贝到这里
├── sketch-plugin-demo.sketchplugin // skpm构建后生成的插件安装包
├── package.json
├── webpack.skpm.config.js // skpm webpack配置文件
└── src // 插件逻辑
├── manifest.json // 插件的清单文件
└── action-center.js // 命令对应的执行脚本js
├── MakeFile // 构建脚本
app目录下存放web应用代码,sketch目录下存放插件逻辑代码,通过MakeFile构建脚本打包app下应用代码,并将打包结构拷贝到sketch/assets/resources中,再进行sketch插件构建。
# MakeFile
build:
cd app && npm run build
rm -rf sketch-plugin-demo.sketchplugin
cp -rf app/build/* sketch/assets/resources/
cd sketch && npm run build
manifest.json 清单文件是 Sketch 插件最核心的一个文件。主要包括插件名称、描述、作者信息、调用菜单项 menu、定义的命令 commands 、事件监听 Actions 等等。Sketch 插件支持定义一个或多个菜单项 menu,菜单项关联相应的命令 command,命令功能由对应的 JS 脚本来实现。
{
"compatibleVersion": 3,
"bundleVersion": 1,
"icon": "icon.png",
"identifier": "LowcodeSketchPlugin",
"commands": [{ // 定义命令
"name": "打开Plugin",
"identifier": "sketch-plugin-lowcode.panel", // 命令标识
"script": "./action-center.js", // 命令执行入口
"shortcut": "command shift g",
"handlers": {
"run": "onManualOpenAction", // 命令入口方法
"actions": { // 相关生命周期事件触发的方法调用
"OpenDocument": "onOpenDocumentAction",
"Shutdown": "onShutdownAction",
"CloseDocument": "onCloseDocumentAction",
"SelectionChanged.finish": "onSelectionChanged"
}
}
}
],
"menu": { // 插件目录下的菜单栏配置
"title": "Plugin",
"items": [
"sketch-plugin-lowcode.panel" // 命令标识
]
}
}
详细释义如下:
-
commands- name:命令对应菜单项的显示名称
- identifier:命令的唯一标识
- shortcut:调用命令的快捷键
- script:实现命令功能的函数所在的脚本
- handlers:定义处理程序,包含触发命令时调用的函数方法名(默认为 onRun),以及定义
Action事件监听
-
menu- title:插件在菜单栏显示的名称
- items:定义菜单的集合,对应命令的 identifier
插件功能开发举例
运行流程:
cd app && yarn && yarn start // 启动web页面
cd sketch && yarn && yarn watch // 启动sketch插件并监听变化自动构建
make // 打包
插件创建WebView容器加载web页面
创建WebView,使用sketch-module-web-view创建WebView容器加载Web应用页面。
- 创建js入口函数onOpenPanel,并配置到manifest.json中的commands中,用于插件打开窗口命令执行js脚本
- onOpenPanel中创建BrowserWindow实例,通过loadURL加载web应用,开发环境使用localhost地址,生产环节使用静态资源相对路径
- 将sketch context和browserWindow.webContents注入到各个模块脚本实现消息订阅与发送逻辑
- 在插件窗口关闭时通过Settings.settingForKey保存窗口历史位置和宽高,使每次打开窗口的位置有记忆性
逻辑代码举例:
import BrowserWindow from 'sketch-module-web-view';
import { getWebview } from 'sketch-module-web-view/remote';
const PanelIdentifier = 'my-plugin-with-webview.webview';
// 打开窗口命令的入口函数
export function onOpenPanel(context) {
// 保存窗口位置信息
const savedPosition = Settings.settingForKey('position') || {}
// BrowserWindow options配置信息
const options = {
identifier: PanelIdentifier,
x: savedPosition['x'],
y: savedPosition['y'],
title: 'This is a WebView',
width: savedPosition['width'] || 320,
height: savedPosition['height'] || 810,
show: false,
titleBarStyle: 'hiddenInset',
alwaysOnTop: true,
acceptsFirstMouse: true,
};
// 新建WebView窗口
const browserWindow = new BrowserWindow(options);
// 页面载入完成后才显示弹窗,避免窗口白屏
browserWindow.once('ready-to-show', () => {
browserWindow.show()
});
// webContents用于访问和控制浏览器窗口的内容
const webContents = browserWindow.webContents;
// 页面载入完成后触发提示信息
webContents.on('did-finish-load', () => {
UI.message('UI loaded!');
});
// 将context和webContents传入其他脚本函数中进行逻辑处理
registerComponentIPC(context, webContents);
// 装载web应用页面,开发环境和生产环境区分
browserWindow.loadURL(isProduction() ? './resources/index.html' : 'http://localhost:3000');
};
// 插件窗口关闭时触发
export function onShutdown (context) {
// 获取窗口实例
const existingWebview = getWebview(PanelIdentifier);
if (existingWebview) {
// 缓存窗口位置信息
Settings.setSettingForKey('position', existingWebview.getBounds());
existingWebview.close();
}
}
标记控件和标记图层
实现通过插件修改sketch文档中控件的名称和选中图层的名称。
控件显示
- sketch中任何组或图层集合都可以被创建成控件,然后在设计中复用
- 通过sketch/dom库的sketchDom.Library.getLibraries()方法获取当前空间下所有可用的控件库,并发送到web页面显示
- getLibraries中没有的控件库可通过导入sketch文件的方式进行控件库导入,通过使用@skpm/dialog库的showOpenDialog方法导入sketch文件,再使用sketch/dom的Library.getLibraryForDocumentAtPath方法加载控件库文件。
- 在web页面选择某个控件库,将选择操作发送到插件端,插件调用控件库Library对象的getDocument方法获取到控件库的dom对象,再调用libraary dom对象下的getSymbols方法获取到所有的控件symbol实例,将控件数据处理后发送到web端进行显示。
(由于在修改控件实例的属性时需要是当前文档下才能修改,因此在切换选择控件库时,可以通过sketc/dom的 Document.open方法打开控件库所在文档)
- 在web回现控件列表时,插件中可通过sketch/dom的export方法将控件对象导出为临时图片,再通过fs.readFileSync读取为base64格式数据,从而在web端能够显示控件的缩图。
控件库相关的数据加载都通过在插件端使用webContents.on("getComponentLibraries", () => {})创建数据处理监听,在插件窗口打开时,对应的web页面调用window.postMessage("getComponentLibraries")方法触发插件数据加载操作,插件端获取并处理好数据后通过webContents.executeJavaScript(`onDidGetComponentLibraries(${JSON.stringify({libraries})})`);将控件数据发送到web端,web端通过window.onDidGetComponentLibraries = () => {}方法进行监听获取。
代码逻辑举例:
export function registerComponentIPC(_context, webContents) {
webContents.on("getComponentLibraries", async () => {
try {
const libraries = [];
// 获取本地控件库
const sketchLibraries =
sketch.Library.getLibraries()?.filter((sketchLibrary) => {
return sketchLibrary.valid && sketchLibrary.enabled;
}) || [];
for (let i = 0; i < sketchLibraries.length; i++) {
const sketchLibrary = sketchLibraries[i];
const libDocument = sketchLibrary.getDocument();
libraries.push({
disabled: !sketchLibrary.enabled || !sketchLibrary.valid,
libraryID: String(libDocument.id),
type: "Local",
name: sketchLibrary.name,
lastModifiedAt: dayjs(sketchLibrary.lastModifiedAt).unix(),
});
}
// 触发web界面的回调,把数据发过去
webContents.executeJavaScript(`onDidGetComponentLibraries(${JSON.stringify({ libraries })})`);
} catch (e) {
console.log(e);
}
});
webContents.on("importComponents", async () => {
// 上传sketch文件
const { canceled, filePaths } = await dialog.showOpenDialog({
filters: [{ name: "sketch", extensions: ["sketch"] }],
title: "请选择要导入的组件库",
properties: ["openFile"],
});
if (!canceled && filePaths && filePaths.length > 0) {
const filePath = filePaths[0];
// 判断文件路径是否有效
if (fs.existsSync(filePath) && path.extname(filePath) === ".sketch") {
// 获取Sketch文档的控件库,同时会把控件库添加到全局的控件库中,后续可以通过sketch.Library.getLibraries()获取
const library = sketch.Library.getLibraryForDocumentAtPath(filePath);
// 打开sketch文档,便于后续修改控件逻辑的执行,否则不需要打开
try {
sketch.Document.open(decodeURI(filePath), (err) => {
if (!err) {
// 更新library到web界面
const libraryInfo = {
disabled: !library.enabled || !library.valid,
libraryID: String(library.id),
type: "Local",
name: library.name,
lastModifiedAt: dayjs(library.lastModifiedAt).unix(),
};
webContents.executeJavaScript(`onDidImportComponents(${JSON.stringify(libraryInfo)})`);
}
});
} catch (e) {
console.log(e);
}
}
} else {
webContents.executeJavaScript(`onDidImportComponents()`);
}
});
// 在全局控件库中删除某个控件库
webContents.on("deleteComponents", async (libraryInfo) => {
// 获取最新的控件库实例
let selectedLibrary = await getSelectedLibrary(libraryInfo);
if (selectedLibrary) {
selectedLibrary.remove();
}
webContents.executeJavaScript("onDidDeleteComponents()");
});
// 获取控件库中的控件列表
webContents.on("getComponents", async (libraryInfo) => {
if (!libraryInfo.libraryID) {
return;
}
try {
// 获取最新的控件库实例
const selectedLibrary = await getSelectedLibrary(libraryInfo);
if (selectedLibrary) {
const symbolMasters = [];
const libDocument = selectedLibrary.getDocument();
// 获取控件库中的控件symbol列表
const symbols = libDocument?.getSymbols();
symbols?.forEach((layer) => {
layer.name = `${layer.name}`;
// 导出symbol为png图片
sketch.export(layer, {
"use-id-for-name": true,
formats: "png",
scales: "2",
trimmed: false,
output: screenshotsFolder(),
overwriting: true,
});
// 编组结构会以/分割,这里根据/分割的层级关系,构建树形结构
const splits = layer.name.split("/");
if (splits.length > 0) {
const symbolFormat = (result, layer, nameSplits, lastIndex) => {
if (lastIndex === nameSplits.length - 1) {
// 读取图片文件,转为base64
const base64String = fs.readFileSync(
path.join(screenshotsFolder(), layer.id + "@2x.png"),
{
encoding: "base64",
}
);
result.push({
key: layer.id,
name: nameSplits[lastIndex],
fullName: layer.name,
width: layer.frame.width,
height: layer.frame.height,
thumbnail: "data:image/png;base64, " + base64String,
originId: layer.symbolId,
});
return;
}
const index = indexOfSymbol(result, nameSplits[lastIndex]);
if (index === -1) {
result.push({
name: nameSplits[lastIndex],
links: [],
key: `${layer.id}${nameSplits[lastIndex]}${index}`,
selectable: false,
});
}
symbolFormat(
result[index === -1 ? result.length - 1 : index].links,
layer,
nameSplits,
lastIndex + 1
);
};
symbolFormat(symbolMasters, layer, splits, 0);
}
});
webContents.executeJavaScript(`onDidGetComponents(${JSON.stringify({ groups: symbolMasters })})`);
}
} catch (e) {
console.log(e);
}
});
}
web端数据处理
useEffect(() => {
// 触发插件端数据处理
window.postMessage("getComponentLibraries");
// 接收控件库数据
window.onDidGetComponentLibraries = (data) => {
// 数据处理与展示
}
}, []);
控件名称修改
该功能主要用于为控件名添加或替换标签,例如#标签名#,便于后续UI转代码实现组件识别转换。
- web端选择需修改的控件库,配置模糊匹配的控件名关键字,以及需要新增或替换的标签内容。web端发送配置信息到插件端进行控件名批量修改。
- 插件端通过getSymbols获取控件列表,但控件库api获取的symbos是无法进行属性修改的,需要获取当前打开的sketch文档的dom对象,再通过getSymbolMasterWithID(symbol.symbolId)获取到当前文档下的控件实例,然后完成控件名修改。
- 全部修改完成后,调用sketch/dom的save保存文档操作,使web端后续能获取到最新的数据。
代码逻辑举例:
export function registerChangeLayerIPC(_context, webContents) {
webContents.on("batchChangeLayer", async (libraryInfo, pathKey, tagName) => {
try {
const selectedLibrary = await getSelectedLibrary(libraryInfo);
if (selectedLibrary) {
const libDocument = selectedLibrary.getDocument();
// 获取symbols,这里的symbol对象是不可修改的,只能通过symbolId获取到对应的symbolMaster对象,然后修改
const symbols = libDocument && libDocument.getSymbols();
// 获取当前文档的dom对象
const document = sketch.Document.getSelectedDocument();
symbols.forEach((symbol) => {
if (symbol.name.toLocaleLowerCase().includes(pathKey.toLocaleLowerCase())) {
// 直接修改symbol无法改变sketch中控件名称,但可使getSymbols()获取到的symbol对象的name属性发生变化
changeLibraryLayerName(symbol, tagName);
// 查找当前文档下的控件实例进行修改
const symbolData = document.getSymbolMasterWithID(symbol.symbolId);
changeLibraryLayerName(symbolData, tagName);
}
});
// 保存并延迟响应到web端,避免web端获取旧数据
new Promise((resolve, reject) => {
document.save((err, result) => {
if (err) {
reject(err);
}
setTimeout(() => {
resolve(result);
}, 500);
});
})
.then(() => {
// 通知web端获取最新数据
webContents.executeJavaScript(`onchangeComponentsNameEnd(${1})`);
})
.catch(() => {
webContents.executeJavaScript(`onchangeComponentsNameEnd(${0})`);
});
}
} catch (e) {
console.log(e);
webContents.executeJavaScript(`onchangeComponentsNameEnd(${0})`);
}
});
}
图层标记
在当前文档选中某个图层,在插件窗口预览选中图层,并进行标记操作。
- 通过sketch/dom获取到当前文档的dom对象,用于选中某个图层后,插件可直接通过dom对象的selectedLayers.layers[0]获取到选中的图层对象layer。
- 通过sketch/dom的export方法将控件对象导出为临时图片,再通过fs.readFileSync读取为base64格式数据,从而在web端能够显示图层的缩图进行预览。
- 由于是修改选中图层,所以在图层名修改时,可使用第一步获取到layer对象,直接修改layer对象属性即可。
逻辑代码举例:
function changeLayerName(newTag) {
const document = sketch.Document.getSelectedDocument();
if (document?.selectedLayers?.layers?.length > 0) {
const selectedLayer = document.selectedLayers.layers[0];
const reg = /#(.*)#/;
if (reg.test(selectedLayer.name)) {
selectedLayer.name = selectedLayer.name.replace(reg, `#${newTag}#`);
} else {
selectedLayer.name = `${selectedLayer.name}#${newTag}#`;
}
}
}
sketch颜色变量管理
实现对sketch中颜色变量的批量操作,包括在web端批量创建、修改颜色变量,UI设计使用颜色变量,便于后续实现低代码的主题变量管理。
- 通过当前文档的dom对象的swatches属性获取颜色变量数据。
- swatch的属性是只读的,通过swatch的sketchObject属性的updateWithColor方法传入MSColor对象,实现颜色值的修改(sketchObject提供了对Sketch内部对象的直接访问。它桥接到Sketch内部Objective-C API的接口,允许直接操作Sketch的底层数据模型)。
- 通过swatchs.push新增颜色变量到sketch。
代码逻辑举例:
export function registerColorIPC(context, webContents) {
webContents.on("getColorVariables", async () => {
try {
const document = sketch.Document.getSelectedDocument();
// 获取当前文档的颜色变量
const colorVariables = document.swatches;
webContents.executeJavaScript(`onDidGetColorVariables(${JSON.stringify(colorVariables)})`);
} catch (e) {
console.log(e);
}
});
webContents.on("updateColorVariables", async (colors) => {
try {
const document = sketch.Document.getSelectedDocument();
colors.forEach((item) => {
// 找到变量位置
const variableIndex = document.swatches.findIndex((swatch) => {
// 判断修改逻辑...
return flag;
});
if (variableIndex !== -1) {
const existingSwatch = document.swatches[variableIndex];
// updateWithColor要求参数是MSColor对象
document.swatches[variableIndex].sketchObject.updateWithColor(
MSColor.colorWithHex_alpha(item.color.slice(0, 7), parseInt(item.color.slice(7), 16) / 255)
);
// 变量名可直接修改
document.swatches[variableIndex].name = item.name;
const swatchContainer = document.sketchObject.documentData().sharedSwatches();
swatchContainer.updateReferencesToSwatch(existingSwatch.sketchObject);
} else {
// 创建颜色变量
const colorItem = sketch.Swatch.from({
name: item.name,
color: item.color,
});
document.swatches.push(colorItem);
}
});
} catch (e) {
console.log(e);
}
});
}
参考
调试过程中插件无法watch更新可通过重启sketch或重装插件等操作
web页面开发模式无法点击,通过首次删除多余的iframe处理
developer.sketchapp.boltdoggy.com/guides/debu…