HTML/CSSコーダーにもXDのプラグインの自作をお勧めする件

XDのプラグインといえばデザイナー向けの便利機能が多いと思いますが、HTML/CSSコーダーがXDから情報を取り出すときにもプラグインが便利です。一般に公開されているものもたくさんありますが、ここではXDのプラグインを自作する方法について説明します。
機能としては以下のようなものを作ります。
- グループ内のテキスト要素から文字をまとめてコピーする
- １つのテキスト要素や四角要素から色情報などを取り出し、css形式でコピーする

また、自作することにより、以下のようなメリットがあります。
- 自分のコーディングルールに合うようにカスタマイズできる
- ちょっとjavascriptの練習になる

# 対象の読者
- XDのデザインカンプをもらってHTML/CSSをコーディングする人
- javascriptの基本的な文法が分かる人

XDが初めての方はこちら↓

https://qiita.com/yymmt/items/0c4daa744a88a72593d4

今回も、こちら↓のサイトから、ポートフォリオのXDファイルをお借りして説明します。

https://webdesigner-go.com/template/coding-tutorial/

# プラグイン作成手順

### フォルダ構成

XDでプラグインを自作する際、「開発フォルダー」にこのように配置します。

```
developフォルダ
└ (プラグイン名)フォルダ
  └ main.js
  └ manifest.json
```

- `main.js` ： 処理の内容を記述します。
- `manifest.json` ： プラグインの概要を記述します。XDのメニューに表示される文字列や、ショートカットキーの設定など。

