Atomプラグイン作成入門 ハンズオン形式でプレビュー型プラグインを作ってみよう!

  • 85
    Like
  • 0
    Comment
More than 1 year has passed since last update.

前置き

タイトルの「プレビュー型プラグイン」とは、テキストエディタに書かれた文字列を変換して表示するプラグインのことです。
例えば、Atomにビルトインされているmarkdown-previewをはじめとして、asciidoc-previewatom-html-previewなどがあります。

この投稿では、ハンズオン形式で簡単なプレビュー型のAtomプラグインを一から作っていく過程を解説していきます。
夏休みの課題としてAtomプラグインを作ってみましょう!

作成するプラグイン

プレビュー元のテキストエディタから数値を抜き出して合計するプレビュープラグインを作ってみます。
video.gif

実装する機能

以下の機能を実装します
1. メニューバーのpackageや、コマンドパレットからプレビューを起動
2. プレビュー対象のテキストエディタやファイルから数値を抜き出して合計をプレビュー画面に表示
3. プレビュー対象のテキストエディタを編集すると合計を再計算してプレビュー画面に表示
4. プレビュー画面を開いた状態でAtomを開き直すと合計を再計算してプレビューに表示(シリアライズ対応)

ソースコード

完成したプラグインのソースコードです。
https://github.com/geekduck/atom-handson-sum-preview

Atomに登録していないので、動かしたい場合は以下のようにgitクローンしたあとにapmコマンドでリンクしてください。

git clone https://github.com/geekduck/atom-handson-sum-preview
cd atom-handson-sum-preview
apm install
apm link

ハンズオン

早速作っていきましょう!

Generator Pluginでプラグインのひな形を作成

まず最初に、AtomにビルトインされているGeneratorPluginを使ってプラグインのひな形を作成します。
Atomを起動してcmd-shift-p(OSX) または ctrl-shift-p(Linux/Windows)でコマンドパレットを開きます。
「generator」でフィルタすると、プラグイン用とテーマ用のgeneratorが見つかります。
スクリーンショット 2015-07-19 16.20.55.png

今回はプラグインを作成するので「Generate Package」を実行します。
プロジェクトのパスを入力するようにいわれるので、プロジェクト名は「atom-handson-sum-preview」として各自適当なディレクトリを指定してください。
スクリーンショット 2015-07-19 16.24.20.png

パスを入力してエンターキーを押下するとGeneratorによって作られたatom-handson-sum-previewのディレクトリが開かれます。
初期状態ではダイアログボックスを表示する機能が実装されているので確認してみましょう。
コマンドパレットを表示して「handson」でフィルタすると「Atom Handson Sum Preview:Toggle」というコマンドが見つかるはずです。
スクリーンショット 2015-07-19 16.38.45.png
表示されない場合はAtomをリロードしてみてください。

実行してみるとダイアログボックスが表示されます。
スクリーンショット 2015-07-19 16.40.03.png

Generatorによって作られたコードを見ていきます。
スクリーンショット 2015-07-19 16.42.19.png

atom-handson-sum-preview
  keymaps/ ・・・ ショートカットキーを登録(デフォルトではctrl+alt+oが設定されている)
  lib/ ・・・ メインコード
  menus/ ・・・ メニューバーや、コンテキストメニュー(右クリックメニュー)にコマンドを登録
  spec/ ・・・ テストコード
  styles/ ・・・ CSSに関するコード
  CHANGELOG.md
  LICENSE.md
  package.json ・・・ Node.jsのpackage.jsonと同様に、プラグインの説明やバージョン情報、依存するnpmパッケージを記述
  README.md

特に重要なのがpackage.jsonファイルです。

package.json
{
  "name": "atom-handson-sum-preview",
  "main": "./lib/atom-handson-sum-preview",
  "version": "0.0.0",
  "description": "A short description of your package",
  "keywords": [
  ],
  "activationCommands": {
    "atom-workspace": "atom-handson-sum-preview:toggle"
  },
  "repository": "https://github.com/atom/atom-handson-sum-preview",
  "license": "MIT",
  "engines": {
    "atom": ">=1.0.0 <2.0.0"
  },
  "dependencies": {
  }
}

