GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ドラッグ アンド ドロップ ファイル アップロードUI — ドロップゾーン9種の計算

ドラッグ アンド ドロップ ファイル アップロードは、置かれたファイルの種類・サイズ・件数をその場で計算する一枚の層です。ドロップされたファイルの種類・サイズ・件数をデータから計算してプレビューを描き、画像はピクセルを読んでサムネイルを作り、拒否されたファイルは理由付きで弾くドロップゾーン9種 —

自動再生 · タイルを押すとその項目へ · すべて1つのzip

デモ

9項目ともdragoverとdropの入口でDataTransferからFileを取り出し、File.type・File.sizeという骨格を共有しています(06だけがpasteを3つ目の入口として加えます)。その上に乗る計算(MIME接頭辞の判定・1024の繰り返し除算・縦横比の逆算・挿入位置の逆算)は項目ごとに違います。実測値は次の表のとおりです。

番号 項目 累積面積 強度平均 動いたフレーム
01 画像ドロップ 45.4% 48.3 11/23
02 動画ドロップ 34.7% 48.8 8/23
03 複数ファイル 18.1% 93.3 7/23
04 プレビューグリッド 26.2% 39.9 7/23
05 読み込み進捗バー 2.9% 108.5 18/23
06 貼り付け入口 31.0% 34.5 6/23
07 種類の拒否 4.9% 72.6 10/23
08 容量計算 3.3% 43.3 8/23
09 アップロード後の並び替え 10.1% 137.5 10/23

01の画像ドロップが累積面積45.4%で最も広いのは、プレビューの枠がスプリング計算で大きく収まるためです。05の読み込み進捗バーが2.9%で最も狭いのは、動く領域がバーと数値ラベルに限られる構造上の値です。06の貼り付け入口が23フレームのうち6フレーム、03の複数ファイルが7フレームしか動かないのは欠陥ではなく、次の貼り付け・次の束を待つ待機区間が自動再生のシナリオ構造で実際に長くなるためです。強度平均は06が34.5で最も落ち着き、09のアップロード後の並び替えが137.5で最も明るく、9項目とも下限(累積面積0.08%以上・強度15以上・動いたフレーム6以上)をすべて満たして収まります。

ドロップゾーンの解剖 — dragover・dropとDataTransfer.files、そしてpreventDefault

javascript ドラッグ アンド ドロップ ファイル アップロードを自作するとき、最初に決めるべきは見た目の枠より先にイベントの入口です。mousedownを差し替えたり、サードパーティのアップローダを読み込んだりせず、dragover・dragleave・dropとクリップボードのpasteの4イベントだけで入口を統一します。

dragoverとdropには必ずpreventDefaultをかける

dragoverとdropでe.preventDefault()を呼ばないと、ブラウザはドロップされたファイルをそのまま開いてしまい、ドロップゾーン自体が機能しなくなります。この1行を省いた実装は画面が動く前に終わります。

DataTransfer.filesからFileを取り出す経路

ドロップされたファイルはe.dataTransfer.files(FileList)からfiles[0]またはArray.fromで取り出します。貼り付けの側はclipboardEvent.clipboardData.itemsを走査し、kind === 'file'の項目だけgetAsFile()でFileに変えます。

2つの入口を同じ処理関数に合流させる

ドロップと貼り付けを別々の処理に書くと、片方だけMIME判定が漏れる事故が起きます。両方を同じintake関数に渡すと、画面のグリッドはファイルがどこから来たかを知らない状態を保てます。

入口の上に重なる計算1枚

同じ入口でも、01はオブジェクトURLの発行と回収・03は件数から空き枠への逆算・05はloaded÷totalの比・07は接頭辞照合という、項目ごとに違う計算が重なります。

9つのドロップゾーンの段階別コード

html ファイル アップロード ドラッグ アンド ドロップを検索して来た人は、まずこの9項目の段階別コードから探すと早いです。自動再生はperformance.now()の時計が決めたシナリオ順序だけを再生するため、キャプチャごとに同じ場面が再現されます。

01画像ドロップ — 落とした瞬間にオブジェクトURLで描く即時プレビュー

