Atom Editor用の取るに足らない自作Init Scriptを紹介してみる

# はじめに

この記事は、[以前の記事][url1]の焼き直しみたいなものです。
よろしければそちらもご覧ください。

Init Scriptを作業のために作りました。
でも大して使いませんでした。(´・ω・\`)
無駄にした労力を何かにぶつけたくなり、久しぶりに記事にしてみることにしました。
パッケージを作るまでもないような取るに足らないものですが、もしかしたら何かの役に立つかもしれないです。
記事にするためには体裁を整えなければいけないと思い、スクリプトに手を加えて余計な作業をしていますがきっと気のせいでしょう。

# 自動的に変換されるcopyとduplicate

## 目的

作業を楽にするために、文字列をコピーして貼り付ける際に、自動的に変換させる機能が欲しくなりました。
通常の状態では、

- copy
    1. ctrl-cでコピーする。
    2. ctrl-vで貼り付ける。
    3. 貼り付けた文字列を変換する。
- duplicate
    1. ctrl-shift-dで複製する。
    2. 複製した文字列を変換する。

となる作業を

- copy
    1. ctrl-cでコピーする。クリップボード内の文字列を自動変換される。
    2. ctrl-vで変換後の文字列を貼り付ける。
- duplicate
    1. ctrl-shift-dで複製する。複製される文字列は変換後のものとなる。

に低減するのが目的です。

## 実装

目的の操作を実現するために、`init.js`へ関数を定義して行きます。
`init.js`の先頭行には、"babel"によるトランスコンパイルを有効にするために`"use babel"`を記述しておきます。

### Copy版

まずは、copyの場合です。
copy版で使うAPIの動作を簡単に説明すると以下のようになります。

- `atom.workspace.getActiveTextEditor()`: アクティブな`Editor`を取得する。
- `editor.copySelectedText()`: 選択された文字列をクリップボードにコピーする。
- `atom.clipboard.readWithMetadata()`: クリップボードから文字列とメタデータを読み込む。
- `atom.clipboard.write(text, metadata)`: クリップボードに文字列とメタデータを書き込む。

これらのAPIを使い、以下のような処理を行っています。
ここでは複数選択に対応させています。
複数選択に対応させなければもっと簡単な処理にすることも出来ます。

1. エディタを取得する。Atom上で処理を行う多くの場合は、エディタを取得する必要があります。
2. 選択された文字列をクリップボードにコピーする。メタデータを一緒に取得しているのは複数選択に対応するためです。そうでなければメタデータは要りません。
3. 文字列を置換する。複数選択をした場合でも文字列を連結したものが得られているので、それを置換します。
4. 複数選択されている場合は、選択されている文字列をそれぞれ置換する。
5. クリップボードに変換後の文字列を書き込む。

詳しくは後述しますが、ここではinitScriptView.searchとinitScriptView.replaceを定義済みの大域変数としておいてください。
initScriptView.searchには、置換対象の正規表現("検索文字列"と呼ぶ事にします)が格納されています。
また、initScriptView.replaceには、置換する文字列(同じく"置換文字列"と呼ぶ事にします)が格納されています。

#### `init.js`

```javascript:javascript
function replacedCopy() {
  const editor = atom.workspace.getActiveTextEditor();
  if (editor === null) {
    return;
  }

  editor.copySelectedText();
  let { text, metadata } = atom.clipboard.readWithMetadata();
  text = text.replace(initScriptView.search, initScriptView.replace);
  if (Object.prototype.hasOwnProperty.call(metadata, "selections")) {
    for (const selection of metadata.selections) {
      selection.text = selection.text.replace(initScriptView.search, initScriptView.replace);
    }
  }
  atom.clipboard.write(text, metadata);
}
```

### duplicate版

次に、duplicateの場合です。
duplicate版で使うAPIの動作を簡単に説明すると以下のようになります。

- `editor.getCursors()`: `Editor`上の全ての`Cursor`を取得する。
- `cursor.getCurrentLineBufferRange()`: カーソルがある行の文字列の範囲を取得する。
- `editor.lineTextForBufferRow(row);`: row行の文字列を取得する
- `cursor.setBufferPosition(point)`: カーソルの位置をpointに変更する。

これらのAPIを使い、以下のような処理を行っています。
こちらも複数選択に対応させています。

1. エディタを取得する。
2. 全てのカーソルを取得する。複数選択に対応させるために全てのカーソルが必要です。
3. カーソルがある行の文字列の範囲を取得する。処理すべき文字列の位置が必要なためです。
4. カーソルがある行の文字列を取得する。処理対象の文字列です。
5. 文字列を置換する。
6. 文字列を置換前文字列と置換後文字列を連結したもので置き換える。こうすることで文字列を複製しています。
7. カーソルを1つ下の行に移動させる。通常のduplicateは、複製された文字列にカーソルが移るのでそれを再現するために行っています。
8. 全てのカーソルの分だけ3.から7.を繰り返す。

#### `init.js`

```javascript:javascript
function replacedDuplicate() {
  const editor = atom.workspace.getActiveTextEditor();
  if (editor === null) {
    return;
  }

  const cursors = editor.getCursors();
  for (const cursor of cursors) {
    const range = cursor.getCurrentLineBufferRange();
    const text = editor.lineTextForBufferRow(range.start.row);
    if (text !== "") {
      const replaced = text.replace(initScriptView.search, initScriptView.replace);
      editor.setTextInBufferRange(range, `${text}\n${replaced}`);
    }
    cursor.setBufferPosition([range.start.row + 1, range.start.column]);
  }
}
```

### "検索文字列"と"置換文字列"について

#### その1 大域変数

上では、initScriptView.searchに"検索文字列"が、initScriptView.replaceに"置換文字列"が大域変数として格納されていますと書きました。
その言葉通りに以下のように、単純に大域変数として定義すれば上記の関数は動作します。

`init.js`

```javascript:javascript
const initScriptView = {};
initScriptView.search = /"search"/g;
initScriptView.replace = "replace";
```

こうした場合でも、思い通りの動作は出来ますし文字列を頻繁に変えなければ支障はありません。
実際に使っていた時には、このまま使っていました。
ただ、それぞれの文字列の変更には、Atomの再起動が必要です。
頻繁に変更する場合は、多くの再起動が必要でとても使い勝手が良いとは言えません。
そこで、将来的に他の処理を作ることも考え動的に変数を変えられる仕組みを考えてみました。

#### その2 既存パッケージのViewから取って来る

今回の機能は、検索文字列と置換文字列を必要とします。
これは、コアパッケージのfind-and-replaceが持っています。
コアパッケージを使わない人はいないはずです。
また、文字列の使われ方が自作関数の使い方と一致します。
丁度良いのでこのコアパッケージの変数をinitScriptView.searchとinitScriptView.replaceに代入することにします。
代入処理を以下のような関数にしてみました。

- atom.packages.getActivePackage(name) : nameで指定されたアクティブなパッケージを取得する
- atom.packages.enablePackage(name) : nameで指定されたパッケージを有効化する

1. "find-and-replace"を取得する。
2. もし取得できなかったら有効化されておらずなおかつアクティブになっていないので、有効化およびアクティブ化する。
3. mainModule.findOptions.findPatternおよびmainModule.findOptions.replacePatternに目的の値が格納されているので代入する。

```javascript:JavaScript
function getSearchPatternFromFaR() {
  const farName = "find-and-replace";
  let far = null;

  if (!far) {
    far = atom.packages.getActivePackage(farName);
    if (!far) {
      far = atom.packages.enablePackage(farName);
      far.mainModule.activate();
    }
  }

  initScriptView.search = far.mainModule.findOptions.findPattern;
  initScriptView.replace = far.mainModule.findOptions.replacePattern;
}
```

これをreplacedCopy()とreplacedDuplicate()の実行前に実行すれば必要な文字列が得られるようになります。
ただし、この記事では、この関数は使いません。
今回は、自作関数の意図に一致した入力を得られました。
独自の値が必要な時も考えて別の方法で値を得る仕組みも作ってみたいと思います。
だだし、ライブラリなど使っていないこともあり面倒な記述になっています。

#### その3 "検索文字列"と"置換文字列"の取得用View

変数への入力を実現するために、文字列取得用のインターフェースを作ります。
Atomでは、DOM(HTML)でViewを定義し、それをPanelとして登録することでインターフェースを作ることが出来ます。
ここでは、Init Script用のViewを`Class` InitScriptViewとして定義します。
Viewのひな形は、"Package generator"により作成されるパッケージ用のViewを使っています。
Viewには

- "検索文字列"入力用のInput
- "置換文字列"入力用のInput
- パネルを閉じるためのButton

を作ります。
Atom用のInputやButtonの作り方は、"Style Guide"(ctrl-shift-g)を参照してください。

Inputを`input-search`で作成しているのは、こうすると入力文字列を消す×ボタンが使えるからです。
文字列を入力するようなInputを作成する場合、一番親のノードのクラスに`navive-key-bindings`を設定してください。
これを設定しておかないと"delete"や"back space"が効かなくなります。
(設定しなくても`addEventListener()`などで自分でいちいち設定してやれば動くのですが、わざわざそんなことをする必要はないでしょう。)
Inputには、それぞれ入力した文字列を"検索文字列"および"置換文字列"に代入する処理をイベントハンドラとして登録します。

Buttonに`close`クラスを追加するとAtomでは特別なボタンとして認識するようです。
(btn.close用のCSSが適用されるだけでしょうか?)
役割や配置的に都合が良いので作成したButtonには、"close"を設定しています。
Buttonには、Panelを閉じる処理をイベントハンドラとして登録します。

作成したView表示するには、AtomのPanelとして登録する必要があります。
表示位置によって以下のようなAPIがあります。

- `atom.workspace.addBottomPanel(option)`
- `atom.workspace.addLeftPanel(option)`
- `atom.workspace.addRightPanel(option)`
- `atom.workspace.addTopPanel(option)`
- `atom.workspace.addHeaderPanel(option)`
- `atom.workspace.addFooterPanel(option)`
- `atom.workspace.addModalPanel(option)`

ここでは、TopPanelとして登録しました。
登録をViewのコンストラクタ上で行っているのはあまりお行儀が良いものではないかもしれません。

定義したInitScriptViewはどこかで作成しなければなりません。
ここでは、Init Script開始とともに行っています。
InitScriptViewのコンストラクタで同時にPanelへの登録をしているので、これで準備は完了です。

最後に、登録されたPanelは、表示しなければ見えません。
表示するための関数`toggleInitPanel()`を作成しています。
この関数を呼び出すことで任意のタイミングでPanelが開閉されるようになります。

##### `init.js`

```javascript:javascript
class InitScriptView {
  constructor() {
    this.search = "";
    this.replace = "";

    this.element = document.createElement("div");
    this.element.classList.add("initScript", "native-key-bindings", "view");

    this.input1 = InitScriptView.makeInput({
      "cl": "search",
      "placeholder": "Search",
      "defaultValue": this.search.toString(),
      "func": this.setSearch(),
    });
    this.input2 = InitScriptView.makeInput({
      "cl": "replace",
      "placeholder": "Rearch",
      "defaultValue": this.replace,
      "func": this.setReplace(),
    });
    this.button = InitScriptView.makeButton({
      "cl": "close",
      "icon": "icon-x",
      "func": this.hidePanel(),
    });

    this.element.appendChild(this.button);
    this.element.appendChild(this.input1);
    this.element.appendChild(this.input2);

    this.panel = atom.workspace.addTopPanel({
      "item": this.getElement(),
      "visible": false,
      "className": "initScriptPanel",
    });
  }

  destroy() {
    this.element.remove();
    this.input1.removeEventListener("input", this.input1.func, false);
    this.input2.removeEventListener("input", this.input2.func, false);
    this.button.removeEventListener("click", this.button.func, false);
  }

  getElement() {
    return this.element;
  }

  setSearch() {
    return (evt) => {
      try {
        this.search = new RegExp(evt.target.value, "g");
      } catch (_e) {
        this.search = evt.target.value;
      }
    };
  }

  setReplace() {
    return (evt) => {
      this.replace = evt.target.value;
    };
  }

  togglePanel() {
    if (this.panel.isVisible()) {
      this.panel.hide();
    } else {
      this.panel.show();
    }
  }

  hidePanel() {
    return () => {
      this.panel.hide();
    };
  }

  static makeInput({ cl, placeholder, defaultValue, func }) {
    const input = document.createElement("input");
    input.classList.add("initScript", "input-search", cl);
    input.type = "search";
    input.placeholder = placeholder;
    input.defaultValue = defaultValue;
    input.addEventListener("input", func, false);
    input.func = func;
    return input;
  }

  static makeButton({ cl, icon, func }) {
    const button = document.createElement("button");
    button.classList.add("initScript", "btn", "inline-block", "icon", icon, cl);
    button.func = func;
    button.textContent = "close";
    button.addEventListener("click", func, false);
    return button;
  }
}

const initScriptView = new InitScriptView();

function toggleInitPanel() {
  initScriptView.togglePanel();
}
```

##### `styles.less`

このままでも動作しますが、Inputの配置が綺麗ではありません。
見た目が気になる場合は、`styles.less`にスタイルを設定してください。
適当に以下のように設定してみました。

```css:less
div.initScript.view {
  width: 95%;
  input.input-search {
    display: inline-flex;
    align-items: center;
    align-content: left;
    width: 45%;
    &.search {
      color: lighten(@text-color, 50%);
      background-color: darken(@base-background-color, 50%);
    }
    &.replace {
      color: darken(@text-color, 50%);
      background-color: lighten(@base-background-color, 50%);
    }
  }
}
```

筆者環境では以下のように表示されます。

![Atom_Init_Script.PNG](https://qiita-image-store.s3.amazonaws.com/0/194349/3159242d-ae2e-bed3-6bd2-282390ebce4c.png)

とても安っぽいですね

### コマンドの登録

これで必要な関数が全て準備出来ました。
関数を定義しただけでは、任意のタイミングで使えないのでAtomのコマンドとして登録する必要があります。
詳しくは、マニュアルを見てもらうとして以下のように登録しました。
toggleInitPanelだけは、使い勝手を考えて`atom-workspace`に登録しています。

#### `init.js`

```javascript:javascript
atom.commands.add("atom-workspace", {
  "toggleInitPanel": toggleInitPanel,
});

atom.commands.add("atom-text-editor", {
  "replacedCopy": replacedCopy,
  "replacedDuplicate": replacedDuplicate,
});

atom.menu.add([{
  "label": "InitScript",
  "submenu": [
    { "label": "toggleInitPanel", "command": "toggleInitPanel" },
    { "label": "replacedCopy", "command": "replacedCopy" },
    { "label": "replacedDuplicate", "command": "replacedDuplicate" },
  ],
}]);
atom.menu.update();
```

### キーバインド

コマンドとして登録しただけでは、メニューからの操作が必要になります。
作業効率を上げるためには、ショートカットキーが必須です。
こちらも詳しくはマニュアル等を参照してください。
そこで`keymap.cson`に以下のように登録しました。
toggleInitPanelは、そう頻繁に使うものではないと思い登録していません。

#### `keymap.cson`

```coffeescript:cson
"atom-text-editor":
  "ctrl-c": "replacedCopy"
  "ctrl-chift-d": "replacedDuplicate"
```

### 結果

これで通常のcopyとduplicateと挙動を変えないで変換機能付きのcopyとduplicateを実装することが出来ました。
また、"検索文字列"と"置換文字列"の入力用インターフェースを備えておりコピー時に変換する文字列を任意に変更することができます。
しかし、Viewを定義した事で肥大してしまいました。
パッケージとして作った方が良かったかもしれません。

# 最後に

Init Scriptに変数を渡す方法を模索していましたが、とりあえずViewを使ってそれ経由で渡す方法を実装出来ました。
決して手軽ではありませんが、一度作ってしまえばいくらでも流用できるので使い勝手は良いのではないでしょうか。
皆さんもよろしければ作ってみてください。

## 完全な余談

世の中の流れはVisual Stodio Codeになっている気がします。
軽く触ってみたところ使い勝手が良かったです。
でも踏ん切りがつかないうちに取り残されてしまいました。
悲しいことです。(´・ω・\`)
こういうインターフェースを自由に作れるならば乗り換えるのもやぶさかではないのですが調べていないという

---

## ソース全文

コピー用にソース全文を再度記載します。

### `init.js`

```javascript:javascript
"use babel"

class InitScriptView {
  constructor() {
    this.search = "";
    this.replace = "";

    this.element = document.createElement("div");
    this.element.classList.add("initScript", "native-key-bindings", "view");

    this.input1 = InitScriptView.makeInput({
      "cl": "search",
      "placeholder": "Search",
      "defaultValue": this.search.toString(),
      "func": this.setSearch(),
    });
    this.input2 = InitScriptView.makeInput({
      "cl": "replace",
      "placeholder": "Rearch",
      "defaultValue": this.replace,
      "func": this.setReplace(),
    });
    this.button = InitScriptView.makeButton({
      "cl": "close",
      "icon": "icon-x",
      "func": this.hidePanel(),
    });

    this.element.appendChild(this.button);
    this.element.appendChild(this.input1);
    this.element.appendChild(this.input2);

    this.panel = atom.workspace.addTopPanel({
      "item": this.getElement(),
      "visible": false,
      "className": "initScriptPanel",
    });
  }

  destroy() {
    this.element.remove();
    this.input1.removeEventListener("input", this.input1.func, false);
    this.input2.removeEventListener("input", this.input2.func, false);
    this.button.removeEventListener("click", this.button.func, false);
  }

  getElement() {
    return this.element;
  }

  setSearch() {
    return (evt) => {
      try {
        this.search = new RegExp(evt.target.value, "g");
      } catch (_e) {
        this.search = evt.target.value;
      }
    };
  }

  setReplace() {
    return (evt) => {
      this.replace = evt.target.value;
    };
  }

  togglePanel() {
    if (this.panel.isVisible()) {
      this.panel.hide();
    } else {
      this.panel.show();
    }
  }

  hidePanel() {
    return () => {
      this.panel.hide();
    };
  }

  static makeInput({ cl, placeholder, defaultValue, func }) {
    const input = document.createElement("input");
    input.classList.add("initScript", "input-search", cl);
    input.type = "search";
    input.placeholder = placeholder;
    input.defaultValue = defaultValue;
    input.addEventListener("input", func, false);
    input.func = func;
    return input;
  }

  static makeButton({ cl, icon, func }) {
    const button = document.createElement("button");
    button.classList.add("initScript", "btn", "inline-block", "icon", icon, cl);
    button.func = func;
    button.textContent = "close";
    button.addEventListener("click", func, false);
    return button;
  }
}

const initScriptView = new InitScriptView();

function toggleInitPanel() {
  initScriptView.togglePanel();
}

function getSearchPatternFromFaR() {
  const farName = "find-and-replace";
  let far = null;

  if (!far) {
    far = atom.packages.getActivePackage(farName);
    if (!far) {
      far = atom.packages.enablePackage(farName);
      far.mainModule.activate();
    }
  }

  initScriptView.search = far.mainModule.findOptions.findPattern;
  initScriptView.replace = far.mainModule.findOptions.replacePattern;
}

function replacedCopy() {
  const editor = atom.workspace.getActiveTextEditor();
  if (editor === null) {
    return;
  }

  editor.copySelectedText();
  let { text, metadata } = atom.clipboard.readWithMetadata();
  text = text.replace(initScriptView.search, initScriptView.replace);
  if (Object.prototype.hasOwnProperty.call(metadata, "selections")) {
    for (const selection of metadata.selections) {
      selection.text = selection.text.replace(initScriptView.search, initScriptView.replace);
    }
  }
  atom.clipboard.write(text, metadata);
}

function replacedDuplicate() {
  const editor = atom.workspace.getActiveTextEditor();
  if (editor === null) {
    return;
  }

  const cursors = editor.getCursors();
  for (const cursor of cursors) {
    const range = cursor.getCurrentLineBufferRange();
    const text = editor.lineTextForBufferRow(range.start.row);
    if (text !== "") {
      const replaced = text.replace(initScriptView.search, initScriptView.replace);
      editor.setTextInBufferRange(range, `${text}\n${replaced}`);
    }
    cursor.setBufferPosition([range.start.row + 1, range.start.column]);
  }
}

atom.commands.add("atom-workspace", {
  "toggleInitPanel": toggleInitPanel,
});

atom.commands.add("atom-text-editor", {
  "replacedCopy": replacedCopy,
  "replacedDuplicate": replacedDuplicate,
});

atom.menu.add([{
  "label": "InitScript",
  "submenu": [
    { "label": "toggleInitPanel", "command": "toggleInitPanel" },
    { "label": "replacedCopy", "command": "replacedCopy" },
    { "label": "replacedDuplicate", "command": "replacedDuplicate" },
  ],
}]);
atom.menu.update();
```

### `keymap.cson`

```coffeescript:cson
"atom-text-editor":
  "ctrl-c": "replacedCopy"
  "ctrl-chift-d": "replacedDuplicate"
```

### `styles.less`

```css:less
div.initScript.view {
  width: 95%;
  input.input-search {
    display: inline-flex;
    align-items: center;
    align-content: left;
    width: 45%;
    &.search {
      color: lighten(@text-color, 50%);
      background-color: darken(@base-background-color, 50%);
    }
    &.replace {
      color: darken(@text-color, 50%);
      background-color: lighten(@base-background-color, 50%);
    }
  }
}
```

[url1]:https://qiita.com/BlueSilverCat/items/04d384816fd60ad91308