Typescriptを用いてディレクトリを含むドラッグ&ドロップを確認する


はじめに

お仕事でTypescriptでディレクトリを含むドラッグ&ドロップが必要になりましたが、ngx-dropzoneなどファイルを対象としたドラッグ&ドロップの記事は多いのですが、ディレクトリ内のファイルまで対象にするものは少なかったので整理用にまとめました。
また当初はngx-dropzoneの設定で対応できないか調査しましたが、下記の理由から根本に手を入れる必要が出てきたので断念。

  • ディレクトリの中を確認するためにはDragEventからDataTransferを引き出して、さらにFileSystemEntryを取り出して中を確認する必要があるが、ngx-dropzoneではイベントはNgxDropzoneChangeEventとなっておりDataTransferが引き出せない

  • ngx-dropzoneでは、processDirectoryDropオプションを入れることでディレクトリ中を確認できるが、1階層しか見ないという制約あり

実現コード

他のサイトの方の実装を内容を参考に、以下のコードを作ってみました。
なお理解用に必須ではない型定義やconsole出力を多数付与しています。

  <div class="content">
    <div class="dragarea" (dragover)="dragOver($event)" (drop)="drop($event)">
      ドラッグエリア
    </div>
    <label>結果表示(ディレクトリ込み:FileSystemEntry[])</label>
    <div *ngFor="let file of allBehaviorSubject | async" class="current-user">
      {{ file.name }}
    </div>
    <hr>
    <label>結果表示(ディレクトリなし:File[])</label>
    <div *ngFor="let file of fileBehaviorSubject | async" class="current-user">
      {{ file.name }}
    </div>
  </div>
 // 画面表示用
  // 取得後の処理ではFile型で扱うこともあるので、参考として用意
  fileBehaviorSubject = new BehaviorSubject<File[]>([]);
  // ディレクトリはFileSystemEntryからFileに変換が(簡単には)できないので
  // FileSystemEntryのままで保持
  allBehaviorSubject = new BehaviorSubject<FileSystemEntry[]>([]);

  dragOver(event: DragEvent) {
    // ブラウザでファイルを開かないようにする
    // ここを設定しないと、drop時でもブラウザでファイルが開かれてしまう
    event.preventDefault();
    console.log('dragOver');
  }

  drop(event: DragEvent) {
    console.log('drop start');
    // ブラウザでファイルを開かないようにする
    event.preventDefault();
    this.dropProcess(event);
    console.log('drop end');
  }

  dropProcess = async (event: DragEvent) => {
    // --- ここから処理の準備
    const files: File[] = [];
    const entries: FileSystemEntry[] = [];
    // ファイルおよびディレクトリの検索(再帰呼び出し)
    // entryはFileSystemFileEntryもしくはFileSystemDirectoryEntry
    const recursiveSearchFileAndDir = async (entry: any) => {
      // ファイル
      if (entry.isFile) {
        entries.push(entry);
        const file: File = await new Promise<File>((resolve) => {
          entry.file((file: File) => { resolve(file) })
        });
        files.push(file);
      }
      // ディレクトリ
      else if (entry.isDirectory) {
        console.log('directory:' + entry.fullPath);
        entries.push(entry);
        const dirReader = entry.createReader();
        let allEntries: FileSystemEntry[] = [];

        const readEntries = async () => {
          console.log('readEntries()');// 複数回呼ばれることが分かるようコンソールを入れている
          // dirReader.readEntriesでは一度にすべてのエントリを取得できないため
          // recursiveReadEntriesを再帰呼び出しして全エントリを取得する
          const entries: FileSystemEntry[] = await recursiveReadEntries();
          if (entries.length > 0) {
            allEntries = [...allEntries, ...entries];
            await recursiveReadEntries();
          }
        };
        const recursiveReadEntries = () => new Promise<FileSystemEntry[]>((resolve) => {
          console.log('getEntries()'); // 複数回呼ばれることが分かるようコンソールを入れている
          dirReader.readEntries((entries: FileSystemEntry[]) => {
            resolve(entries);
          });
        });

        // 上のgetEntriesで全エントリを取得して、searchFileAndDirを再帰呼び出し
        await readEntries();
        for (const entry of allEntries) {
          await recursiveSearchFileAndDir(entry);
        };
      }
    }
    // --- ここまで処理の準備

    // イベントからsearchFileAndDirを呼び出す本体
    const dr = event.dataTransfer;
    if (dr) {
      // DragEventからファイルを扱えるようdataTransferを実行
      const items: DataTransferItemList = dr.items;
      const promises: Promise<void>[] = Array.from(items).map((item) => {
        return new Promise((resolve) => {
          const entry: FileSystemEntry | null = item.webkitGetAsEntry();
          // nullの時は何もしない
          if (!entry) {
            resolve;
            return;
          }
          resolve(recursiveSearchFileAndDir(entry));
        });
      });
      await Promise.all(promises);
      // 画面表示用に設定
      this.fileBehaviorSubject.next(files);
      this.allBehaviorSubject.next(entries);
    }
  }

実行時の画面

実行すると下記のような画面が表示され、水色内にドラッグ&ドロップするとその下の結果表示にファイル名とディレクトリ名の一覧が表示されます。

ドラッグ&ドロップ時の動き

ディレクトリをドラッグ&ドロップした場合の動きを理解するために、下記内容のディレクトリtest1Dirをドラッグ&ドロップしてみると、その下のようにreadEntriesとgetEntriesがディレクトリ構造の数以上に呼び出されていることがわかります。これはコードのコメントにもあるとおり、エントリを一度にすべて取得できないことから、再帰呼び出しですべて取り出しているためです。

  test1Dirtest1-(1).xlsx
  │  ~
  │  test1-(4).xlsx
  │
  └─test2Dir
          test2- (1).xlsx
          test2- (10).xlsx
          test2- (100).xlsxtest2- (98).xlsx
          test2- (99).xlsx
app.component.ts:24 dragOver
app.component.ts:28 drop start
app.component.ts:52 directory:/test1Dir
app.component.ts:58 readEntries()
app.component.ts:68 getEntries()
app.component.ts:32 drop end
app.component.ts:68 getEntries()
app.component.ts:52 directory:/test1Dir/test2Dir
app.component.ts:58 readEntries()
app.component.ts:68 getEntries()
app.component.ts:68 getEntries()

補足

今回の実装でFile[]が取れるので、ngx-dropzoneを使って一覧表示に転用できるのではないかと期待(見た目と一覧からファイルの個別除去ができて便利そうなため)。
なおngx-dropzoneは公式を見ると、開発が終わっているので仕事で新たに使うのは避けた方が良いと思われます。

いいなと思ったら応援しよう!