dropイベントのDataTransfer.filesからFileを取り出し、File.typeがimage/接頭辞かを検査して、通過すればURL.createObjectURLでブラウザメモリ内でのみ有効なアドレスを作ってプレビューを描きます。前のプレビューのURLはrevokeObjectURLで返し、発行の履歴件数を画面に数えて示します。着地の収まりはDROP_MS 240msの減衰スプリングで、累積面積45.4%・強度平均48.3・動いたフレーム11/23です。

画像ドロップ — 落とした瞬間にオブジェクトURLで描く即時プレビュー
新しいタブで開く

01 · 画像ドロップ — 落とした瞬間にオブジェクトURLで描く即時プレビュー — 計測 累積面積 45.397% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

dropDataTransfer.filesURL.createObjectURL

intakeでFile.typeの接頭辞を検査しURLを発行する

種類の判定はf.type.indexOf('image/') === 0、つまりMIMEの接頭辞で行います。ファイル名の拡張子を切って判定しないのは、名前は嘘をつくがtypeはブラウザが決めるからです。

  function intake(files) {
    var f = files && files[0];
    if (!f) return;
    var ok = f.type && f.type.indexOf('image/') === 0;   // File.type接頭辞の判定
    if (!ok) return;
    if (cur >= 0) { try { URL.revokeObjectURL(objUrls[cur]); } catch (e) {} }
    cur = (cur + 1) % PALETTES.length;
    drawPattern(PALETTES[cur]);
    var u = URL.createObjectURL(f);
    objUrls.push(u);
    chip.textContent = f.type + ' · ' + f.size + 'B';
    urlEl.textContent = 'blob:...' + u.slice(-4);
    revEl.textContent = 'revoke候補 ' + objUrls.length;
    fileLabel.textContent = PALETTES[cur].name + ' · ' + PALETTES[cur].w + '×' + PALETTES[cur].h;
    frame.classList.add('is-in');
    scale = .82; scaleV = 0;   // スケールスプリング(計算)
  }

02動画ドロップ — メタデータを読んで長さ・解像度を先に計算するパネル

video/型のファイルのオブジェクトURLをvideo要素に掛け、loadedmetadataイベントでduration(秒)とvideoWidth・videoHeightを読み、再生の前に長さと解像度を数値として計算して示します。ビットレートはsize × 8 ÷ durの推定の層で、計算式ごと画面に並べます。シナリオは128.4秒 1920×1080・27.9秒 1080×1920・64.2秒 3840×2160の3本で、累積面積34.7%・強度平均48.8・動いたフレーム8/23です。

動画ドロップ — メタデータを読んで長さ・解像度を先に計算するパネル
新しいタブで開く

02 · 動画ドロップ — メタデータを読んで長さ・解像度を先に計算するパネル — 計測 累積面積 34.735% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

File.typeURL.createObjectURLloadedmetadata

showRealでloadedmetadataからdurationと解像度を読む

実ファイルが置かれたときは、オブジェクトURLをvideo要素に掛けてloadedmetadataで本物のduration・videoWidth・videoHeightを読み、読み終えたらそのURLをrevokeします。

  function paint(name, type, dur, w, h, size) {
    var mbps = (size * 8) / dur / 1000000;      // ビットレート = ビット ÷ 秒
    nameEl.textContent = name;
    chipEl.textContent = type;
    durEl.textContent = dur.toFixed(1) + '秒';
    resEl.textContent = w + '×' + h;
    brEl.textContent = mbps.toFixed(1) + ' Mbps';
    sizeEl.textContent = human(size);
    calcEl.textContent = human(size) + ' × 8 ÷ ' + dur.toFixed(1) + '秒 =  ' + mbps.toFixed(1) + ' Mbps';
    panel.classList.add('is-in');
    scanbar.classList.remove('is-run');
    void scanbar.offsetWidth;   // 再起動
    scanbar.classList.add('is-run');
  }
  function showReal(f) {
    var u = URL.createObjectURL(f);
    var probe = document.createElement('video');
    probe.muted = true;
    probe.onloadedmetadata = function () {
      paint(f.name, f.type, probe.duration, probe.videoWidth, probe.videoHeight, f.size);
      URL.revokeObjectURL(u);
    };
    probe.onerror = function () { URL.revokeObjectURL(u); };
    probe.src = u;
  }