`develop`フォルダは、XDの「プラグイン → 開発版 → 開発フォルダーを表示」から開きます。(「レガシー」と出てますが、今回のようにちょっとしたものを作るなら十分です。)
[![Image from Gyazo](https://i.gyazo.com/ae57232546da315c23f29d6bd589c1a6.png)](https://gyazo.com/ae57232546da315c23f29d6bd589c1a6)

今回は`develop`フォルダ内に、このように`MyPlugin`フォルダ、`main.js`ファイル、`manifest.json`ファイルを作成します。
[![Image from Gyazo](https://i.gyazo.com/12dadc789dd7c286d8f7f12246d6ce8f.png)](https://gyazo.com/12dadc789dd7c286d8f7f12246d6ce8f)

それぞれ以下を記述します。(一部解説を後述。)

``` json:manifest.json
{
    "name": "MyPlugin",
    "id": "copy-text-property",
    "version": "1.0.0",
    "description": "copy text from Text element, or copy property from Text, Rectangle element and so on.",
    "host": {
        "app": "XD",
        "minVersion": "13.0.0"
    },
    "uiEntryPoints": [
        {
            "type": "menu",
            "label": "Copy Text",
            "commandId": "copy-text",
            "shortcut": {
                "mac": "Cmd+Alt+C",
                "win": "Ctrl+Alt+C"
            }
        },
        {
            "type": "menu",
            "label": "Copy Property(px)",
            "commandId": "copy-property-px",
            "shortcut": {
                "mac": "Cmd+Alt+A",
                "win": "Ctrl+Alt+A"
            }
        },
        {
            "type": "menu",
            "label": "Copy Property(vw)",
            "commandId": "copy-property-vw",
            "shortcut": {
                "mac": "Cmd+Alt+Shift+A",
                "win": "Ctrl+Alt+Shift+A"
            }
        },
        {
            "type": "menu",
            "label": "Copy Property(calc vw)",
            "commandId": "copy-property-calc-vw",
            "shortcut": {
                "mac": "Cmd+Alt+Shift+X",
                "win": "Ctrl+Alt+Shift+X"
            }
        }
    ]
}
```

``` javascript:main.js
const { Text, Line, Rectangle, Ellipse, Artboard, Group, SymbolInstance } = require("scenegraph");
const clipboard = require("clipboard");

function copyText(selection) {
    copy(getLines(selection.items).join("\n"));
}

function copyPropertyPx(selection) {
    copyProperty(selection.items[0], "px");
}

function copyPropertyVw(selection) {
    copyProperty(selection.items[0], "vw");
}

function copyPropertyCalcVw(selection) {
    copyProperty(selection.items[0], "calcvw");
}

function copyProperty(item, md) {
    let ret=[];
    let p = item;
    while(!(p instanceof Artboard)) { p = p.parent; }
    let artBoardWidth = p.width;
    let f = (v,d) => parseFloat(v.toFixed(d));
    let unitNum = (v) => {
        if(md == "vw"){
            return `${f(v/artBoardWidth*100,2)}vw`;
        }
        if(md == "calcvw"){
            return `calc(${v} / ${artBoardWidth} * 100vw)`;
        }
        return f(v,2) + "px";
    }
    let getFontStyleByFontFamily = f => f.replace(/.*-/,"");

    console.log(item);

    if(item instanceof Text) {
        item.styleRanges.forEach(s=>{
            ret.push(`font-size: ${unitNum(s.fontSize)};`);
            ret.push(`font-weight: ${weightNameToNumber(s.fontStyle || getFontStyleByFontFamily(s.fontFamily))};`);
        });
        ret.push(`line-height: ${f(item.lineSpacing/item.fontSize,4)}em;`);
        ret.push(`color: ${toHex(item.fill.value)};`);
        ret.push(`letter-spacing: ${f(item.charSpacing/1000,4)}em;`);
    }
    if(item instanceof Line) {
        if(item.strokeEnabled && item.stroke) {
            ret.push(`border-bottom: ${unitNum(item.strokeWidth)} solid ${toHex(item.stroke.value)};`);
        }
    }
    if(item instanceof Rectangle) {
        ret.push(`width: ${unitNum(item.width)};`);
        ret.push(`height: ${unitNum(item.height)};`);
        if(item.cornerRadii.topLeft || item.cornerRadii.topRight || item.cornerRadii.bottomRight || item.cornerRadii.bottomLeft){
            if(item.cornerRadii.topLeft == item.cornerRadii.topRight &&
                item.cornerRadii.topLeft == item.cornerRadii.bottomRight &&
                item.cornerRadii.topLeft == item.cornerRadii.bottomLeft){
                ret.push(`border-radius: ${unitNum(item.cornerRadii.topLeft)};`);
            } else {
                ret.push(`border-radius: ${unitNum(item.cornerRadii.topLeft)} ${unitNum(item.cornerRadii.topRight)} ${unitNum(item.cornerRadii.bottomRight)} ${unitNum(item.cornerRadii.bottomLeft)};`);
            }
        }
    }
    if(item instanceof Ellipse) {
        ret.push(`width: ${unitNum(item.radiusX*2)};`);
        ret.push(`height: ${unitNum(item.radiusY*2)};`);
        ret.push(`border-radius: 50%;`);
    }
    if(item instanceof Rectangle || item instanceof Ellipse) {
        if(item.fillEnabled && item.fill) {
            if(item.fill.value) {
                ret.push(`background-color: ${toHex(item.fill.value)};`);
            } else if(item.fill.colorStops){
                let c = item.fill.colorStops.map(cs=>toHex(cs.color.value)).join(",");
                ret.push(`background: linear-gradient(${c});`);
            }
        }
        if(item.strokeEnabled && item.stroke) {
            ret.push(`border: ${unitNum(item.strokeWidth)} solid ${toHex(item.stroke.value)};`);
        }
    }
    if(item.shadow && item.shadow.visible) {
        ret.push(`text-shadow: ${unitNum(item.shadow.x)} ${unitNum(item.shadow.y)} ${unitNum(item.shadow.blur)} ${toHex(item.shadow.color.value)};`);
    }
    copy(ret.join("\n"));
}

function copy(s){
    s = s.trim();
    if(s==""){
        return;
    }
    console.log(s);
    clipboard.copyText(s);
}

function toHex(v) {
    let c = "00000000" + v.toString(16).toUpperCase();
    c = c.replace(/^.*?(..)(......)$/,"$2$1");
    c = c.replace(/FF$/,"");
    return '#' + c;
}

function weightNameToNumber(name) {
    name = name.trim().toLowerCase().replace(" ","");
    if(/^(w0|w1|thin|hairline)$/           .test(name)) { return 100; }
    if(/^(w2|extralight|ultralight)$/      .test(name)) { return 200; }
    if(/^(w3|light)$/                      .test(name)) { return 300; }
    if(/^(w4|normal|regular)$/             .test(name)) { return 400; }
    if(/^(w5|medium)$/                     .test(name)) { return 500; }
    if(/^(w6|semibold|demibold)$/          .test(name)) { return 600; }
    if(/^(w7|bold)$/                       .test(name)) { return 700; }
    if(/^(w8|extrabold|ultrabold)$/        .test(name)) { return 800; }
    if(/^(w9|black|heavy)$/                .test(name)) { return 900; }
    if(/^(extrablack|ultrablack)$/         .test(name)) { return 950; }
    return 400;
}

function getLines(items) {
    const lines = items.filter(i => i instanceof Text).map(i => i.text);
    items.filter(item => item instanceof Group || item instanceof SymbolInstance).forEach((item)=>{
        lines.push(...getLines(item.children));
    });
    return lines;
}

module.exports = {
    commands: {
        "copy-text": copyText,
        "copy-property-px": copyPropertyPx,
        "copy-property-vw": copyPropertyVw,
        "copy-property-calc-vw": copyPropertyCalcVw,
    }
};
```

### メニューへの表示を確認

「プラグイン →　開発版 →　プラグインを再読込み」をクリックします。
[![Image from Gyazo](https://i.gyazo.com/5fb5ad75f7143e5d5f840b7d87ac4cae.png)](https://gyazo.com/5fb5ad75f7143e5d5f840b7d87ac4cae)

「プラグイン →　MyPlugin」が表示されていれば成功です。
[![Image from Gyazo](https://i.gyazo.com/e7f80a37838218116d5cae41358a942f.png)](https://gyazo.com/e7f80a37838218116d5cae41358a942f)

# 使い方

テキスト要素を選択した状態`1`で「Copy Text」を実行`2`すると、文字をコピーできます`3`。
![](https://i.gyazo.com/986c27b2009deb3a7c203aa846491abd.png)

グループを選択した状態`1`で「Copy Text」を実行`2`すると、グループに含まれるテキスト要素全てからコピーされます`3`。見た目の順番通りにはなりませんので、HTMLに貼り付けるときは適宜順番を調整しましょう。
![](https://i.gyazo.com/1ba85a147f4eaba10540e80aee9fddcd.jpg)

テキスト要素を選択した状態`1`で「Copy Property(px)」を実行`2`すると、`font-size`や`color`などをコピーできます`3`。「vw」の方`4`ではvw単位でコピーできます`5`。
![](https://i.gyazo.com/c7b818dc19aba08a8533e12e2f634258.png)

四角要素を選択した状態では、`width`, `height` など。
![](https://i.gyazo.com/282409fb381119deee4a3aa737c8e3bc.png)

四角要素は、塗りがグラデーションであれば `linear-gradient` で取得できるようにしました。
![](https://i.gyazo.com/978416a33c1e3ff4bbbe0602853e2a9a.png)

線要素を選択した状態では、`border-bottom` として取り出します。(上記全体的にですが、CSSでは適宜修正して使うことを想定しています。この例では border-left に修正する必要がありますし、斜めの線では transform と組み合わせるか画像にするか...など工夫が必要です。)
![](https://i.gyazo.com/06aa4a082bc4a5f97939b349a870cb00.png)

# 軽く解説

### XDのメニュー、manifest.json、main.jsの関係

- `manifest.json`の`name`と`uiEntryPoints.label`がXDのメニューに表示されます。
- `manifest.json`の`uiEntryPoints.commandId`が`main.js`の`module.exports.commands`に対応する関数を呼び出します。

![](https://i.gyazo.com/26d12f8711ba8694280ea8be6247aceb.png)

### main.js(XD関連の部分のみピックアップして解説します。)

フロントのコーディングが多い人は`require`に馴染みがないかもしれませんが、サーバサイドやある種のツール上でjavascriptを動かす際にはよく出てきます。ここでは、XDの`Text`, `Line`, `Rectangle`, ... の各要素や、クリップボードの機能を読み込んでいます。
[![Image from Gyazo](https://i.gyazo.com/a8e5732115f01b740f0a68c75e96c8de.png)](https://gyazo.com/a8e5732115f01b740f0a68c75e96c8de)

実行される関数の第１引数`selection`には、XDで選択されている物の情報が入っています。`selection.items` で、選択されている要素の配列を参照できます。
![](https://i.gyazo.com/1e0cbb974243e05dda80fa445e7aac05.png)

`instanceof` は、オブジェクトの種類を判定します。ここでは、選択された要素の親要素を辿ってアートボードを探し、`artBoardWidth`変数にアートボードの横幅を代入しています。vwの計算に使います。
![](https://i.gyazo.com/fa533e9cc02134712c568e310716ba87.png)

`item.text`で、テキスト要素の文字を取り出します。`Group`か`SymbolInstance`の場合、再帰してグループ内のテキスト要素を探します。
[![Image from Gyazo](https://i.gyazo.com/57c9d4bc92b99a2e6bffc703b044f27b.png)](https://gyazo.com/57c9d4bc92b99a2e6bffc703b044f27b)

`Text`要素の場合、`Line`要素の場合、`Rectangle`要素の場合...と、要素の種類ごとにプロパティを取り出して、css形式に変換しています。
![](https://i.gyazo.com/89feb874d1b822ea6e2edcc27eb9a78b.png)

cssの`letter-spacing`が、XDオブジェクトでは`charSpacing`と名前が違ったり1000倍した値だったりと、形式が異なるので、カスタマイズする際はこちら↓のリファレンスを参照しましょう。

https://adobexdplatform.com/plugin-docs/reference/scenegraph.html#rectangle

`console.log`でXDのコンソールに出力できます。デバッグに使います。XDのコンソールは、XDの「プラグイン →　開発版 →　開発者コンソール」から開きます。
[![Image from Gyazo](https://i.gyazo.com/3523b5a8ff9a42da40eecf1832c43322.jpg)](https://gyazo.com/3523b5a8ff9a42da40eecf1832c43322)