"main": "./lib/atom-handson-sum-preview"はこのプラグインの起点となるファイルを指定します。

ここで指定されたファイルは以下の3つのメソッドを実装する必要があります。

  • activate 必須メソッド パッケージがアクティベートされたときに実行されます

  • serialize オプションメソッド Atomを終了して次に起動した時に以前の内容を復元したい場合は実装します

  • deactivate オプションメソッド Atom終了時に外部リソースの開放を行う場合は実装します

プレビュー画面を開く

ダイアログボックスではなく、タブとしてビューを表示するように修正していきます。

Atomで新しいタブを開く場合は、atom.workspace.openメソッドを実行します。
第一引数に開きたいURIを指定しますが、何も指定しなかった場合はデフォルト動作として新しいテキストエディタタブが開きます。
lib/atom-handson-sum-preview.coffeeのtoggleメソッドを修正して新しいテキストエディタを開くようにしてみましょう。

lib/atom-handson-sum-preview.coffee
  # 〜省略〜
  toggle: ->
    atom.workspace.open()

Atomをリロードしコマンドを実行するとダイアログボックスの代わりに新しいテキストエディタが開かれます。
video2.gif

次にデフォルトのテキストエディタの代わりに、このプラグイン独自のビューを開くように実装していきます。
独自のビューを開くようにするためには、このプラグイン独自のURIを決めて、そのURIを開くためのカスタムオープナーを登録する必要があります。

このプラグインのURIを以下のように決めます。
atom-handson-sum-preview://editor/テキストエディタのID
テキストエディタはそれぞれIDを持っているので、そのIDをURIに含めることでプレビュー元のテキストエディタを特定できるようにします。

atom.workspace.getActiveTextEditorメソッドを使用すると、アクティブなテキストエディタを取得できます。
テキストエディタのIDはidプロパティから取得できます。

lib/atom-handson-sum-preview.coffee
  # 〜省略〜
  toggle: ->
    editor = atom.workspace.getActiveTextEditor()
    return unless editor?

    atom.workspace.open(@uriForEditor editor)

  # 新規メソッド
  uriForEditor: (editor) ->
    # プラグインのURI 例:atom-handson-sum-preview://editor/1
    "atom-handson-sum-preview://editor/#{editor.id}"

続いて、独自URIに対応するカスタムオープナーを登録します。
カスタムオープナーとして関数を登録し、ビューを開くタイミングでAtomからURIを渡されて実行されます。
カスタムオープナー関数でURIを解析し、プロトコル部分が「atom-handson-sum-preview:」と一致したらビューを作成して戻り値に指定します。
ここでは、開くビューはひとまずAtomのテキストエディタを開くように実装します。
以下に示すコードはGeneratorに生成されたダイアログボックスを開くコードは削除してあります。

lib/atom-handson-sum-preview.coffee
  activate: (state) ->
    # Events subscribed to in atom's system can be easily cleaned up with a CompositeDisposable
    @subscriptions = new CompositeDisposable

    # Register command that toggles this view
    @subscriptions.add atom.commands.add 'atom-workspace', 'atom-handson-sum-preview:toggle': => @toggle()

    # カスタムオープナーを定義
    atom.workspace.addOpener (uriToOpen) ->
      try
        url = require 'url'
        {protocol, host, pathname} = url.parse(uriToOpen)
      catch error
        return
      return unless protocol is 'atom-handson-sum-preview:' # プロトコルがこのプラグインのプロトコルならビューを生成する

      try
        pathname = decodeURI(pathname) if pathname
      catch error
        return

      {TextEditor} = require 'atom'
      view = new TextEditor
      view.setText "editorId: #{pathname.substring(1)}"
      view

Atomをリロードしてコマンドを実行すると、「editorId: 12」などの内容がセットされた状態でテキストエディタが開きます。
video3.gif