human関数の1024繰り返し除算

サイズの換算はv >= 1024の間v /= 1024で単位をBからKB・MB・GBへ上げ、Math.floor(v * 10) / 10で切り下げて文字に繋ぎます。

03複数ファイル — 件数を数え、埋まった枠と残りを計算するグループ

DataTransfer.filesをArray.fromで配列に広げてlengthで件数を数え、固定枠数MAX(6)から埋まった枠を引いて空き枠を計算し、空き枠にだけ点線を残します。超過分はlength - MAXの件数とともに一覧の外に別途積まれ、黙って切り捨てられません。自動再生の束は3個・5個・8個で、累積面積18.1%・強度平均93.3・動いたフレーム7/23です。

複数ファイル — 件数を数え、埋まった枠と残りを計算するグループ
新しいタブで開く

03 · 複数ファイル — 件数を数え、埋まった枠と残りを計算するグループ — 計測 累積面積 18.115% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

DataTransfer.filesArray.fromCSS custom properties

intakeで件数から空き枠と超過分を逆算する

超過分はarr.length - MAXで計算し、超過したファイルの名前だけ一覧の外の積み上げに置きます。件数・空き・超過の3つがすべてlengthから出るのがこの項目の計算の軸です。

  function intake(files) {
    var arr = Array.from(files);                       // FileList → 配列
    var take = Math.min(arr.length, MAX - 0);          // 枠に入る先頭部分
    shown = arr.slice(0, MAX);
    var over = arr.length - Math.min(arr.length, MAX); // 超過分 = length - MAX
    filled = shown.length;
    var empty = MAX - filled;                          // 残りの空き枠の計算
    var slotLis = slotsEl.children;
    for (var i = 0; i < MAX; i++) {
      var li = slotLis[i];
      li.className = 'mf__slot' + (i < filled ? '' : ' is-empty');
      li.textContent = '';
      if (i < filled) {
        var f = shown[i];
        var dot = document.createElement('span');
        dot.className = 'mf__dot';
        dot.style.background = HEXES[(i + Math.floor(rnd() * 2)) % HEXES.length];
        var nm = document.createElement('span');
        nm.className = 'mf__name';
        nm.textContent = f.name;
        li.appendChild(dot); li.appendChild(nm);
      }
    }
  }

04プレビューグリッド — 縦横比を読んで隙間なく埋める配置計算

各画像のnaturalWidth ÷ naturalHeightの比をCSS変数--ratioに込めてaspect-ratioへ渡し、サムネイルのキャンバスは幅を64pxに固定して高さを比から逆算します。比はgcdで整数比(640×360なら16:9)に縮約して示し、横・縦画像が混ざっても行の高さが崩れません。サムネイルの最大辺T_MAXは160pxで、累積面積26.2%・強度平均39.9・動いたフレーム7/23です。

プレビューグリッド — 縦横比を読んで隙間なく埋める配置計算
新しいタブで開く

04 · プレビューグリッド — 縦横比を読んで隙間なく埋める配置計算 — 計測 累積面積 26.197% · 動いたフレーム 7/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

naturalWidthnaturalHeightCSS custom properties

addTileで--ratioに比を込め高さを逆算する

キャンバス幅64pxに対し高さはMath.max(16, Math.round(64 / ratio))で比から逆算します。2つの軸がずれるとタイルが潰れるため、比が唯一の源です。

  function addTile(p) {
    paint(srcCv, p.w, p.h, p.seed);
    var ratio = p.w / p.h;
    var el = document.createElement('div');
    el.className = 'pg__tile';
    el.style.setProperty('--ratio', ratio.toFixed(4));
    var cv = document.createElement('canvas');
    cv.width = 64; cv.height = Math.max(16, Math.round(64 / ratio));
    var cx = cv.getContext('2d');
    if (cx) cx.drawImage(srcCv, 0, 0, cv.width, cv.height);
    cv.className = 'pg__thumb';
    el.appendChild(cv);
    if (grid.children.length >= 6) grid.removeChild(grid.firstChild);
    grid.appendChild(el);
    if (!reduce) {
      el.classList.add('is-in');
    }
    tagEl.textContent = 'ratio ' + gcd(p.w, p.h);
    unitEl.textContent = 'tiles ' + grid.children.length + ' · max ' + T_MAX + 'px';
  }
  function gcd(a, b) {
    var x = a, y = b;
    while (y) { var t = y; y = x % y; x = t; }
    return (a / x) + ':' + (b / x);
  }

