diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 68828898..673574e1 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -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, + * 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 diff --git a/static/js/features/files/batchToolbar.js b/static/js/features/files/batchToolbar.js index 99aadc1a..07c123cc 100644 --- a/static/js/features/files/batchToolbar.js +++ b/static/js/features/files/batchToolbar.js @@ -15,6 +15,7 @@ import { loadFiles } from '../../app/filesView.js'; import { app } from '../../app/state.js'; import { showConfirmDialog, ui } from '../../app/ui.js'; import { i18n } from '../../core/i18n.js'; +import { buildBatchDownloadUrl, triggerBrowserDownload } from '../../utils/download.js'; import { favorites } from '../library/favorites.js'; import { contextMenus } from './contextMenus.js'; import { getAuthHeaders } from './fileOperations.js'; @@ -126,10 +127,14 @@ const batchToolbar = { clear() { this._selected.clear(); this._lastClickedIndex = -1; - document.querySelectorAll('.file-item.selected').forEach((el) => { + // Scope the DOM sweep to the files list — the only container this + // toolbar manages (see `selectAll`) — instead of the whole document, + // and only touch checkboxes that are actually checked. + const list = document.getElementById('files-list'); + list?.querySelectorAll('.file-item.selected').forEach((el) => { el.classList.remove('selected'); }); - document.querySelectorAll('.item-checkbox').forEach((cb) => { + list?.querySelectorAll('.item-checkbox:checked').forEach((cb) => { /** @type {HTMLInputElement} */ (cb).checked = false; }); // Reset the active component's internal selection state without going @@ -173,15 +178,16 @@ const batchToolbar = { /** @type {Array} */ const folderIds = []; - // TODO optimize & check if _selected is a better use - /** @type {NodeListOf} */ (document.querySelectorAll(`div.file-item.selected`)).forEach((item) => { - if (item.dataset.fileId) { - fileIds.push(item.dataset.fileId); - } else { - // ignore selectedItem if this is the target - if (targtFolderId && targtFolderId !== item.dataset.folderId) folderIds.push(item.dataset.folderId); + // `_selected` is the source of truth (every selection path keeps it + // in sync) — no need to re-derive the selection from a DOM scan. + for (const sel of this._selected.values()) { + if (sel.type === 'file') { + fileIds.push(sel.id); + } else if (targtFolderId && targtFolderId !== sel.id) { + // ignore the selected folder if it is the drop target itself + folderIds.push(sel.id); } - }); + } return { fileIds: fileIds, @@ -454,10 +460,22 @@ const batchToolbar = { ui.showNotification('Preparing download', 'Creating ZIP archive...'); - try { - const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); - const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); + const fileIds = items.filter((i) => i.type === 'file').map((i) => i.id); + const folderIds = items.filter((i) => i.type === 'folder').map((i) => i.id); + const zipName = `oxicloud-download-${Date.now()}.zip`; + // Browser-native download via the GET variant of the endpoint: the + // ZIP streams to disk instead of being buffered whole in the tab's + // memory (a multi-GB selection used to risk crashing the tab). + const url = buildBatchDownloadUrl(fileIds, folderIds); + if (url.length <= 4000) { + triggerBrowserDownload(url, zipName); + return; + } + + // Selections too large for a URL (~100+ items) keep the buffered + // POST path — the id list only fits in a request body. + try { const response = await fetch('/api/batch/download', { method: 'POST', headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, @@ -467,14 +485,14 @@ const batchToolbar = { if (!response.ok) throw new Error(`Server returned ${response.status}`); const blob = await response.blob(); - const url = URL.createObjectURL(blob); + const blobUrl = URL.createObjectURL(blob); const link = document.createElement('a'); - link.href = url; - link.download = `oxicloud-download-${Date.now()}.zip`; + link.href = blobUrl; + link.download = zipName; document.body.appendChild(link); link.click(); document.body.removeChild(link); - URL.revokeObjectURL(url); + URL.revokeObjectURL(blobUrl); } catch (e) { console.error('Batch download error:', e); ui.showNotification('Error', 'Could not download selected items'); diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index de7f5251..1a26584d 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -10,6 +10,7 @@ import { showConfirmDialog, ui } from '../../app/ui.js'; import { getCsrfHeaders, getCsrfToken } from '../../core/csrf.js'; import { i18n } from '../../core/i18n.js'; import { notifications } from '../../core/notifications.js'; +import { triggerBrowserDownload } from '../../utils/download.js'; /** * @typedef {Object} BatchResult @@ -1369,32 +1370,13 @@ const fileOps = { }, /** - * Download a file + * Download a file — handed to the browser so it streams to disk with + * its native download UI instead of buffering the file in memory. * @param {string} fileId - File ID * @param {string} fileName - File name */ async downloadFile(fileId, fileName) { - try { - const response = await fetch(`/api/files/${fileId}`, { - headers: getAuthHeaders() - }); - if (response.ok) { - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } else { - ui.showNotification('Error', 'Error downloading the file'); - } - } catch (error) { - console.error('Error downloading file:', error); - ui.showNotification('Error', 'Error downloading the file'); - } + triggerBrowserDownload(`/api/files/${fileId}`, fileName); }, /** @@ -1403,30 +1385,10 @@ const fileOps = { * @param {string} folderName - Folder name */ async downloadFolder(folderId, folderName) { - try { - // Show notification to user - ui.showNotification('Preparing download', 'Preparing the folder for download...'); - - const response = await fetch(`/api/folders/${folderId}/download?format=zip`, { - headers: getAuthHeaders() - }); - if (response.ok) { - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = `${folderName}.zip`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - } else { - ui.showNotification('Error', 'Error downloading the folder'); - } - } catch (error) { - console.error('Error downloading folder:', error); - ui.showNotification('Error', 'Error downloading the folder'); - } + // Show notification to user (the server still has to assemble the + // ZIP before the browser's own download UI takes over). + ui.showNotification('Preparing download', 'Preparing the folder for download...'); + triggerBrowserDownload(`/api/folders/${folderId}/download?format=zip`, `${folderName}.zip`); } }; diff --git a/static/js/features/files/inlineViewer.js b/static/js/features/files/inlineViewer.js index c4b14c74..e97133c3 100644 --- a/static/js/features/files/inlineViewer.js +++ b/static/js/features/files/inlineViewer.js @@ -6,6 +6,7 @@ import { updateHistory } from '../../app/main.js'; import { app } from '../../app/state.js'; import { isTextViewable } from '../../core/formatters.js'; +import { triggerBrowserDownload } from '../../utils/download.js'; import { wopiEditor } from './wopiEditor.js'; /** @import {FileItem} from '../../core/types.js' */ @@ -397,111 +398,92 @@ class InlineViewer { } /** - * Creates an audio or video player using blob URL (authenticated fetch) + * Creates an audio or video player that streams straight from the API. + * The element's `src` points at the same-origin endpoint (cookies are + * sent automatically), so the browser issues Range requests and starts + * playback progressively — the file is never materialized in memory, + * and seeking works without downloading everything first. * @param {FileItem} file * @param {string} mediaType * @param {HTMLDivElement} container * @param {HTMLDivElement} loader */ - async createMediaViewer(file, mediaType, container, loader) { - try { - console.log(`Creating ${mediaType} player for:`, file.name); + createMediaViewer(file, mediaType, container, loader) { + console.log(`Creating ${mediaType} player for:`, file.name); - // Fetch file (cookie auto-sent) - const response = await fetch(`/api/files/${file.id}?inline=true`, { - credentials: 'same-origin' - }); + const streamUrl = `/api/files/${file.id}?inline=true`; - if (!response.ok) { - throw new Error(`Error fetching file: ${response.status} ${response.statusText}`); - } + // The native player has its own buffering UI — drop our spinner now. + if (loader?.parentNode) { + loader.parentNode.removeChild(loader); + } - const blob = await response.blob(); - const blobUrl = URL.createObjectURL(blob); + if (mediaType === 'audio') { + // Wrapper with icon + player + const wrapper = document.createElement('div'); + wrapper.className = 'inline-viewer-audio-wrapper'; - // Remove loader - if (loader?.parentNode) { - loader.parentNode.removeChild(loader); - } + const icon = document.createElement('div'); + icon.className = 'inline-viewer-audio-icon'; + icon.innerHTML = ''; + wrapper.appendChild(icon); - if (mediaType === 'audio') { - // Wrapper with icon + player - const wrapper = document.createElement('div'); - wrapper.className = 'inline-viewer-audio-wrapper'; + const nameEl = document.createElement('div'); + nameEl.className = 'inline-viewer-audio-name'; + nameEl.textContent = file.name; + wrapper.appendChild(nameEl); - const icon = document.createElement('div'); - icon.className = 'inline-viewer-audio-icon'; - icon.innerHTML = ''; - wrapper.appendChild(icon); + const audio = document.createElement('audio'); + audio.className = 'inline-viewer-audio'; + audio.controls = true; + audio.preload = 'metadata'; + audio.src = streamUrl; + wrapper.appendChild(audio); - const nameEl = document.createElement('div'); - nameEl.className = 'inline-viewer-audio-name'; - nameEl.textContent = file.name; - wrapper.appendChild(nameEl); - - const audio = document.createElement('audio'); - audio.className = 'inline-viewer-audio'; - audio.controls = true; - audio.preload = 'metadata'; - audio.src = blobUrl; - wrapper.appendChild(audio); - - // Fallback message for unsupported codecs - audio.addEventListener('error', () => { - console.warn('Audio playback error — codec may not be supported'); - wrapper.innerHTML = ''; - const msg = document.createElement('div'); - msg.className = 'inline-viewer-message'; - msg.innerHTML = ` + // Fallback message for unsupported codecs / failed loads + audio.addEventListener('error', () => { + console.warn('Audio playback error — codec may not be supported'); + wrapper.innerHTML = ''; + const msg = document.createElement('div'); + msg.className = 'inline-viewer-message'; + msg.innerHTML = `

Your browser cannot play this audio format.

Click "Download" to save the file.

`; - wrapper.appendChild(msg); - }); + wrapper.appendChild(msg); + }); - container.appendChild(wrapper); - } else { - const video = document.createElement('video'); - video.className = 'inline-viewer-video'; - video.controls = true; - video.preload = 'metadata'; - video.src = blobUrl; - video.setAttribute('playsinline', 'true'); + container.appendChild(wrapper); + } else { + const video = document.createElement('video'); + video.className = 'inline-viewer-video'; + video.controls = true; + video.preload = 'metadata'; + video.src = streamUrl; + video.setAttribute('playsinline', 'true'); - // Fallback message for unsupported codecs - video.addEventListener('error', () => { - console.warn('Video playback error — codec may not be supported'); - if (video.parentNode) { - video.parentNode.removeChild(video); - } - const msg = document.createElement('div'); - msg.className = 'inline-viewer-message'; - msg.innerHTML = ` + // Fallback message for unsupported codecs / failed loads + video.addEventListener('error', () => { + console.warn('Video playback error — codec may not be supported'); + if (video.parentNode) { + video.parentNode.removeChild(video); + } + const msg = document.createElement('div'); + msg.className = 'inline-viewer-message'; + msg.innerHTML = `

Your browser cannot play this video format.

Click "Download" to save the file.

`; - container.appendChild(msg); - }); + container.appendChild(msg); + }); - container.appendChild(video); - } - - // Store blob URL for cleanup on close - this.currentBlobUrl = blobUrl; - } catch (error) { - console.error(`Error creating ${mediaType} viewer:`, error); - - if (loader?.parentNode) { - loader.parentNode.removeChild(loader); - } - - this.showErrorMessage(container); + container.appendChild(video); } } @@ -549,26 +531,12 @@ class InlineViewer { } /** - * + * Download the file via a browser-native download (streams to disk, + * nothing is buffered in page memory). * @param {FileItem} file */ downloadFile(file) { - fetch(`/api/files/${file.id}`, { credentials: 'same-origin' }) - .then((res) => { - if (!res.ok) throw new Error(`HTTP ${res.status}`); - return res.blob(); - }) - .then((blob) => { - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - }) - .catch((err) => console.error('Download error:', err)); + triggerBrowserDownload(`/api/files/${file.id}`, file.name); } /** diff --git a/static/js/utils/download.js b/static/js/utils/download.js new file mode 100644 index 00000000..5fa7ad17 --- /dev/null +++ b/static/js/utils/download.js @@ -0,0 +1,44 @@ +// @ts-check + +/** + * Browser-native download helpers. + * + * Downloads are handed to the browser as same-origin navigations: the + * response streams straight to disk with the browser's own progress UI, + * and auth cookies travel automatically. Nothing is buffered in page + * memory — unlike the old `fetch → blob → objectURL` pattern, which + * materialized the entire payload in the tab's heap before the save + * dialog could even appear. + */ + +/** + * Trigger a browser-native download for a same-origin URL. + * + * @param {string} url - Same-origin URL of the resource to download + * @param {string} [filename] - Suggested file name. The server's + * `Content-Disposition` filename wins when present; an empty string + * keeps whatever the server (or URL) provides. + */ +export function triggerBrowserDownload(url, filename = '') { + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +/** + * Build the GET URL for the batch ZIP download endpoint + * (`GET /api/batch/download` accepts comma-separated id lists). + * Shared by the batch toolbar download and the drag-out `DownloadURL` + * builder so both stay in sync with the endpoint's query contract. + * + * @param {string[]} fileIds + * @param {string[]} folderIds + * @returns {string} Root-relative URL (prepend `window.location.origin` + * when an absolute URL is required, e.g. for `DataTransfer.setData`). + */ +export function buildBatchDownloadUrl(fileIds, folderIds) { + return `/api/batch/download?file_ids=${fileIds.join(',')}&folder_ids=${folderIds.join(',')}`; +}