ここまでで独自URIを定義し、そのURIに反応して新しいビューを開く処理を実装しました。
ここまでを整理したソースコード

プレビュー画面を表示する

前節ではAtomのTextEditorビューを使いましたが、ここではプラグイン独自のビューを実装していきます。

Atomのビューを実装するためのspace-penatom-space-pen-views
というライブラリがあるのでこれらを利用します。
2つの違いは、space-penが基本となる「View」クラスのみが定義されているのに対し、atom-space-pen-views
はサブクラスの「TextEditorView」、「SelectListView」、「ScrollView」の3つが定義されています。

プレビュー画面に複数行表示するような場合はScrollViewを利用するのがよいですが、今回は1行しか表示しないプレビュー画面なのでspace-penの「View」クラスを継承してプレビュー画面を実装します。
Node.jsと同様に、package.jsonのdependenciesに依存するライブラリを指定します。

package.json
{
  "name": "atom-handson-sum-preview",
  "main": "./lib/atom-handson-sum-preview",
  "version": "0.0.0",
  "description": "A short description of your package",
  "keywords": [
  ],
  "activationCommands": {
    "atom-workspace": "atom-handson-sum-preview:toggle"
  },
  "repository": "https://github.com/geekduck/atom-handson-sum-preview",
  "license": "MIT",
  "engines": {
    "atom": ">=1.0.0 <2.0.0"
  },
  "dependencies": {
    "space-pen": "^5.1.1"
  }
}

この状態でプラグインのディレクトリでapm installコマンドを実行すると依存ライブラリがダウンロードされます。

space-penのViewクラスを継承したプレビュークラスを実装します。

lib/atom-handson-sum-preview-view.coffee
{View} = require 'space-pen'

module.exports =
class AtomHandsonSumPreviewView extends View
  @content: ->
    @div class: 'atom-handson-sum-preview', =>
      @div outlet: "container"

  constructor: ({@editorId}) ->
    super
    @container.html "Hello World #{@editorId}"

  getTitle: ->
    "#{@editorId} Sum Preview"

プレビュー画面のテンプレートはcontentというクラスメソッドとして実装します。
記述方法はHTMLの各要素名がそのままメソッド名になったspace-penのDSL形式で定義します。
動的に変更したいような要素はoutlet属性を付与しておくと、あとでその名前でインスタンスメソッド上から参照できます。
上ではcontainerという名前のdivをconstructor上で書き換えています。
また、Atomのタブとして表示したいビューはgetTitleメソッドを実装しておく必要があるので実装しておきましょう。

実装したAtomHandsonSumPreviewViewを使用するように変更します。

lib/atom-handson-sum-preview.coffee
  activate: (state) ->
    # 〜省略〜
    atom.workspace.addOpener (uriToOpen) ->
      try
        url = require 'url'
        {protocol, host, pathname} = url.parse(uriToOpen)
      catch error
        return
      return unless protocol is 'atom-handson-sum-preview:' # プロトコルがこのプラグインのプロトコルならビューを生成する

      try
        pathname = decodeURI(pathname) if pathname
      catch error
        return

      # プラグイン独自のビュークラス
      new AtomHandsonSumPreviewView editorId: pathname.substring(1)

プラグイン実行してみると、ちょっと見にくいですが実装したビューが使われています。
video4.gif

ここまでで独自のビューを定義し、そのビューを開く処理を実装しました。
ここまでを整理したソースコード

CSS、デザインを修正する

ビューに表示されている文字が読みにくのでスタイルを修正します。
Atomのプラグインやテーマのスタイルは、スタイルガイドに従って書きましょう。
lessの変数として使用可能なものはここにまとまっています。
コマンドパレットから「styleguide」を実行するとスタイルガイドが参照できるので目を通しておくとよいでしょう。
Atomが定義しているless変数を再利用したりスタイルガイドに従うことで、例えばユーザがAtomのテーマを変更しても見やすいスタイルを維持しやすくなります。

styles/atom-handson-sum-preview.less
@import "ui-variables";
.atom-handson-sum-preview {
  color: @text-color-highlight;
  background-color: @app-background-color;
}

