Vue.jsで汎用ウインドウコンポーネントをつくってみた

GMOペパボ新卒エンジニアの@gatchanです。
@sunecosuriさんの「[Nuxt.jsでlocalStorageを用いる際のTipsとハマったこと](https://qiita.com/sunecosuri/items/3544fb101cabd310acc3)」につづき、Vue.jsな内容です。

最近作っている趣味プログラミングで必要だったので、Vue.jsで汎用的なウインドウコンポーネントをつくってみました。
（ここでいうウインドウとは、各種OSのGUIに採用されているウインドウシステムのようなものです）
あくまでウインドウ画面実装の一例ですが、参考になればうれしいです。

（こういうものをつくりました）
![wnd2.gif](https://qiita-image-store.s3.amazonaws.com/0/215122/79b050e1-5feb-9363-d005-2d11b97439c6.gif)


詳細な実装は、githubに公開したソースコードを参照していただければと思います。
https://github.com/piyoppi/vue_window_sample

# ウインドウの要件
私が求めるウインドウの要件は、以下の通りでした。

- ドラッグアンドドロップで移動できる
- テキストや画像、ボタンなど、様々な要素を格納できる
- 複数のモーダルウインドウ表示に対応している
    - クリックでzIndexが入れ替わるなど

# コンポーネントの使い方
まずは、今回つくったコンポーネントについて、ざっくりと説明します。
ウインドウの内部に表示すべきものは、必要に応じてウインドウコンポーネントが親コンポーネントに対して要求する形としてみました。このようにすることで、あらゆる要素をウインドウ内部に組み込むことができます。

```html:main.vue
<template>
    <wnd-component caption="Window1"
                   @require-inner-item="setInnerElement"
                   :visible.sync="isVisibleWindow"></wnd-component>
    <div ref="windowInner" class="window-inner">
        Hello world!!
    </div>
</template>

<script>
    import wndComponent from "./wnd.vue"
    import store from "./store.js"

    export default {
        components: {
            wndComponent
        },
        data: function () {
            return {
                isVisibleWindow: true
            };
        }
        store,
        methods: {
            setInnerElement: function(callback){
                //ここでウインドウ内部に入れる要素を渡してあげる
                callback(this.$refs.windowInner);
            }
        }
    }
<script>

<style scoped>
.window-inner {
    width: 200px;
    height: 100px;
    color: white;
}
</style>
```

`require-inner-item` で呼び出されるメソッドの引数にコールバック関数が与えられるので、組み込みたい要素を渡してあげます。上記のソースコードの場合は、`Hello world!!` を含む `div` 要素がウインドウの中に格納され、このように表示されます。

![wnd1.png](https://qiita-image-store.s3.amazonaws.com/0/215122/d593ca9a-3825-7118-1c19-a64f25e77a70.png)

表示されたウインドウのキャプション部分（上図`Window1`の部分）をドラッグアンドドロップすると、ウインドウを動かせます。また、上図の赤い部分をドラッグアンドドロップすると、ウインドウサイズを変更できます。

# コンポーネントの実装について
コンポーネントの実装について、ポイントとなる部分を見ていきます。
（以下より、ウインドウコンポーネントは`wnd.vue`、ウインドウコンポーネントを持つ親コンポーネントは`main.vue`となっています）

## ウインドウの表示・非表示
ウインドウの表示・非表示の状態は親コンポーネントが持つこととしました。

（テンプレート部分）

```html:main.vue
<template>
    <wnd-component caption="Window1"
                   @require-inner-item="setInnerElement"
                   :visible.sync="isVisibleWindow">
   ...(省略)...
</template>
```

（ロジック部分）

```js:main.vue
<script>
    export default {
        data: function () {
            return {
                isVisibleWindow: true
            };
        }
        //...(省略)...
```


テンプレートに`:visible.sync`とありますが、`sync`修飾子を使うことで、propsで渡した値を子コンポーネントから操作可能にします。（厳密には「操作可能」にしているのではなく、子コンポーネントからのemitを受け取って値を操作するシンタックスシュガーです。くわしくは[公式ドキュメント](https://jp.vuejs.org/v2/guide/components.html#sync-%E4%BF%AE%E9%A3%BE%E5%AD%90)をご覧ください。）

ウインドウコンポーネントがpropsで受け取った `visible` を用いて、`v-show`に適用してあげることで表示・非表示を切り替えます。

```html:wnd.vue
<template>
    <transition v-on:enter="enter">
        <div class="wnd_outer"
             v-show="visible"
```

以下のように記述することで、ウインドウコンポーネント内でpropsの`visible`を操作でき、ウインドウコンポーネント内の×ボタンでウインドウを閉じることができるようになります。

```js:wnd.vue
this.$emit('update:visible', false)
```

（参考）[コンポーネント - .sync修飾子](https://jp.vuejs.org/v2/guide/components.html#sync-%E4%BF%AE%E9%A3%BE%E5%AD%90)


## 要素の埋め込み
ウインドウコンポーネントがマウントされたときに`require-inner-item`イベントを発生させることで、親コンポーネントに対して内包すべき要素を要求します。要素が取得できたら、初期化関数`setInitialState`を呼び出し、ウインドウ位置の調整やサイズの設定を行います。

（テンプレート部分）

```html:wmd.vue
 <transition v-on:enter="enter">
    <div class="wnd_outer"
         v-show="visible"
         v-bind:style="{
            width: this._width,
            height: this._height,
            left: this._x,
            top: this._y,
            zIndex: this.zIndex,
         }"
    >
           ...(省略)...
    </div>
</transition>
```

（ロジック部分）

```js:wnd.vue
export default {
    data: function () {
        return {
            x: null,
            y: null,
            width: this.initialWidth,
            height: this.initialHeight,
            //...(省略)...
        }
    },

    //...(省略)...
    mounted: function(){
        this.$emit('require-inner-item', el => {
            this.$refs.wndInner.appendChild(el);
            //（v-show=falseの時は要素の高さが取れないので初期化しない）
            if( this.visible && this.$el ){
                this.setInitialState();
            }
        });
    },
    props: {
        visible: Boolean,
        initialPosition: {
            type: Array,
            default: null, 
        },
        initialWidth: {
            type: Number,
            default: 0,
        },
        initialHeight: {
            type: Number,
            default: 0,
        },
        // ...（省略）...
    }
    methods: {
        enter: function() {
            this.setInitialState();
        },
        setInitialState: function() {
            //v-ifなどで要素自体が取れない場合は処理を中断
            if( !this.$el || !this.$refs.wndInner ) return;

            //...（一部省略）...

            let innerItemRect = this.$refs.wndInner.getBoundingClientRect();
            this.width = this.initialWidth || innerItemRect.width;
            this.height = this.initialHeight || innerItemRect.height;

            //初期化が済んでいれば処理を終了
            if( (this.x !== null) && (this.y !== null) ) return;
            if( this.initialPosition && this.initialPosition.length === 2 ){
                //initialPositionに値が設定されていれば初期位置とする
                this.x = this.initialPosition[0];
                this.y = this.initialPosition[1];
            } else {
                //初期位置が与えられていなければ画面中央に表示
                this.x = (window.innerWidth / 2) - (this.$el.clientWidth / 2);
                this.y = (window.innerHeight / 2) - (this.$el.clientHeight / 2);
            }
        },
        ...(省略)...
```

初期化関数内では、内包する要素の幅と高さを取得してウインドウサイズを決定したり、props経由で初期位置や初期サイズが設定されている場合はそれらを反映したりします。初期位置が設定されていない場合は、ウインドウサイズとブラウザの表示領域から画面中心位置に表示されるように位置を計算します。

また、ウインドウコンポーネントのマウント時に（`v-show=false`などによって）ウインドウが表示されていない場合は、要素の大きさが取得できないため初期化できません。そこで、もし初期化に失敗した場合のために、テンプレートに`transition`を記述し、`v-on:enetr`で要素が表示されたときを捕捉して初期化する処理を実装しました。


(参考）[Enter/Leave とトランジション一覧](https://jp.vuejs.org/v2/guide/transitions.html)

（`v-if=false` のときは要素が取得できないことや、`v-show=false`のときは要素の大きさなどが取れないことを気にしつつ実装しなければいけないので、ちょっと大変でした。この辺の実装はもうちょっと良くできるといいな。。。）

## ウインドウの移動

![path4535-8.png](https://qiita-image-store.s3.amazonaws.com/0/215122/8c805cb2-eeba-98f6-67d7-d92f90ecb412.png)

ウインドウ上部のキャプション部分をクリックしたタイミングで`mousemove`と`mouseup`イベントを捕捉するようにし、自身のマウスカーソル位置を控えておきます。これらの情報を用いてマウスを移動させるとともにウインドウ位置も移動させます。
ちなみに、マウスカーソルがウインドウからはみ出したときもイベントを捕捉できるよう、`document.addEventListener`を使って画面全体のマウスイベントを取れるようにしています。

`mouseup`イベント時に`EventListener`を`remove`してあげることで、余計なイベントを発生させないようにします。

```js:wnd.vue
export default {
    method: {
        //...(省略)...
        mousedown: function(e) {
            //カーソルの初期位置を控えておく
            this.cursorOffset.x = e.pageX;
            this.cursorOffset.y = e.pageY;
            this.cursorStartPos = {x: this.x, y: this.y};
            //イベントを登録
            document.addEventListener("mousemove", this.mousemove)
            document.addEventListener("mouseup", this.mouseup)
            //ウインドウを最前面に（以降に解説があります）
            this.$store.dispatch('moveWndToTop', {wndID: this.wndID});
        },
        mousemove: function(e) {
            //ウインドウを移動
            this.x = this.cursorStartPos.x + (e.pageX - this.cursorOffset.x);
            this.y = this.cursorStartPos.y + (e.pageY - this.cursorOffset.y);
        },
        mouseup: function(e) {
            this.cursorStartPos = null;
            //イベントの後始末
            document.removeEventListener("mousemove", this.mousemove)
            document.removeEventListener("mouseup", this.mouseup)
        },
//...
```

## ウインドウのzIndex管理
アクティブなウインドウは最前面に表示したいので、ウインドウのzIndexを管理しなければいけません。今回は（私が使ってみたかったという理由もあり）Vuexを触ってみました。

（Vuexについては、[公式ドキュメント](https://vuex.vuejs.org/ja/getting-started.html)を見ればだいたい分かるようになっているので、ほんとにありがたいなと思いました！）

```js:wnd.vue
//...(省略)...
export default {
    created: function(){
        //wndIDを決める
        this.wndID = this.$store.state.wndCount;
        //ウインドウステータスを登録する
        this.$store.dispatch('setWndStatuses', {wndID: this.$store.state.wndCount});
    },
//...(省略)...
```

ウインドウごとに固有のwndIDをStoreから取得し、自身の情報をStoreへ登録します。
（ウインドウ数`wndCount`をStoreが持ち、これをwndIDとすることで一意性を担保しています）

ウインドウを最前面に表示したいタイミングで、以下を呼び出します

```js:wnd.vue
//指定したIDを持つウインドウを最前面にする
this.$store.dispatch('moveWndToTop', {wndID: this.wndID});
```

Mutation`moveWndToTop`には、ウインドウのzIndexを入れ替える処理が記述してあるので、Storeに情報があるすべてのウインドウのzIndexを適宜入れ替えてあげることができます。

Store部分はこんな感じです。

```js:store.js
import Vuex from "vuex"

export default new Vuex.Store({
    state: {
        wndStatuses: {},     //ウインドウの状態
        wndCount: 0,         //総ウインドウ数
        maxWndZIndex: 0,     //zIndex最大値
    },
    mutations: {
        setWndStatuses: (state, payload) => {
            //ウインドウの登録処理
            if( !state.wndStatuses[payload.wndID] ) {
                Vue.set(state.wndStatuses, payload.wndID, {
                    zIndex: state.wndCount,
                });
                state.maxWndZIndex = state.wndCount;
                state.wndCount = state.wndCount+1;
            }
        },
        moveWndToTop: (state, payload) => {
            //ウインドウの入れ替え処理
            let oldZIndex = state.wndStatuses[payload.wndID].zIndex;
            //ウインドウを最前面にする
            state.wndStatuses[payload.wndID].zIndex = state.maxWndZIndex;
            //最前面にしたウインドウより手前に表示されていたウインドウのzIndexをデクリメント
            for(let key in state.wndStatuses){
                if( (state.wndStatuses[key].zIndex > oldZIndex) && (key != payload.wndID) ) {
                    state.wndStatuses[key].zIndex -= 1;
                }
            }
        },
    },
    actions: {
        setWndStatuses(context, payload) {
            context.commit('setWndStatuses', payload);
        },
        moveWndToTop(context, payload) {
            context.commit('moveWndToTop', payload);
        }
    }
});
```

ウインドウコンポーネントの要素のスタイルにzIndexをバインドしているので、StoreのzIndexを書き換えると、ウインドウ要素のzIndexも変化します。Vuex便利だ。。。

```html:main.vue
<template>
    <transition v-on:enter="enter">
        <div class="wnd_outer"
             v-bind:style="{
                zIndex: this.zIndex,
                ...(省略)...
             }"
        >
```

```js:main.vue
export default {
    //...（省略）....
    computed: {
        zIndex: function() {
            return this.$store.state.wndStatuses[this.wndID].zIndex || 0;
        }
    },
```

# おわりに
この手の便利UIは、きっと探せば便利ライブラリなどがありそうですが、自分で実装するといろいろと気づいたりわかったりすることが多いのでよいな、と思います。githubで公開しているソースコードには、ほかにも画面サイズを調整する機能や選択ボタンを表示する機能などがあったりしますので、見てみていただければと思います。

筆者はVue.jsを触って半年もたっておらず、いろいろと至らないコードなどがあるかもしれませんが、コメントなどいただければと思います。

ではでは。