HTML
JavaScript
TypeScript
GoogleDrive
SPA

[ページ遷移しないWebシステム] JavaScriptだけでWindowsのExplorer風、GoogleDriveファイルマネージャの実装

1.Webアプリをネイティブアプリに近づける

 SPA(SinglePageApplication)による使いやすいシステムを普及させるべく、JavaScriptで便利に使えるWindowフレームワークを作成している最中である。TreeViewやListViewの実装を行ったので、それらを生かすためのWebアプリは無いかと考えた結果、Explorer風のファイルマネージャを作成することにした。何かを使うために作るものを決めるという発想は本末転倒ではある。しかしいずれ使う時も来るであろうから、GoogleDriveのファイルマネージャを作ることにした。正月の暇な時間を利用して、ファイルの表示やアップロード、削除の機能などは作り終わった。まだまだ実装すべき機能はあるのだが、現時点での内容を紹介したいと思う。

 ちなみにこれを作り込んでいくとどうなるかというと、ネイティブアプリのようなファイルマネージャを好きなときに利用できるようになる。GoogleDriveに対して入出力を行いたいとき、保存先を選ぶためのウインドウを出してユーザに選ばせることが出来る。しかもポップアップウインドウのように動作するので、レイアウトを気にせず必要なときに呼び出せるのだ。もちろん本当のポップアップではないので、セキュリティの制限には引っかからない。表示したウインドウは位置やサイズに問題があれば、移動もリサイズも可能だ。

 JavaScriptだけで実装しているので、バックエンドにAppサーバを置く必要は無い。ただしGoogleのOAuthを通さなければならないので、配置した場所のドメインを登録する必要がある。
 

2.成果物

 ソースコード

 https://github.com/JavaScript-WindowFramework/GDriveExplorer

 実運用ページ

 GitHubのGitHubPagesを使用している
 ただしOAuthを通す必要があるので、ドメインは独自のものを設定している
 https://gdriveexplorer.github.croud.jp/

 動作画面

image.png

 リストビューにファイルをドラッグドロップすれば、アップロードも可能
 公式のものより何故か動作のレスポンスが良い

3.利用技術

 3.1 Windowフレームワークに使用しているもの

・TypeScript
 トランスコンパイルを挟むのに少し前まで抵抗があり使っていなかったが、実際使ってみると無茶苦茶便利だった
 ES5に出力するコードでclassが使えるの利便性は大きい

・SCSS
 TypeScript同様、CSSをトランスコンパイルし便利な機能を提供してくれる
 VSCodeならEasySassというプラグインを入れておけば勝手に変換される

 3.2 GoogleDriveの操作に使用しているもの

https://cdn.jsdelivr.net/npm/es6-promise@4/dist/es6-promise.auto.min.js
 IE11レベルでPromiseを使うのに必要
 Windowフレームワークの方では使っていないのだが、GoogleDriveの操作に利用
 使えば通信時の非同期の扱いが統一されてわかりやすくなる

https://apis.google.com/js/client.js
 GoogleDriveの操作に利用
 ただ、認証情報がグローバル変数に記憶されるので、用途によっては使いづらい
 いずれ使わない方向に持って行く予定

3.GoogleDrive

 JavaScriptでGoogleDriveを扱うために色々と試行錯誤する結果となった。Google公式のgapi.client.driveを利用しているのだが、認証部分とファイルのアップロードに多少時間を食った。

 しかし驚くべきことに一番時間を持って行かれたのは、ファイルをゴミ箱に入れる処理なのだ。trashed属性をtrueにすれば良いというのは最初から分かっていた。そしてgapi.client.drive系からそれを変更する方法を探し回った。結果、存在しないという結論に達するのに時間がかかったのだ。Web上には完全削除の方法ばかり引っかかり、trashedをtrueにする方法は発見できなかった。完全削除はわかりやすいAPIが用意されているので、みんなゴミ箱を無視したようだ。結局、gapi.client.requestから半手動設定でパラメータを設定する以外に方法が無かった。

 GoogleDriveを操作するクラスをTypeScriptで作ったのでソースを貼り付けておく。取り急ぎ作ったのでエラーチェックをほぼやっていないが、同じようなことをやっている人の参考になればと思う。

GoogleDrive.ts
namespace JSW{

/**
 * GoogleDrive操作用クラス
*/
export class GoogleDrive {
    mClientId: string