スクリーンショット 2015-07-19 19.31.40.png

見やすくなりました。

別のテーマに変更してみるとこんな感じです。
スクリーンショット 2015-07-19 19.36.37.png

less変数text-color-highlightを使わずに、color: whiteなどと指定してしまうとこのテーマでは見難くなってしまいます。

ここまででAtomのスタイルガイドに沿ったstyleを実装しました。
ここまでを整理したソースコード

プレビュー画面がすでに開かれている場合は閉じる

markdown-previewを参考にして、以下のような動作を実装していきます。

  • テキストエディタがアクティブな状態でtoggleされた場合、そのテキストエディタに対応するプレビュー画面があれば閉じ、なければ新しく画面を分割して右側に開く
  • プレビュー画面がアクティブな状態でtoggleされた場合、そのプレビュー画面を閉じる
  • プレビュー画面を開いたあとでそのプレビュー画面がアクティブになると使いにくいので元のテキストエディタをアクティブに戻す
lib/atom-handson-sum-preview.coffee
  # 〜省略〜

  toggle: ->
    # プレビュー画面がアクティブ状態でtoggleされたら閉じる
    if atom.workspace.getActivePaneItem() instanceof AtomHandsonSumPreviewView
      atom.workspace.destroyActivePaneItem()
      return

    editor = atom.workspace.getActiveTextEditor()
    return unless editor?
    # editorに対応するプレビュー画面がすでに開かれていれば閉じ、そうでなければ開く
    @addPreviewForEditor(editor) unless @removePreviewForEditor(editor)

  uriForEditor: (editor) ->
    # プラグインのURI 例:atom-handson-sum-preview://editor/1
    "atom-handson-sum-preview://editor/#{editor.id}"

  # 新規メソッド 指定したテキストエディタに対応するプレビューが開かれている場合は閉じる
  removePreviewForEditor: (editor) ->
    uri = @uriForEditor(editor)
    # 開こうとしているURIですでに開かれてるビューがあれば閉じる
    previewPane = atom.workspace.paneForURI(uri)
    if previewPane?
      previewPane.destroyItem(previewPane.itemForURI(uri))
      true
    else
      false

  # 新規メソッド 指定したテキストエディタに対応するプレビューを開く
  addPreviewForEditor: (editor) ->
    # URIを生成してopen
    uri = @uriForEditor(editor)
    previousActivePane = atom.workspace.getActivePane() # プレビュー元となるテキストエディタ
    options =
      split: 'right' # 画面を分割して右側に表示する
      searchAllPanes: true
    atom.workspace.open(uri, options).done (atomHandsonSumPreviewView) ->
      if atomHandsonSumPreviewView instanceof AtomHandsonSumPreviewView
        previousActivePane.activate() # プレビュー画面をアクティブにすると使いにくいので元のテキストエディタをアクティブにする

atom.workspace.paneForURIメソッドを使用すると、指定したURIで開かれているビューを取得できます。
このメソッドに対応するために、AtomHandsonSumPreviewViewクラスにgetURIメソッドを実装します。

lib/atom-handson-sum-preview-view.coffee
{View} = require 'space-pen'

module.exports =
  class AtomHandsonSumPreviewView extends View
    # 〜省略〜

    # 新規メソッド
    getURI: ->
      "atom-handson-sum-preview://editor/#{@editorId}" if @editorId?

プラグインを実行してみるとtoggleされています。
video6.gif

ここまででプレビュー画面をtoggleする処理を実装しました。
ここまでを整理したソースコード

プレビュー対象のテキストエディタの内容を取得・変換し、プレビュー画面に描画する

テキストエディタから数値を探して合計し、プレビュー画面に表示する機能を実装します。

  • エディタIDからテキストエディタを取得する
  • テキストエディタから文字列を取得し、文字列を空白文字で分割・数値化して合計する
  • プレビュー画面に描画する

全体のコードです。

lib/atom-handson-sum-preview-view.coffee
{View} = require 'space-pen'