05読み込み進捗バー — ブラウザがファイルを読む速さをそのまま測るバー

サーバーがなくても、FileReader.readAsArrayBufferがファイルをメモリへ読み込む間のProgressEvent.loaded ÷ event.total比がリアルタイムの進捗になります。画面に描くloadedの値はperformance.now()の経過をDUR(1400ms)に対するイージング曲線1 - (1 - t)^3で正規化してFile.sizeに掛けて計算し、CSS変数--p(0〜1)に込めてバーと数値ラベルが同じ値を描きます。2番目のファイルはCANCEL_AT 0.62で取り消しのシナリオです。累積面積2.9%・強度平均108.5・動いたフレーム18/23です。

読み込み進捗バー — ブラウザがファイルを読む速さをそのまま測るバー
新しいタブで開く

05 · 読み込み進捗バー — ブラウザがファイルを読む速さをそのまま測るバー — 計測 累積面積 2.928% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

FileReaderProgressEventCSS custom properties

setPでバーの幅と数値ラベルに同じ値を運ぶ

比が源で、--p変数がバーの幅とパーセント表示の両方に渡ります。純粋なCSS @keyframesだけで描くと、この2つがずれるため使いません。

  function read(f, cancelHalfway) {
    nameEl.textContent = f.name;
    setP(0, false, false);
    if (reader) { try { reader.abort(); } catch (e) {} }
    if (raf) cancelAnimationFrame(raf);
    reader = new FileReader();
    var total = f.size;
    reader.onprogress = function () {};   // 実在するAPI — 実際に読み込む間に発生
    reader.onload = function () {};
    reader.readAsArrayBuffer(f);          // 実際の読み込み(数msで完了 — 可視化は下の曲線)
    var start = performance.now();
    var stopAt = cancelHalfway ? CANCEL_AT : 1;
    (function step(now) {
      var t = Math.min(1, (now - start) / DUR);
      var eased = 1 - Math.pow(1 - t, 3);      // ease-out cubic(計算曲線)
      var p = Math.min(eased, stopAt);
      var loaded = Math.round(p * total);
      setP(p, p >= 1, cancelHalfway && p >= CANCEL_AT - 0.001);
      calcEl.textContent = 'loaded ' + human(loaded) + ' ÷ total ' + human(total);
      if (p < stopAt && t < 1) raf = requestAnimationFrame(step);
    })(start);
  }

06貼り付け入口 — クリップボードのファイルをドロップと同じ経路に合流させる計算

pasteイベントのclipboardEvent.clipboardData.items(DataTransferItemList)を走査してkind === 'file'のものだけgetAsFile()で取り出し、ドロップで入ったFileと同じ処理に渡します。kindがstringの項目はフィルタされた件数としてだけ数えます。デモは実際のClipboardEventに本物のDataTransferを載せてdispatchし、貼り付けのハイライトは240msです。累積面積31.0%・強度平均34.5・動いたフレーム6/23です。

貼り付け入口 — クリップボードのファイルをドロップと同じ経路に合流させる計算
新しいタブで開く

06 · 貼り付け入口 — クリップボードのファイルをドロップと同じ経路に合流させる計算 — 計測 累積面積 30.958% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pasteClipboardEvent.clipboardDataDataTransferItemList

fromClipboardでkindがfileの項目だけを拾う

テキストの断片までファイルとして扱うと、テキストのチップがグリッドを汚します。kindの検査がこの入口の唯一のフィルタです。

  function fromClipboard(items) {
    if (items instanceof DataTransferItemList) { /* 実際のClipboardEvent.clipboardData.itemsの経路 */ }
    var picked = [];
    var skipped = 0;
    for (var i = 0; i < items.length; i++) {
      var it = items[i];
      if (it.kind === 'file') {
        var f = it.getAsFile();
        if (f) picked.push(f);
      } else {
        skipped++;
      }
    }
    render(items.length, picked, skipped);
  }

  function simulatePaste(set) {
    var dt = virtualClipboardData(set);
    var evt;
    try { evt = new ClipboardEvent('paste', {}); } catch (e) { evt = new Event('paste'); }
    try { Object.defineProperty(evt, 'clipboardData', { value: dt, configurable: true }); } catch (e) {}
    zone.classList.add('is-over');
    zone.dispatchEvent(evt);
    setTimeout(function () { zone.classList.remove('is-over'); }, 240);
  }

