WebアプリにおけるResizableかつDraggableな共通コンポーネントを作成した話
2023-04-06
azblob://2023/04/07/eyecatch/2023-04-07-introduction-yoshimi-suematsu-000_0.jpg

はじめに

4月1日に新卒としてFIXERに入社した末松です。初めてのtech blogで拙いところもあるかもしれないですが、楽しく書いていこうと思います。

わたしについて

1998年4月17日東京生まれ、東京育ちの天パの人です。

東京都立大学院システムデザイン研究科電子情報システム工学域を卒業し、FIXERに新卒入社しました。

趣味は、バスケ、カラオケ、ゲーム、飲酒、料理です。

いままでやったこと

2年半くらい会計事務所に隣接したIT企業の自社開発デジタルフォレンジックアプリケーションのフロントエンドをやっていました。

使用フレームワークはNuxt.jsで、その他有名なJavaScriptライブラリーは大概見たことある気がします(Auth, SVG, D3, PDF,,,etc)。

UIデザイン方面もかなり好きで、いずれ記事にする予定ですが、貴金属比やピタゴラス数など根拠を持ったデザインをすることが好きでした。

Nuxt.jsの大体のことならわかります。

バックエンドのサーバー構築や、DBまわり、Dockerなどをのインフラもある程度なら触れます。

大学院のほうでやっていたのは、「DAG based ブロックチェーンNFTにおける自己組織化MAPを用いたトランザクション分散管理」についての研究を行っていました。

DAG based ブロックチェーンと呼ばれる、従来のBTCやETHのブロックチェーンの問題点を複数解決した、新しいブロックチェーンのNFT機構におけるトランザクションの肥大化問題を解決するために、教師なし学習の自己組織化MAPを用いたトランザクション分散管理ブロックチェーンシステムの提案しました。

Webアプリにおける共通コンポーネント

 NuxtなどのWebフレームワークにおいて、共通コンポーネントの作成という作業はとても重要な位置づけです。

簡単な話、すべてのUIパーツが保守性、拡張性に優れた共通コンポーネントで作成されていれば、UIの変更、増築の際ほとんどバグが出ないことが保障されます。

その反面、保守性、拡張性に優れた共通コンポーネントを作成することは非常に難しいです。

今回は、フロントエンドエンジニアをしていた際に苦労した「共通DraggableAndResizableコンポーネントの作成」について軽く話をし、実際に出来上がったコードを実際に明示して後学の人が苦労しずらいようにしていこうと思います。

なぜ苦労したのか?

今回のBlogのテーマの対象である「ResizableかつDraggableな共通コンポーネント」の作成において、どういう問題点が存在したのか、いくつか説明をしていこうと思います。

  1. 既存ライブラリNuxtで作成したほかの共通コンポーネントが使用できない

    最初にして、最大にして、最強の問題でした。自社開発のWebアプリで、インフラ、バックエンド、フロントエンドすべて自社開発でしたので、今まで作成したUIに適応できないとお話になりません。

    そもそも今回のDraggableかつResizableの話は、既存に出来上がっているUIブロック(Side barやHeaderなど)に適応したいという、設計段階にはなかった仕様を後から追加するようなものであるため、既存のUIシステム+Resizable機能+Draggable機能となるような構築を考えなければいけなかったのです。

    非常に頭を悩ませた問題です。

    僕は解決に3日ほどかかりました。

    皆さんも以下の条件を達成できるようなフロントエンドのコンポーネント構築の問題を考えてみましょう!

    1. 既存のUIブロックが既に存在する
    2. 既存のUIブロックをいじってはならない(昨日や構成面をいじるとバグが生じる可能性があるため)
    3. 既存のUIブロックをResizableにする
    4. 既存のUIブロックをDraggableにする

    思いつきましたか?

    思いついたあなたはかなりフロントエンド力があるので、ぜひフロントエンドの道に進みましょう!!(僕は進みませんが)

    後ほどコードで見せる予定ですが答えを先に置いておきましょう。

    既存のUIブロックをラッピングするような共通Draggableの内部の四辺にHoverにより現れるResizableバーを仕込んで置き、既存のUIブロックのHeightとWidthをフロントエンドフレームワークで管理するようにする

    が正解でした、みんな分かったかな?

  2. 同じことをしようとしてる人がほとんどいない

    エンジニアあるあるなんですが、自分がやろうと思っている構築をググってみると意外と先にやってくれている人がいて、コピペで実装できてしまう、なんてことがほとんどだと思います。

    そういう時、楽ですよね~、早く終わるし評価も上がるし、、、、。

    はい、今回は無理でした。

    そもそも「既存のUIブロックには絶対に手を加えない」というお約束があるため、Resizable部とDraggable部が完璧にUI部分から離れているような実装をしなければなりません。

    そんなめんどくさいことやっているエンジニアなんて、ましてやそれを記事にしているエンジニアなんてほっとんどいませんでした。まーじでいない。(英語だとちらほらいましたけど、調べるのが難しい。当時はほっとんどなかったですが、今調べるとちらほらありました^ ^;)

    そのため、構成とかを既存のライブラリの中身を見て自分で逆算して作らなきゃいけなかったです。

    はっきり言ってめんどくさかったですね。

    皆さん、仕様変更には気を付けましょう(まじで)。

    次の章で実装方法の考え方などを説明していきます。