module.exports =
  class AtomHandsonSumPreviewView extends View
    @content: ->
      @div class: 'atom-handson-sum-preview', =>
        @div outlet: "container"

    constructor: ({@editorId}) ->
      super
      @total = 0

      if @editorId?
        @resolveEditor(@editorId)

    # 新規メソッド エディターIDからエディタを取得し、取得できたらビューを描画する
    resolveEditor: (editorId) ->
      resolve = =>
        @editor = @getEditorById(editorId)

        if @editor?
          @renderSum()

      if atom.workspace?
        resolve()

    # 新規メソッド エディタIDからエディタを取得する
    getEditorById: (editorId) ->
      for editor in atom.workspace.getTextEditors()
        return editor if editor.id?.toString() is editorId.toString()
      null

    # 新規メソッド 合計を計算し、ビューに描画する
    renderSum: ->
      text = @editor.getText() if @editor?
      @total = if text then @sumUp(text) else 0
      @renderContainer()

    # 新規メソッド 文字列を空白文字で分割し、数値に変換できたら合計する
    sumUp: (text) ->
    # 単純に空白文字で分割して合計する
      words = text.split(/\s+/)
      total = 0
      words.forEach (word) =>
        num = Number word
        total += num unless isNaN num
      total

    # 新規メソッド totalプロパティをビューに描画する
    renderContainer: ->
      @container.html "合計:#{@total}"

    getTitle: ->
      if @editor?
        "#{@editor.getTitle()} Sum Preview" # 元のタイトルを表示
      else
        "Sum Preview"

    getURI: ->
      "atom-handson-sum-preview://editor/#{@editorId}" if @editorId?

エディタIDからテキストエディタを取得するには、atom.workspace.getTextEditorsメソッドで全てのテキストエディタを取得し、IDで比較します。

lib/atom-handson-sum-preview-view.coffee
    getEditorById: (editorId) ->
      for editor in atom.workspace.getTextEditors()
        return editor if editor.id?.toString() is editorId.toString()
      null

テキストエディタから文字列を取得するには、getTextメソッドを使います。
文字列の変換方法は簡単に正規表現で空白文字で分割し、Numberクラスで変換します。
NaNじゃないオブジェクトであれば合計していきます。
合計値はtotalプロパティに格納しておき、renderContainerメソッドでビューに描画します。

lib/atom-handson-sum-preview-view.coffee
    # 新規メソッド 合計を計算し、ビューに描画する
    renderSum: ->
      text = @editor.getText() if @editor?
      @total = if text then @sumUp(text) else 0
      @renderContainer()

    # 新規メソッド 文字列を空白文字で分割し、数値に変換できたら合計する
    sumUp: (text) ->
    # 単純に空白文字で分割して合計する
      words = text.split(/\s+/)
      total = 0
      words.forEach (word) =>
        num = Number word
        total += num unless isNaN num
      total

    # 新規メソッド totalプロパティをビューに描画する
    renderContainer: ->
      @container.html "合計:#{@total}"

適当なファイルを作成し、数値を入力したあとでコマンドを実行してみると合計値が表示されます。
しかし、プレビュー画面を表示したあとで元のテキストエディタを編集しても合計値は変わりません。
そのためにはテキストエディタの変更イベントをsubscribeする必要があります。
video7.gif

ここまででテキストエディタの文字列を変換し、プレビュー画面に表示する処理を実装しました。
ここまでを整理したソースコード

プレビュー対象のテキストエディタの変更イベントに反応する

テキストエディタの変更はonDidChangeイベントか、onDidStopChangingイベントをsubscribeすれば検知できます。
それぞれの違いはイベント名のとおり、onDidChangeイベントは変更中に発火するのに対し、onDidStopChangingは変更が終了したときに発火します。
今回は特にリアルタイムで合計を計算する必要はないので、onDidStopChangingイベントを使います。

lib/atom-handson-sum-preview-view.coffee
{View} = require 'space-pen'
{CompositeDisposable} = require 'atom'

