perf(files-ui): stream media/downloads natively, rAF rubber-band, Map selection

Four related fixes to stop buffering files in page memory and stop
hammering layout from the selection paths:

- Inline viewer media: video/audio fetched the ENTIRE file into a blob
  before the first frame (a 2 GB video = 2 GB of tab heap, no
  progressive playback, no seek). The <video>/<audio> src now points
  straight at the same-origin API URL — cookies travel automatically
  and the browser streams with native Range requests, exactly like the
  music player already did.

- Downloads (file, folder ZIP, batch ZIP, viewer button): the
  fetch → blob → objectURL pattern materialized the whole payload in
  RAM before the save dialog appeared (a 10 GB batch ZIP risked
  crashing the tab) with no download progress UI. New shared
  utils/download.js hands the URL to the browser, which streams to
  disk with its own progress UI. Batch download uses the existing GET
  endpoint (same URL contract as the drag-out DownloadURL, now shared
  via buildBatchDownloadUrl); selections whose id list cannot fit in a
  URL keep the buffered POST fallback. Trade-off: failed downloads now
  surface in the browser's download shelf instead of an in-app toast.

- Rubber-band lasso: every mousemove (>100/s) walked all cards
  interleaving getBoundingClientRect() reads with class writes — up to
  N forced reflows per event, freezing the frame rate on folders with
  thousands of loaded rows. Card rects (+ item info) are now snapshot
  once per drag (rebuilt on scroll), and a single rAF pass per frame
  compares against the cached geometry, touching only cards whose
  selection state changed.

- batchToolbar: getSelection() rebuilt the selection by scanning every
  .file-item in the document (the TODO admitted it); it now reads the
  _selected Map that every selection path already keeps in sync.
  clear() scopes its DOM sweep to #files-list (the only container the
  toolbar manages) instead of the whole document.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:02:13 +00:00
parent 8d040314a3
commit fd80a3de67
5 changed files with 232 additions and 186 deletions
+82 -28
View File
@@ -14,6 +14,7 @@ import { fileOps } from '../features/files/fileOperations.js';
import { inlineViewer } from '../features/files/inlineViewer.js';
import { wopiEditor } from '../features/files/wopiEditor.js';
import { recent } from '../features/library/recent.js';
import { buildBatchDownloadUrl } from '../utils/download.js';
import { positionMenu } from '../utils/menuPosition.js';
import { loadFiles } from './filesView.js';
import { updateHistory } from './main.js';
@@ -774,7 +775,7 @@ const ui = {
if (item?.type === 'file') fileIds.push(item.id);
else if (item) folderIds.push(item.id);
});
downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`;
downloadUrl = `${window.location.origin}${buildBatchDownloadUrl(fileIds, folderIds)}`;
}
e.dataTransfer.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`);
@@ -1099,6 +1100,69 @@ function initRubberBandSelection() {
let active = false;
let startX = 0,
startY = 0;
let curX = 0,
curY = 0;
let rafId = 0;
/**
* Card geometry snapshot taken once per drag (and rebuilt on scroll).
* Comparing the lasso against these cached rects means the per-frame
* pass performs zero DOM reads — no forced reflow per card.
* @type {Array<{el: HTMLElement, left: number, top: number, right: number,
* bottom: number, info: ReturnType<typeof batchToolbar._extractInfo>,
* selected: boolean}> | null}
*/
let cardCache = null;
const buildCardCache = () => {
cardCache = [];
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const el = /** @type {HTMLElement} */ (card);
const r = el.getBoundingClientRect();
cardCache.push({
el,
left: r.left,
top: r.top,
right: r.right,
bottom: r.bottom,
info: batchToolbar ? batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (el)) : null,
selected: el.classList.contains('selected')
});
});
};
// Scrolling mid-drag shifts every viewport rect — drop the snapshot so
// the next frame rebuilds it.
const invalidateCardCache = () => {
cardCache = null;
};
/** One classification pass per animation frame (cached rects only). */
const classifyCards = () => {
rafId = 0;
if (!cardCache) buildCardCache();
const left = Math.min(startX, curX);
const top = Math.min(startY, curY);
const right = Math.max(startX, curX);
const bottom = Math.max(startY, curY);
for (const entry of cardCache) {
const intersects = entry.left < right && entry.right > left && entry.top < bottom && entry.bottom > top;
if (intersects === entry.selected) continue;
entry.selected = intersects;
entry.el.classList.toggle('selected', intersects);
// Sync with batchToolbar module (only on state change)
if (batchToolbar && entry.info) {
if (intersects) {
batchToolbar.select(entry.info.id, entry.info.name, entry.info.type, entry.info.parentId);
} else {
batchToolbar.deselect(entry.info.id);
}
}
}
};
// We listen on the whole files-container (covers grid + empty space)
const container = document.querySelector('.files-container') || document.getElementById('files-list');
@@ -1123,6 +1187,10 @@ function initRubberBandSelection() {
active = true;
startX = e.clientX;
startY = e.clientY;
curX = startX;
curY = startY;
cardCache = null; // built lazily on the first classification frame
document.addEventListener('scroll', invalidateCardCache, { capture: true, passive: true });
selRect.style.left = `${startX}px`;
selRect.style.top = `${startY}px`;
@@ -1136,8 +1204,8 @@ function initRubberBandSelection() {
document.addEventListener('mousemove', (e) => {
if (!active) return;
const curX = e.clientX;
const curY = e.clientY;
curX = e.clientX;
curY = e.clientY;
const left = Math.min(startX, curX);
const top = Math.min(startY, curY);
@@ -1149,41 +1217,27 @@ function initRubberBandSelection() {
selRect.style.display = 'block';
}
// Style writes only — no layout reads here. The card highlighting
// runs at most once per frame against the cached geometry.
selRect.style.left = `${left}px`;
selRect.style.top = `${top}px`;
selRect.style.width = `${width}px`;
selRect.style.height = `${height}px`;
// Highlight cards that intersect with the rectangle
const rectBounds = { left, top, right: left + width, bottom: top + height };
document.querySelectorAll('#files-list .file-item').forEach((card) => {
const cardRect = card.getBoundingClientRect();
const intersects =
cardRect.left < rectBounds.right && cardRect.right > rectBounds.left && cardRect.top < rectBounds.bottom && cardRect.bottom > rectBounds.top;
if (intersects) {
card.classList.add('selected');
// Sync with batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.select(info.id, info.name, info.type, info.parentId);
}
} else {
card.classList.remove('selected');
// Deselect from batchToolbar module
if (batchToolbar) {
const info = batchToolbar._extractInfo(/** @type {HTMLDivElement} */ (card));
if (info) batchToolbar.deselect(info.id);
}
}
});
if (!rafId) rafId = requestAnimationFrame(classifyCards);
});
document.addEventListener('mouseup', () => {
if (!active) return;
active = false;
document.removeEventListener('scroll', invalidateCardCache, { capture: true });
// Apply the still-pending classification so the final lasso
// position is what determines the selection.
if (rafId) {
cancelAnimationFrame(rafId);
classifyCards();
}
cardCache = null;
const hadSelection = selRect.style.display === 'block';
selRect.style.display = 'none';
// Update the batch bar after rubber band selection completes