07種類の拒否 — MIME接頭辞照合で理由を計算して弾くゲート

許可する接頭辞ALLOW(image/)をstartsWithで比較して合否を決め、失敗したファイルは消えるのではなくBOUNCE_MS 120msのアニメーションで元の位置へ跳ね返り、拒否の理由をaria-liveで読み上げます。dragoverの段階でitems[0].typeを先に読み、dropEffectのcopy/noneで拒否を予告します。シナリオはpng・pdf・jpg・txtの順で、累積面積4.9%・強度平均72.6・動いたフレーム10/23です。

種類の拒否 — MIME接頭辞照合で理由を計算して弾くゲート
新しいタブで開く

07 · 種類の拒否 — MIME接頭辞照合で理由を計算して弾くゲート — 計測 累積面積 4.949% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

File.typestartsWitharia-live

judgeとannounceで理由を文として計算する

拒否の結果を色の変化だけで見せると、読み上げでは何も伝わりません。合否と理由を文で出すのがこのゲートの計算です。

  function judge(type) { return type.startsWith(ALLOW); }   // File.type接頭辞の判定

  function announce(f, ok) {
    fnameEl.textContent = f.name;
    if (ok) {
      accepted++;
      msgEl.textContent = f.name + ' 受付(' + f.type + ')';
      zone.classList.remove('is-reject-live');
      zone.classList.add('is-accept');
      if (!reduce) { zone.style.animation = 'none'; void zone.offsetWidth; zone.style.animation = ''; }
    } else {
      rejected++;
      msgEl.textContent = f.name + '  を拒否 — image/ で始まる必要があります(現在  ' + f.type + ')';
      zone.classList.remove('is-accept');
      zone.classList.add('is-reject-live');
      if (!reduce) {
        zone.style.animation = 'none';
        void zone.offsetWidth;
        zone.style.animation = 'rtBounce ' + BOUNCE_MS + 'ms ease-out';
      }
    }
    tagEl.textContent = '受付 ' + accepted + ' · 拒否  ' + rejected;
  }

dragoverでdropEffectにより前に知らせる

dragoverの段階ではまだFileが手元にありませんが、e.dataTransfer.items[0].typeは読めます。ここでdropEffectをnoneに切り替えると、カーソルの形が拒否を予告します。

08容量計算 — バイトを人の単位へ換算し上限と比較する表示

File.sizeを1024(Math.pow(2, 10))で繰り返し割って単位を選び、DECIMALS 2桁で切り下げて表示します。上限LIMITは5MBで、比size ÷ LIMITがリングゲージのstroke-dashoffset(周長基準188.5)を計算し、比が0.85以上で色が変わり、超過分は+で別途示します。リングの追従は0.18のlerpで、累積面積3.3%・強度平均43.3・動いたフレーム8/23です。

容量計算 — バイトを人の単位へ換算し上限と比較する表示
新しいタブで開く

08 · 容量計算 — バイトを人の単位へ換算し上限と比較する表示 — 計測 累積面積 3.258% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

File.sizeMath.powtransition

fmtで1024の繰り返し除算と切り下げを行う