    /**
     * @param clientId GoogleのOAuthクライアントID
     * @param callback 初期化時のコールバック
    */
    constructor(clientId:string, callback?:()=>void) {
        this.mClientId = clientId

        gapi.load('client:auth2', () => {
            console.log('API Loaded')

            gapi.client.init({
                clientId: this.mClientId,
                scope: 'https://www.googleapis.com/auth/drive',
                discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/drive/v3/rest']
            }).then(function () {
                if (callback) {
                    callback()
                }
            })
        });
    }
    /**
     * サインイン状態のチェック
     * @returns サインイン状態
    */
    isSignIn() {
        if (!gapi.auth2)
            return false;
        return gapi.auth2.getAuthInstance().isSignedIn.get()
    }
    /**
    * サインインの要求
    * @async
    * @resolve flag true:成功 false:失敗
    * @reject value エラーメッセージ
    */
    signIn() {
        return new Promise((resolve: (flag?:boolean)=> void, reject:(value?:{message})=>void)=>{
            if (gapi.auth2) {
                const auth = gapi.auth2.getAuthInstance();
                const flag = auth.isSignedIn.get()
                if (flag)
                    resolve(flag);
                else
                    auth.signIn().then(()=>{
                        resolve(flag);
                    }).catch(()=>{
                        reject({ message: 'ログイン失敗' })
                    });
            }else{
                reject({message:'APIが初期化されていない'})
            }
        })

    }
    /**
    * サインアウトンの要求
    * @async
    * @resolve flag true:成功 false:失敗
    * @reject value エラーメッセージ
    */
    signOut(){
        return new Promise((resolve:(value?: boolean)=> void, reject:(value?:{message})=>void)=>{
            if (!gapi.auth2)
                reject({message:'APIが初期化されていない'})
            gapi.auth2.getAuthInstance().signOut().then((flag)=>{
                resolve(flag)
            })
        })

    }
    /**
    * ファイルのアップロード
    * @async
    * @resolve response アップロードレスポンス
    */
    upload(parentId : string,files : FileList){
        const that = this
        return new Promise((resolve:(value?: gapi.client.drive.File[])=> void, reject)=>{
            const user = gapi.auth2.getAuthInstance().currentUser.get();
            const oauthToken = user.getAuthResponse().access_token;
            for (var i = 0; i < files.length; i++) {
                var file = files[i];
                var reader = new FileReader()
                reader.onload = function () {
                    const http = new XMLHttpRequest()
                    const contentType = file.type || 'application/octet-stream'
                    http.open('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable', true)
                    http.setRequestHeader('Authorization', 'Bearer ' + oauthToken)
                    http.setRequestHeader('Content-Type', 'application/json')
                    http.setRequestHeader('X-Upload-Content-Length', file.size.toString())
                    http.setRequestHeader('X-Upload-Content-Type', contentType)
                    http.onreadystatechange = function () {
                        if (http.readyState === XMLHttpRequest.DONE && http.status === 200) {
                            const locationUrl = http.getResponseHeader('Location')
                            const httpUpload = new XMLHttpRequest()
                            httpUpload.open('PUT', locationUrl, true)
                            httpUpload.setRequestHeader('Content-Type', contentType)
                            httpUpload.setRequestHeader('X-Upload-Content-Type', contentType)
                            httpUpload.onreadystatechange = function () {
                                if (httpUpload.readyState === XMLHttpRequest.DONE && httpUpload.status === 200) {
                                    let response = JSON.parse(httpUpload.response)
                                    that.moveDir(response.id,parentId).then(()=>{
                                        resolve(response)
                                    })
                                }
                            };
                            httpUpload.send(reader.result);
                        }
                    }
                    http.send(JSON.stringify({
                        'name': file.name,
                        'mimeType': contentType,
                        'Content-Type': contentType,
                        'Content-Length': file.size
                    }));
                }
                reader.readAsArrayBuffer(file);
            }
        })
    }
    /**
    * ディレクトリ一覧の要求
    * @async
    * @Param id ディレクトリID
    * @resolve files ファイルリスト
    * @reject value エラーメッセージ
    */
    getDir(id: string | string[]) {
        return new Promise((resolve:(files?: gapi.client.drive.File[])=> void, reject)=>{
            if (!gapi.client.drive) {
                reject({message:'DriveAPIがロードされていない'})
            }

            let parent: string[]
            if (id instanceof Array) {
                if (id.length === 0){
                    resolve([])
                }
                parent = id as string[]
            } else {
                parent = [id]
            }
            let query = '('
            for (let i = 0, l = parent.length; i < l; i++) {
                if (i > 0)
                    query += ' or '
                query += "'" + parent[i] + "' in parents"
            }
            query += ')'

            let files:gapi.client.drive.File[] = []
            function getDir(token?) {
                gapi.client.drive.files.list({
                    pageSize: 1000,
                    corpora: 'user',
                    spaces: "drive",
                    orderBy: 'name',
                    q: query + " and mimeType='application/vnd.google-apps.folder' and trashed=false",
                    fields: "nextPageToken, files(id, name,mimeType,kind,parents,iconLink)",
                    pageToken: token
                }).then(function (response) {
                    Array.prototype.push.apply(files, response.result.files)
                    if (response.result.nextPageToken)
                        getDir(response.result.nextPageToken)
                    else
                        resolve(files)
                });
            }
            getDir()

        })
    }
    /**
    * ファイルの削除(ゴミ箱)
    * @async
    * @Param id ファイルID
    * @resolve response レスポンス
    */
    delete(id:string){
        return new Promise((resolve: (response: gapi.client.Response<{}>)=>void,reject)=>{
            gapi.client.request({
                path:'https://www.googleapis.com/drive/v3/files/'+id,
                method:'PATCH',
                body:JSON.stringify({trashed:true})
            }).execute((response)=>{
                resolve(response)
            })

        })
    }
    /**
    * ファイルの削除(ゴミ箱)
    * @async
    * @Param srcId 移動元ID
    * @Param toId 移動元フォルダID
    * @resolve files ファイルリスト
    */
    moveDir(srcId:string,toId:string){
        return new Promise((resolve,reject)=>{
            gapi.client.drive.files.get({ fileId: srcId,fields: 'parents' }).then((response) => {
                let file = response.result
                var previousParents = file.parents.join(',')

                gapi.client.drive.files.update({
                    fileId: srcId,
                    addParents: toId,
                    removeParents: previousParents,
                    fields: 'id, parents'
                    }).then(function(response){
                        resolve(response)
                    })
                })
            })
    }
    /**
    * ファイル情報の取得
    * @async
    * @Param id ファイルID
    * @resolve response レスポンス
    */
    getFile(id:string) {
        return new Promise(
            (resolve:(value?: gapi.client.Response<gapi.client.drive.File>)=> void,
            reject:(value?:{message:string})=>void)=>{
            if (!gapi.client.drive) {
                reject({message:'DriveAPIがロードされていない'})
            }
            gapi.client.drive.files.get({ fileId: id }).then((response) => {
                resolve(response)
            })
        })
    }
    /**
    * ファイルリストの取得
    * @async
    * @Param parentId フォルダID
    * @resolve response レスポンス
    * @reject value エラーメッセージ
    */
    getFiles(parentId:string) {
        return new Promise(
                    (resolve:(files?: gapi.client.drive.File[])=> void,
                    reject:(value?:{message:string})=>void)=>{
            if (!gapi.client.drive) {
                reject({message:'DriveAPIがロードされていない'})
            }
            let files = []
            function getFiles(token?) {
                gapi.client.drive.files.list({
                    pageSize: 1000,
                    corpora: 'user',
                    spaces: "drive",
                    orderBy: 'name',
                    q: "'" + parentId + "' in parents and trashed=false",
                    fields: "nextPageToken, files(id, name,mimeType,kind,modifiedTime,parents,iconLink,size,webContentLink)",
                    pageToken: token
                }).then((response) => {
                    Array.prototype.push.apply(files, response.result.files)
                    if (response.result.nextPageToken)
                        getFiles(response.result.nextPageToken)
                    else
                        resolve(files)
                })
            }
            getFiles()
        })
    }
}
}