WebアプリにおけるDraggable

要素がDragableとは、「クリックを押している際に中にマウスカーソルがドラッグ中の表示になり、要素の画面上の位置が変マウスカーソルに従い変化し、クリックを離した場所で何らかのアクションが起こること」と考えられます。

つまり、クリックを行う必要があるので、その要素の中身(画像や、リストなどの要素のこと)が存在する個所ではDragすることはできず、中身以外のどこか特定の場所がDragのフックになる必要があります。

ChromeなどのWebブラウザ例にとると、ヘッダーDragのフックになっていることがわかると思います。

今回の要件の「既存のUIブロック」には必ずヘッダーと呼ばれるものが存在していたため、そこに対してDragのフックを仕込むことにします。

WebアプリにおけるResizable

要素がResizableとは、「要素の辺がドラッグすることができ、要素のサイズ(height, Width,etc,,,,,)を変更できること」と考えられます。

今回は、うえで定義したDraggableの箱にposition: relative;を仕込み、position: absolute;なdivで作成したResize barをhoverして出現するように4つ仕込みます。

実装

<template>
   <div
     ref="draggableContainer"
     id="draggable-container"
     class="draggable-container"
     :style="WrapperStyle"
     @mouseenter="
       mouseOverContainer = true;
       isInResizeBox = true;
     "
     @mouseleave="
       mouseOverContainer = false;
       isInResizeBox = false;
     "
     @mousedown="resizeMouseDown"
     :class="[{ resizing: resizing }]"
   >
     <div id="resizeBox-header" :class="['resizeBox-header']">
       <div
         id="resizeBox-header-left-corner"
         :class="['resizeBox-header-left-corner']"
       ></div>
       <div
         id="resizeBox-header-main"
         :class="['resizeBox-header-main']"
       ></div>
       <div
         id="resizeBox-header-right-corner"
         :class="['resizeBox-header-right-corner']"
       ></div>
     </div>
     <div
       id="resizeBox-main"
       :class="[
         'resizeBox-main'
       ]"
     >
       <div
         id="resizeBox-main-left"
         :class="['resizeBox-main-left']"
       ></div>
       <div
         id="resizeBox-main-center"
         @mouseenter="isInResizeBox = false"
         @mouseleave="isInResizeBox = true"
         :class="[
           {
             'hover-container':
               mouseOverContainer,
           },
           'resizeBox-main-center'
         ]"
       >
         <div
           id="draggable-header"
           class="draggable-header"
           @mousedown="dragMouseDown"
           @mouseenter="mouseOverHeader = true"
           @mouseleave="mouseOverHeader = false"
           :style="HeaderStyle"
           :class="[
             { moving: moving },

             { 'hover-header': mouseOverHeader },
           ]"
         >
           <slot name="header"></slot>
          
         </div>
         <slot name="main"></slot>
         <slot name="footer"></slot>
       </div>
       <div
         id="resizeBox-main-right"
         :class="['resizeBox-main-right']"
       ></div>
     </div>
     <div id="resizeBox-footer" :class="['resizeBox-footer']">
       <div
         id="resizeBox-footer-left-corner"
         :class="['resizeBox-footer-left-corner']"
       ></div>
       <div
         id="resizeBox-footer-main"
         :class="['resizeBox-footer-main']"
       ></div>
       <div
         id="resizeBox-footer-right-corner"
         :class="['resizeBox-footer-right-corner']"
       ></div>
     </div>
   </div>
 </template>
 <script>
 export default {
   props: {
     WrapperStyle: {
       type: Object,
       default() {
         return {};
       },
     },
     ContainerStyle: {
       type: Object,
       default() {
         return {};
       },
     },
     HeaderStyle: {
       type: Object,
       default() {
         return {};
       },
     },
     id: Number,
   },
   name: 'DraggableDiv',
   data: function () {
     return {
       positions: {
         clientX: undefined,
         clientY: undefined,
         movementX: 0,
         movementY: 0,
         draggableContainer_left: undefined,
         draggableContainer_top: undefined,
         draggableContainer_width: undefined,
         draggableContainer_height: undefined,
       },
       moving: false,
       mouseOverHeader: false,
       mouseOverContainer: false,
       resizing: false,
       isInResizeBox: false,
     };
   },
   methods: {
     dragMouseDown: function (event) {
       this.moving = true;
       event.preventDefault();
       // get the mouse cursor position at startup:
       this.positions.clientX = event.clientX;
       this.positions.clientY = event.clientY;
       document.onmousemove = this.elementDrag;
       document.onmouseup = this.closeDragElement;
     },
     elementDrag: function (event) {
       event.preventDefault();
       this.positions.movementX = this.positions.clientX - event.clientX;
       this.positions.movementY = this.positions.clientY - event.clientY;
       this.positions.clientX = event.clientX;
       this.positions.clientY = event.clientY;
       // set the element's new position:
       this.$refs.draggableContainer.style.top =
         this.$refs.draggableContainer.offsetTop -
         this.positions.movementY +
         'px';
       this.$refs.draggableContainer.style.left =
         this.$refs.draggableContainer.offsetLeft -
         this.positions.movementX +
         'px';
     },
     closeDragElement(event) {
       document.onmouseup = null;
       document.onmousemove = null;
       this.moving = false;
     },
     resizeMouseDown(event) {
       if (this.isInResizeBox == false)
         return;
       this.resizing = true;
       event.preventDefault();
       this.positions.clientX = event.clientX;
       this.positions.clientY = event.clientY;
       this.positions.draggableContainer_left = this.$refs.draggableContainer.offsetLeft;
       this.positions.draggableContainer_top = this.$refs.draggableContainer.offsetTop;
       this.positions.draggableContainer_width = this.$refs.draggableContainer.offsetWidth;
       this.positions.draggableContainer_height = this.$refs.draggableContainer.offsetHeight;
       document.onmousemove = this.resizeDrag;
       document.onmouseup = this.resizeClose;
     },
     resizeDrag(event) {
       event.preventDefault();
       const ABS_THRESHOLD = 8;
       const ini_draggableContainer_right =
         this.positions.draggableContainer_left +
         this.positions.draggableContainer_width;
       // Left
       if (
         Math.abs(
           this.positions.draggableContainer_left - this.positions.clientX
         ) <= ABS_THRESHOLD
       ) {
         const horizonal_moved_vec =
           this.$refs.draggableContainer.offsetLeft - event.clientX;
         this.$refs.draggableContainer.style.width =
           horizonal_moved_vec +
           this.$refs.draggableContainer.offsetWidth +
           'px';
         this.$refs.draggableContainer.style.left = event.clientX + 'px';
         // Right
       } else if (
         Math.abs(ini_draggableContainer_right - this.positions.clientX) <=
         ABS_THRESHOLD
       ) {
         this.$refs.draggableContainer.style.width =
           event.clientX - this.$refs.draggableContainer.offsetLeft + 'px';
       }
       const ini_relative_clientY = this.positions.clientY - 50;
       const relative_clientY = event.clientY - 50; // Headerの高さ 50px
       const ini_draggableContainer_bottom =
         this.positions.draggableContainer_top +
         this.positions.draggableContainer_height;
       // Top
       if (
         Math.abs(
           this.positions.draggableContainer_top - ini_relative_clientY
         ) <= ABS_THRESHOLD
       ) {
         const vertical_moved_vec =
           this.$refs.draggableContainer.offsetTop - relative_clientY;
         this.$refs.draggableContainer.style.height =
           vertical_moved_vec +
           this.$refs.draggableContainer.offsetHeight +
           'px';
         this.$refs.draggableContainer.style.top = relative_clientY + 'px';
         // Bottom
       } else if (
         Math.abs(ini_draggableContainer_bottom - ini_relative_clientY) <=
         ABS_THRESHOLD
       ) {
         this.$refs.draggableContainer.style.height =
           relative_clientY - this.$refs.draggableContainer.offsetTop + 'px';
       }
     },
     resizeClose() {
       document.onmouseup = null;
       document.onmousemove = null;
       this.$emit(
         'updateStoredHeight',
         this.$refs.draggableContainer.style.height
       );
       this.$emit(
         'updateStoredWidth',
         this.$refs.draggableContainer.style.width
       );
       this.resizing = false;
     },
     
   },
   mounted() {
     this.$refs.draggableContainer.style.top = this.WrapperStyle.top;
     this.$refs.draggableContainer.style.left = this.WrapperStyle.left;
   },
 };
 </script>

以上が作成されたコードです。

必要なスタイルは、Propsで渡すことができ、その他拡張性もしやすいコードになっています。

実装の解説を軽く。

今回の実装のみそは、「仮想DOMではなく$refsを使って実DOMにアクセスし続け、Draggable、Resizableに関するすべての状態をNuxtシステムで監視すること」です。

普段仮想DOMしか触っていない人にはマジで面倒くさいですし、HTMLの知識がある人じゃないとかなりの時間がかかると思います。

「Dragしているかどうか」を特定の要素上でmousedown、mouseupしたタイミングで切り替え、mouseの画面上の位置を記録し、、、って感じでmouseのイベントと、要素の状態を完全にコントロールしなければいけません。

頑張りました;;。

最後に

FIXERでは、フロントをやる気はありませんが、もし「フロントエンドのことを話したい」「Vue/Nuxtのことを聞きたい」などの方がいたら話しましょ~。

この記事が、皆さんのフロントエンド体験の改善につながると幸いです。

azblob://2024/04/08/eyecatch/2024-04-04-yellow-apron-000.jpg
2024/04/15
About FIXER