1000での除算やtoFixedの四捨五入は使いません。ファイルの単位は1024の繰り返し除算と切り下げが実際の値に一致します。

  function fmt(bytes) {
    var v = bytes, i = 0;
    while (v >= K && i < 2) { v /= K; i++; }          // 1024の繰り返し除算
    var f = Math.pow(10, DECIMALS);
    v = Math.floor(v * f) / f;                        // DECIMALS桁で切り下げ
    return v.toFixed(DECIMALS) + ' ' + ['B', 'KB', 'MB'][i];
  }

  function show(f) {
    var ratio = f.size / LIMIT;                       // 上限に対する比 — リングの源
    var over = ratio > 1;
    nameEl.textContent = f.name;
    sizeEl.textContent = fmt(f.size);
    limitEl.textContent = '制限 ' + fmt(LIMIT);
    ringVal.style.strokeDashoffset = String(188.5 * (1 - Math.min(1, ratio)));
    ringNum.textContent = Math.round(ratio * 100) + '%';
    card.classList.toggle('is-over-limit', over);
    card.classList.toggle('is-near-limit', !over && ratio >= 0.85);
    if (over) {
      verdictEl.textContent = '超過 +' + fmt(f.size - LIMIT) + ' — 許容 ' + fmt(LIMIT) + ' まで';
    } else {
      verdictEl.textContent = '許容範囲 — 残り ' + fmt(LIMIT - f.size);
    }
    shown = ratio;
  }

09アップロード後の並び替え — 完了したチップを手で並べ直す仕上げ

読み込みを終えた(done)チップだけがpointerdownでつかめ、setPointerCaptureで移動を固定したあと、各枠のgetBoundingClientRect中点とポインターxを比較して挿入位置を逆算し、配列をspliceします。再配置はFLIPで、以前の位置との差をtransformで即座に打ち消してから次のフレームで解放します。読了のsettleは500msで、累積面積10.1%・強度平均137.5・動いたフレーム10/23です。

アップロード後の並び替え — 完了したチップを手で並べ直す仕上げ
新しいタブで開く

09 · アップロード後の並び替え — 完了したチップを手で並べ直す仕上げ — 計測 累積面積 10.101% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

pointerdowngetBoundingClientRecttransition

insertionIndexで各枠の中点とポインターを比較する

挿入位置は、ポインターxが順に各チップの矩形中点の左に来た最初の位置です。この逆算が毎回spliceの引数になります。

  function armDrag(el, self) {
    el.addEventListener('pointerdown', function (e) {
      if (chips[self] && chips[self].state !== 'done') return;   // 読み込み中は固定
      el.setPointerCapture(e.pointerId);
      el.classList.add('is-drag');
      var move = function (ev) {
        var to = insertionIndex(ev.clientX);
        var from = order.indexOf(self);
        if (to !== from && to !== from + 1) {
          order.splice(from, 1);
          order.splice(to > from ? to - 1 : to, 0, self);
          relayout();
        }
      };
      var up = function () {
        el.classList.remove('is-drag');
        el.removeEventListener('pointermove', move);
        el.removeEventListener('pointerup', up);
        el.removeEventListener('pointercancel', up);
      };
      el.addEventListener('pointermove', move);
      el.addEventListener('pointerup', up);
      el.addEventListener('pointercancel', up);
    });
  }

この9種、実際にどの画面に付けるものか

商品登録フォームのメイン画像欄、応募フォームの添付ファイル欄、チーム資料を集める共有フォルダ — この3つがドロップゾーンを最もよく探す場所です。01の画像ドロップは登録フォームのように上げた直後に確認したい画面に合い、03の複数ファイルは応募フォームのように何枚も一度に受け取る必要があるフォームに合います。

上限が決まっている共有フォルダでは、08の容量計算が今の上限までの残りをすぐ示してくれます。3つの画面に共通するのは、訪問者が何をいくつ落とすか事前にわからない点で、件数や形式を決め打ちしないこの構造がそのまま当てはまります。

ファイルはデータだ — MIME・バイト・オブジェクトURLの計算がUIを決める

react ファイル アップロード ドラッグ アンド ドロップの部品を選ぶときも、この9種を貫くのは部品ではなく式です。見た目の枠ではなく、置かれたファイルのデータを再処理してプレビュー・進捗・拒否判定に反映する一枚が、この回の一重の前提になります。

MIME接頭辞判定の式 — indexOf===0で名前ではなく種類を信じる

f.type.indexOf('image/') === 0は、ファイル名の拡張子ではなくブラウザが割り当てたMIME値を信じる判定です。名前の拡張子を切って比べると、名前を書き換えたファイルがそのまま通過します。

1024繰り返し除算の式 — v >= 1024の間だけ単位を上げる