4.Window周り

 4.1 フレームワークの構成クラス

  • JSW.JWindow     ウインドウ用基本クラス
    • JSW.JFrameWindow フレームウインドウ用クラス
    • JSW.JSplitter   分割ウインドウ用クラス
    • JSW.TreeView   ツリービュー用クラス
    • JSW.ListView   リストビュー用クラス
    • JSW.JPanel    パネルバー用クラス

 これらのクラスがWindowを表示させるための基本クラスとなる。アプリケーションの開発はこのクラスを継承させる形となる

 4.2 GoogleDrive操作用の拡張クラス

 GoogleDrive操作用のUI構築のため、フレームワークの各クラスを継承

  • JSW.JPanel
    • DrivePanel    サイドパネル表示クラス
  • JSW.JTreeView
    • DriveTree     ディレクトリツリー表示クラス
  • JSW.JListView
    • DriveList     ファイルリスト表示クラス
  • JSW.JFrameWindow
    • GoogleExplorer  ファイルマネージャ本体クラス
    • MessageBox    メッセージ表示用クラス
    • GoogleSignWindow Googleサインイン用クラス

 

 4.3 継承例

 以下はツリービューをGoogleDriveのディレクトリ表示用に継承したものだ。やっていることは、Driveにデータを要求して、受け取ったらツリーに追加するという処理になる。UIの操作性を上げるため、展開したツリーの一階層先までリストを先読みする。

 最初から全部読み込もうと思ったら、さすがに重くなったので断念した。

