Sketch插件开发

405 阅读9分钟

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);
        }
    });
}

参考

调试插件:github.com/skpm/sketch…

调试过程中插件无法watch更新可通过重启sketch或重装插件等操作

web页面开发模式无法点击,通过首次删除多余的iframe处理

github.com/fbmore/Colo…

developer.sketchapp.boltdoggy.com/guides/debu…

developer.sketch.com/reference/a…

github.com/sketch-hq/S…

github.com/alibaba/Gai…

github.com/wuba/Picass…

xaviervia.github.io/sketch2json…