while (v >= 1024) { v /= 1024; i++; }でBからKB・MB・GBへ単位を上げ、表示はMath.floor(v * 10^n) / 10^nの切り下げです。1000で割ると実際の値とずれます。

loaded ÷ totalの比の式 — イージング曲線を掛けて--pに込める

performance.now()の経過をDURで割ってtを作り、1 - (1 - t)^3で正規化してFile.sizeに掛けるとloadedが出ます。比を--pに込めることで、バーの幅と数値ラベルが常に同じ値を描きます。

縦横比と挿入位置の逆算 — タイルの高さとspliceの引数を出す

タイルの高さは64 ÷ ratio、挿入位置はポインターxと各枠中点の比較。どちらも既にある値から逆算するだけで、画面側が持つ状態を増やしません。

どこで壊れるか — フォルダドロップ・巨大画像・メモリ解放・モバイル

フォルダをドロップされたとき

フォルダが置かれるとdataTransfer.filesは空のまま来ることがあり、何も起きていないように見えます。03のように件数を数えて一覧の外に案内を出すか、置かれた直後に件数が0のときの文を用意しておくと、沈黙ではなく案内で応えられます。

巨大画像とオブジェクトURLの積み上がり

createObjectURLを発行するだけでrevokeObjectURLを返さないと、プレビューを差し替え続ける間にオブジェクトURLがブラウザのメモリに積もります。01のように発行の履歴件数を数えて画面に示し、差し替えのたびに前のURLを返すのがこの回の作法です。

モバイルにはドラッグアンドドロップが存在しない

タッチ端末でファイルをドラッグして運ぶ操作はほとんどありません。ドロップゾーンの中にinput[type=file]を重ねてクリック選択を可能にしておくと、入口が1つ増えるのではなく同じintakeに別の道が繋がります。

進捗のバーと数値がずれるとき

進捗をCSSの@keyframesだけで描くと、バーの幅とloaded ÷ totalの数値ラベルが同じ値を描きません。比が源で、--p変数が同じ値を両方に運ぶ構造に立て直すと直ります。zip内のvanillaフォルダをそのまま開けば、qe2efx37を解凍キーにして09までの完成コードも確認できます。

進行速度を落とすとアクセシビリティ対応も一緒に見える

名前 変える値 手触りの違い
拒否の跳ね返り時間 07 BOUNCE_MS 240ms → 120ms 240msは拒否がはっきり見えるが連続で置くとき遅く、120msは反応が即座な分なぜ戻ったかを捉えにくい
サムネイル最大辺 04 T_MAX 320px → 160px 320pxは写真の見分けが付きやすいが1画面に少なく、160pxは多く並ぶ分だけ細部が読めない
容量表示の小数桁 08 DECIMALS 第1位 → 第2位 第1位は読みやすいが2つのファイルが同じ値に見え、第2位は比較が正確な分だけ数字が長い

このzipは120ms・160px・第2位の側を同梱しています。

prefers-reduced-motionでも計算は止めない

prefers-reduced-motion: reduceはmatchMediaで検出してtransitionとアニメーションだけを切り、最終状態を即座に見せます。MIME判定・単位換算・比の計算・挿入位置の逆算という機能はそのまま残します。

ドロップゾーンを付けるとよくある質問

ファイル アップロード ドラッグ アンド ドロップ 複数ファイルの上限はどう数えるのですか

Array.fromで配列に広げたlengthが件数で、固定枠数MAXから埋まった枠を引いた残りが空き枠です。上限を超えた分はlength - MAXの件数とともに一覧の外に案内として積まれ、黙って切り捨てられません。

web ファイル アップロード ドラッグ アンド ドロップでサーバーへの送信は必要ですか

いいえ、この9種には送信のコードが一切ありません。fetchもXMLHttpRequestも書かず、種類・サイズ・件数の計算とプレビュー・進捗・拒否判定までをすべてブラウザ内で完結させています。実際の送信を足す場合は、このintakeの後に置くのが安全です。

パスワードzipのコードはこの記事の9項目とそのまま同じですか

はい、同じです。vanillaフォルダのindex.htmlが記事内のコードブロックと同じ関数名・同じ定数(MAX・DUR・CANCEL_AT・BOUNCE_MSなど)で書かれています。

もっと読む

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。