Main.js
/**
 * GoogleDriveディレクトリリスト用ツリービュー
*/
class DriveTree extends JSW.JTreeView {
    mGoogleExplorer: GoogleExplorer
    constructor(googleExplorer: GoogleExplorer) {
        super()
        this.mGoogleExplorer = googleExplorer
        //ツリーが展開されたら下の階層を先読み
        this.addEventListener('open', function (e) {
            let params = e.params
            if(params.opened)
                this.loadChild(params.item.getItemValue())
        }.bind(this))
        //選択されたらファイルリストを読み出す
        this.addEventListener('select', function (e) {
            googleExplorer.loadFiles(e.params.item.getItemValue())
        })
    }
    load(id?) {
        const that = this
        const googleDrive = this.mGoogleExplorer.getGoogleDrive()
        if (id == null) {
            var item = that.getRootItem()
            item.clearItem()
            googleDrive.getFile('root').then((r)=> {
                item.setItemText(r.result.name)
                item.setItemValue(r.result.id)
                item.select()
                that.load(r.result.id)
            })
        }else{
            let msgBox = new MessageBox(this,'メッセージ','フォルダ一覧の取得')
            googleDrive.getDir(id).then(function (files) {
                let item = that.findItemFromValue(id)
                item.setKey('loaded', true)
                for (let file of files) {
                    item.addItem([file.name, file.id])
                }
                that.loadChild(id)
                msgBox.close()
            })
        }
    }
    loadChild(id) {
        const that = this
        const googleDrive = this.mGoogleExplorer.getGoogleDrive()
        let item = this.findItemFromValue(id)
        let ids = []
        for (let i = 0, l = item.getChildCount(); i < l; i++) {
            let child = item.getChildItem(i)
            ids.push(child.getItemValue())
        }
        //子ノードの読み込み
        googleDrive.getDir(ids).then(files=> {
            for (let file of files) {
                for (let p of file.parents) {
                    let item = that.findItemFromValue(p)
                    if (item) {
                        if(item.findItemFromValue(file.id) == null){
                            item.addItem([file.name, file.id])
                            item.setKey('loaded', false)
                        }
                    }
                }

            }
        })
    }
}

 5.目指すもの

 重要視しているポイントは二つある。必要なときに使える利便性と、好きなようにカスタマイズ出来る拡張性だ。

 利便性に関してWindowフレームワーク部分は、一切他のライブラリを使用していないので、何かとバッティングすることは無い。また、CSSのセレクターにclassやIDを一切使用していないので、セレクターの衝突も基本的には起こらないだろう。そしてどこにでもウインドウを表示できるので、他に何が表示されていようとほぼ影響はない。フレームウインドウを使わず、好きなNodeに貼り付けることも可能だ。

 拡張性に関してはJWindowクラスを継承すれば、ウインドウの基本機能を持った状態で、好きなウインドウやビューを作成可能である。作ったものが増えていけば、開発時間が大幅に短縮されるだろう。ちなみに今回の内容は、GUI部分に限定すればMain.tsの320行ほどのコードとなっている。

 現状での欠点として、このUIのまま行くとモバイル対応が厳しい。タップイベントにも対応させているのだが操作性には難がある。モバイル用の新たなコントロールを開発する必要があるだろう。

 コードはMIT Licenseで公開しているので、好きなように使ってもらってかまわない。しかし仕様が完全に固まっていない都合上、そもそもドキュメントを書いていないので、次の作業はそれかなと思っている。