module.exports =
  class AtomHandsonSumPreviewView extends View
    @content: ->
      @div class: 'atom-handson-sum-preview', =>
        @div outlet: "container"

    constructor: ({@editorId}) ->
      super
      # イベントのunsubscribeを簡単にするオブジェクト
      @disposables = new CompositeDisposable
      @total = 0

      if @editorId?
        @resolveEditor(@editorId)

    # 新規メソッド イベント関連のオブジェクトを破棄
    destroy: ->
      @disposables.dispose() # subscribeしたイベントを全てunsubscribe

    # エディターIDからエディタを取得し、取得できたらビューを描画する
    resolveEditor: (editorId) ->
      resolve = =>
        @editor = @getEditorById(editorId)

        if @editor?
          @handleEvents() # イベント関連初期化
          @renderSum()

      if atom.workspace?
        resolve() # atomが初期化済みならすぐ開く
      else
        @disposables.add atom.packages.onDidActivateInitialPackages(resolve) # atomが初期化されていないなら初期化後に開く

    # 新規メソッド イベント関連の初期化
    handleEvents: ->
      if @editor?
        # 元のファイルが更新されたら合計を再計算
        @disposables.add @editor.onDidStopChanging => @renderSum()

handleEventsメソッドを実装し、onDidStopChangingが発火した場合はrenderSumメソッドを呼び出してビューを再描画します。

onDidStopChangingイベントをsubscribeすると、disposeメソッドを持つDisposableオブジェクトが返されます。
このビューを閉じる場合はdisposeメソッドを実行してリソースの開放を行う必要があります。
今回はイベントのsubscribeが1つですが、増えると後始末が煩雑になってしまいます。
対処として、CompositeDisposableオブジェクトを作り、Disposableオブジェクトをaddしていきます。
ビューを破棄する際にCompositeDisposableオブジェクトのdisposeメソッドを呼ぶと、addした全てのDisposableオブジェクトのdisposeメソッドを呼んでくれるため、イベントのunsubscribeが簡単に行えます。
参考:A New Event Subscription API

video8.gif

ここまででテキストエディタが編集されたときに、合計を再計算してプレビュー画面に表示する処理を実装しました。

シリアライズを実装する

最後に、プレビュー画面を開いた状態でAtomをリロードしたときに、プレビュー画面を再び表示しなおすシリアライズを実装します。
参考:Serialization in Atom

シリアライズしたい場合はatom.deserializers.addメソッドに対象のビュークラスを登録します。
対象のビュークラスのクラスメソッドとしてdeserializeメソッドと、
インスタンスメソッドとしてserializeメソッドを定義します。

注意点として、package.jsonにactivationCommandsを指定している場合は、プラグインの初期化タイミングがAtomの初期化時ではなく、プラグインのコマンドの初回実行時になってしまうためデシリアライズがうまく実行されません。
シリアライズしたい場合はpackage.jsonのactivationCommandsを削除しましょう。

lib/atom-handson-sum-preview-view.coffee
{View} = require 'space-pen'
{CompositeDisposable} = require 'atom'

module.exports =
  class AtomHandsonSumPreviewView extends View
    atom.deserializers.add(this)

    @deserialize: (state) ->
      new AtomHandsonSumPreviewView(state)

    serialize: ->
      deserializer: 'AtomHandsonSumPreviewView'
      editorId: @editorId

    # 〜省略〜

プレビューを表示した状態でウィンドウをリロードすると、プレビューが残った状態でウィンドウが開かれます。
video9.gif

ここまででウィンドウをリロードしたときにプレビュー画面を再び表示しなおすシリアライズ処理を実装しました。
ここまでを整理したソースコード

まとめ

長くなってしまいましたが、簡単なプレビュープラグインを作る過程をハンズオン形式で解説してきました。
Atomプラグインを作る際に少しでも手助けになれば幸いです。

さらに複雑なプラグインを作りたい場合はmarkdown-previewなどAtom本家のプラグインや、人気の高いプラグインのソースコードを参考にするのが良いと思います。

気になる点、突っ込みどころがあれば遠慮なくお願いします!