PageSourceSearch

https://smrtbld.ai/js/components/FileViewer.js

js smrtbld.ai collected 2026-10-05 15:11:01 UTC 23,397 bytes, 581 lines download raw bytes

1/**
2 * FileViewer — Microsoft 365-style full-screen file preview overlay
3 *
4 * Renders images, PDFs, and Office docs inline. Supports optional info bar
5 * (origin context) and extra toolbar actions (Link To, Calendar, etc.).
6 *
7 * Ownership contract:
8 *   - ALL visible DOM (overlay, header/info bar, toolbar actions, grouped
9 *     menus, loading, image/PDF/Office media, fallback) is owned by
10 *     templates/components/file-viewer-templates.html. This module creates
11 *     only the neutral #fv-root mount and binds behavior to rendered nodes.
12 *   - Focus trap, app/modal isolation, stacking, Escape, counted body
13 *     scroll lock, and exact return focus are owned by
14 *     createModalInteractionOwner() from utils/modal.js. The overlay mounts
15 *     inside the shared #app-modals container so stacking follows DOM order
16 *     exactly like showModal() surfaces (a task modal below the viewer, a
17 *     later task-link modal above it).
18 *   - open()/close()/destroy() share one monotonic current-instance owner:
19 *     a close, owner-signal abort, or superseding open makes every pending
20 *     template/media/download/action callback of the old instance silent.
21 *
22 * Usage:
23 *   import { FileViewer } from '../components/FileViewer.js';
24 *
25 *   // Basic
26 *   FileViewer.open({ url, contentType, fileName });
27 *
28 *   // Full-featured (messages)
29 *   FileViewer.open({
30 *       url, contentType, fileName,
31 *       fileIcon: 'file-text',
32 *       infoBar: { icon: 'reply-all', label: 'Reply #2', date: 'Jan 15', detail: 'From: John' },
33 *       toolbarActions: [
34 *           { id: 'link-to', icon: 'link', label: 'Link To', items: [
35 *               { id: 'folder', label: 'Project Folder' },
36 *               { id: 'photo', label: 'Progress Photo' },
37 *           ]},
38 *           { id: 'ics', icon: 'calendar-check', label: 'Calendar' },
39 *       ],
40 *       onAction: (actionId) => { ... },
41 *       signal: pageSignal,   // optional owner signal — abort closes the viewer
42 *   });
43 */
44
45import { createTemplateLoader } from '../utils/templates.js';
46import { downloadBlob } from '../utils/download.js';
47import { createModalInteractionOwner } from '../utils/modal.js';
48import { afterRender } from '../utils/dom.js';
49
50// Shared with showModal() so viewer/modal stacking follows DOM append order.
51const OVERLAY_CONTAINER_ID = 'app-modals';
52const CSV_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
53const CSV_PREVIEW_MAX_ROWS = 1000;
54const CSV_PREVIEW_MAX_COLUMNS = 100;
55
56// ── State — one monotonic current-instance owner ─────────────────────
57
58let openSequence = 0;
59let current = null;
60
61// ── File Type Detection ──────────────────────────────────────────────
62
63function normalizeContentType(contentType) {
64    return (contentType || '').split(';', 1)[0].trim().toLowerCase();
65}
66
67function isImage(contentType, fileName) {
68    return normalizeContentType(contentType).startsWith('image/') ||
69        /\.(?:jpe?g|png|gif|webp|svg|bmp|ico)$/i.test(fileName || '');
70}
71
72function isPdf(contentType, fileName) {
73    return normalizeContentType(contentType) === 'application/pdf' || /\.pdf$/i.test(fileName || '');
74}
75
76function isCsv(contentType, fileName) {
77    return /(?:^|\/)csv$/i.test(normalizeContentType(contentType)) || /\.csv$/i.test(fileName || '');
78}
79
80function isOfficeDoc(contentType, fileName) {
81    const normalizedType = normalizeContentType(contentType);
82    if (
83        normalizedType.includes('word') || normalizedType.includes('excel') ||
84        normalizedType.includes('spreadsheet') || normalizedType.includes('powerpoint') ||
85        normalizedType.includes('presentation')
86    ) return true;
87    return /\.(docx?|xlsx?|pptx?)$/i.test(fileName || '');
88}
89
90function canPreview({ contentType = '', fileName = '' } = {}) {
91    return isImage(contentType, fileName) || isPdf(contentType, fileName) ||
92        isCsv(contentType, fileName) || isOfficeDoc(contentType, fileName);
93}
94
95function getOfficeOnlineUrl(blobUrl) {
96    return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(blobUrl)}`;
97}
98
99// ── File Icon by Extension ───────────────────────────────────────────
100
101function getFileIconName(fileName) {
102    const ext = (fileName || '').split('.').pop()?.toLowerCase();
103    const map = {
104        pdf: 'file-text',
105        doc: 'file-text', docx: 'file-text', odt: 'file-text', rtf: 'file-text', txt: 'file-text',
106        xls: 'file-spreadsheet', xlsx: 'file-spreadsheet', csv: 'file-spreadsheet', ods: 'file-spreadsheet',
107        ppt: 'presentation', pptx: 'presentation', odp: 'presentation',
108        zip: 'file-archive', rar: 'file-archive', '7z': 'file-archive', tar: 'file-archive', gz: 'file-archive',
109        mp3: 'file-audio', wav: 'file-audio', ogg: 'file-audio', flac: 'file-audio', m4a: 'file-audio',
110        mp4: 'file-video', avi: 'file-video', mov: 'file-video', wmv: 'file-video', mkv: 'file-video', webm: 'file-video',
111        jpg: 'image', jpeg: 'image', png: 'image', gif: 'image', webp: 'image', svg: 'image', bmp: 'image', ico: 'image',
112    };
113    return map[ext] || 'file';
114}
115
116// ── Template Loading ─────────────────────────────────────────────────
117
118const ensureTemplates = createTemplateLoader('components/file-viewer-templates');
119
120function ensureOverlayContainer() {
121    let container = document.getElementById(OVERLAY_CONTAINER_ID);
122    if (!container) {
123        container = document.createElement('div');
124        container.id = OVERLAY_CONTAINER_ID;
125        document.body.appendChild(container);
126    }
127    return container;
128}
129
130// ── Rendering Paths (behavior bound to template-rendered nodes) ──────
131
132function hideSpinner(inst) {
133    inst.rootEl.querySelector('#fv-spinner')?.remove();
134}
135
136function renderMedia(inst, contentEl, vm) {
137    contentEl.innerHTML = $.templates('#tpl-file-viewer-content').render(vm);
138    afterRender(contentEl);
139}
140
141function renderFallback(inst) {
142    if (current !== inst) return;
143    const contentEl = inst.rootEl.querySelector('#fv-content');
144    if (!contentEl) return;
145    contentEl.innerHTML = $.templates('#tpl-file-viewer-fallback').render({});
146    afterRender(contentEl);
147}
148
149function parseCsv(text) {
150    const rows = [];
151    let row = [];
152    let field = '';
153    let quoted = false;
154    let columnLimited = false;
155
156    const pushField = () => {
157        if (row.length < CSV_PREVIEW_MAX_COLUMNS) row.push(field);
158        else columnLimited = true;
159        field = '';
160    };
161
162    const pushRow = () => {
163        pushField();
164        rows.push(row);
165        row = [];
166        return rows.length > CSV_PREVIEW_MAX_ROWS + 1;
167    };
168
169    for (let index = 0; index < text.length; index += 1) {
170        const char = text[index];
171        if (quoted) {
172            if (char === '"' && text[index + 1] === '"') {
173                field += '"';
174                index += 1;
175            } else if (char === '"') {
176                quoted = false;
177            } else {
178                field += char;
179            }
180        } else if (char === '"' && field.length === 0) {
181            quoted = true;
182        } else if (char === ',') {
183            pushField();
184        } else if (char === '\n' || char === '\r') {
185            if (char === '\r' && text[index + 1] === '\n') index += 1;
186            if (pushRow()) return { rows, rowLimited: true, columnLimited };
187        } else {
188            field += char;
189        }
190    }
191
192    if (field.length > 0 || row.length > 0) {
193        pushRow();
194    }
195    return { rows, rowLimited: rows.length > CSV_PREVIEW_MAX_ROWS + 1, columnLimited };
196}
197
198async function readCsvPreview(response) {
199    const reader = response.body?.getReader();
200    if (!reader) {
201        const source = await response.text();
202        const encoded = new TextEncoder().encode(source);
203        return {
204            source: new TextDecoder().decode(encoded.subarray(0, CSV_PREVIEW_MAX_BYTES)),
205            byteLimited: encoded.length > CSV_PREVIEW_MAX_BYTES,
206        };
207    }
208
209    const decoder = new TextDecoder();
210    let source = '';
211    let bytesRead = 0;
212    let byteLimited = false;
213    while (true) {
214        const { value, done } = await reader.read();
215        if (done) break;
216        const remaining = CSV_PREVIEW_MAX_BYTES - bytesRead;
217        if (remaining <= 0) {
218            byteLimited = true;
219            await reader.cancel();
220            break;
221        }
222        const chunk = value.subarray(0, remaining);
223        source += decoder.decode(chunk, { stream: true });
224        bytesRead += chunk.length;
225        if (chunk.length < value.length) {
226            byteLimited = true;
227            await reader.cancel();
228            break;
229        }
230    }
231    source += decoder.decode();
232    return { source, byteLimited };
233}
234
235async function renderCsv(inst) {
236    const contentEl = inst.rootEl.querySelector('#fv-content');
237    if (!contentEl) return;
238    renderMedia(inst, contentEl, { showSpinner: true });
239
240    try {
241        const response = await fetch(inst.state.url, { signal: inst.workController.signal });
242        if (!response.ok) throw new Error(`HTTP ${response.status}`);
243        const { source, byteLimited } = await readCsvPreview(response);
244        if (current !== inst) return;
245
246        const parsed = parseCsv(source.replace(/^\uFEFF/, ''));
247        const parsedRows = parsed.rows;
248        const headerRow = parsedRows.shift() || [];
249        let widestRow = headerRow.length;
250        parsedRows.forEach((csvRow) => { widestRow = Math.max(widestRow, csvRow.length); });
251        const columnCount = Math.min(Math.max(widestRow, 1), CSV_PREVIEW_MAX_COLUMNS);
252        const headers = Array.from({ length: columnCount }, (_, index) => ({
253            value: headerRow[index]?.trim() || `Column ${index + 1}`,
254        }));
255        const rows = parsedRows.slice(0, CSV_PREVIEW_MAX_ROWS).map((csvRow) => ({
256            cells: Array.from({ length: columnCount }, (_, index) => ({ value: csvRow[index] || '' })),
257        }));
258        const truncated = byteLimited || parsed.rowLimited || parsed.columnLimited;
259
260        contentEl.innerHTML = $.templates('#tpl-file-viewer-csv').render({
261            fileName: inst.state.fileName || 'CSV file',
262            headers,
263            rows,
264            rowCount: rows.length,
265            truncated,
266        });
267        afterRender(contentEl);
268    } catch (error) {
269        if (error?.name === 'AbortError' || current !== inst) return;
270        console.error('[FileViewer] CSV preview failed:', error);
271        renderFallback(inst);
272    }
273}
274
275function renderContent(inst) {
276    const contentEl = inst.rootEl.querySelector('#fv-content');
277    if (!contentEl) return;
278    const { url, contentType, fileName } = inst.state;
279
280    if (isImage(contentType, fileName)) {
281        renderMedia(inst, contentEl, { isImage: true, src: url, alt: fileName || 'Image preview', showSpinner: true });
282        const img = contentEl.querySelector('#fv-media');
283        img?.addEventListener('load', () => { if (current === inst) hideSpinner(inst); });
284        img?.addEventListener('error', () => { if (current === inst) renderFallback(inst); });
285    } else if (isPdf(contentType, fileName)) {
286        const pdfUrl = url + (url.includes('#') ? '&toolbar=0' : '#toolbar=0');
287        renderMedia(inst, contentEl, { isPdf: true, src: pdfUrl, showSpinner: false });
288    } else if (isCsv(contentType, fileName)) {
289        void renderCsv(inst);
290    } else if (isOfficeDoc(contentType, fileName)) {
291        renderMedia(inst, contentEl, { isOffice: true, src: getOfficeOnlineUrl(url), showSpinner: true });
292        const iframe = contentEl.querySelector('#fv-media');
293        iframe?.addEventListener('load', () => {
294            if (current !== inst) return;
295            inst.officeLoadCount += 1;
296            hideSpinner(inst);
297            // Parity: a second load means Office Online bounced to an error page.
298            if (inst.officeLoadCount >= 2) renderFallback(inst);
299        });
300    } else {
301        renderFallback(inst);
302    }
303}
304
305// ── Download / New Tab ───────────────────────────────────────────────
306
307async function triggerDownload(inst) {
308    const url = inst.state.downloadUrl || inst.state.url;
309    if (!url) return;
310    try {
311        const resp = await fetch(url, { signal: inst.workController.signal });
312        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
313        const blob = await resp.blob();
314        if (current !== inst) return; // superseded/closed — stay silent
315        downloadBlob(blob, inst.state.fileName || 'download');
316    } catch (error) {
317        // Cancelled or stale work never opens the fallback tab.
318        if (error?.name === 'AbortError' || current !== inst) return;
319        window.open(url, '_blank', 'noopener');
320    }
321}
322
323function openInNewTab(inst) {
324    const { url, contentType, fileName } = inst.state;
325    const viewerUrl = !isCsv(contentType, fileName) && isOfficeDoc(contentType, fileName) ? getOfficeOnlineUrl(url) : url;
326    window.open(viewerUrl, '_blank', 'noopener');
327}
328
329// ── Toolbar Menus (template-owned DOM, delegated behavior) ───────────
330
331function openMenu(inst, triggerBtn) {
332    if (inst.openMenu) closeMenu(inst);
333    const menuEl = document.getElementById(triggerBtn.getAttribute('aria-controls') || '');
334    if (!menuEl) return;
335
336    menuEl.classList.remove('hidden');
337    triggerBtn.setAttribute('aria-expanded', 'true');
338
339    const items = [...menuEl.querySelectorAll('[role="menuitem"]')];
340    const keyHandler = (event) => {
341        const currentIndex = items.indexOf(document.activeElement);
342        if (event.key === 'Escape') {
343            event.preventDefault();
344            event.stopPropagation();
345            closeMenu(inst, { restoreFocus: true });
346        } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
347            event.preventDefault();
348            event.stopPropagation();
349            const delta = event.key === 'ArrowDown' ? 1 : -1;
350            if (currentIndex === -1) {
351                // Focus is not on a menuitem yet: Down enters at the first item, Up at the last.
352                items[delta === 1 ? 0 : items.length - 1]?.focus();
353                return;
354            }
355            items[(currentIndex + delta + items.length) % items.length]?.focus();
356        } else if (event.key === 'Home' || event.key === 'End') {
357            event.preventDefault();
358            event.stopPropagation();
359            items[event.key === 'Home' ? 0 : items.length - 1]?.focus();
360        } else if (event.key === 'Tab') {
361            // Deterministic continuation: return focus to the trigger and let
362            // the dialog's own Tab ownership continue from there.
363            closeMenu(inst, { restoreFocus: true });
364        }
365    };
366    menuEl.addEventListener('keydown', keyHandler);
367
368    // The menu stays inside the named dialog (no body portal): a body-portaled
369    // z-dropdown surface would paint under the z-modal viewer, and the dialog's
370    // interaction owner already isolates everything beneath it. On phones the
371    // shared .popover-st media query presents this same node as a bottom sheet;
372    // outside tap and Escape remain the dismissal owners.
373    inst.openMenu = { trigger: triggerBtn, menuEl, keyHandler };
374    items[0]?.focus();
375}
376
377function closeMenu(inst, { restoreFocus = false } = {}) {
378    const open = inst.openMenu;
379    if (!open) return;
380    inst.openMenu = null;
381    const focusWasInside = open.menuEl.contains(document.activeElement);
382    open.menuEl.removeEventListener('keydown', open.keyHandler);
383    open.menuEl.classList.add('hidden');
384    open.trigger.setAttribute('aria-expanded', 'false');
385    if ((restoreFocus || focusWasInside) && open.trigger.isConnected) open.trigger.focus();
386}
387
388// ── Delegated Actions ────────────────────────────────────────────────
389
390function dispatchAction(inst, actionId) {
391    if (current !== inst) return; // stale instance never dispatches
392    inst.onAction?.(actionId);
393}
394
395function handleRootClick(event, inst) {
396    if (current !== inst) return;
397
398    const menuTrigger = event.target.closest('[data-fv-menu-trigger]');
399    const openMenuEl = inst.openMenu?.menuEl;
400
401    // A click outside the open menu and its own trigger closes the menu first.
402    if (openMenuEl && !openMenuEl.contains(event.target) && inst.openMenu.trigger !== menuTrigger) {
403        closeMenu(inst);
404    }
405
406    if (menuTrigger) {
407        if (inst.openMenu?.trigger === menuTrigger) closeMenu(inst);
408        else openMenu(inst, menuTrigger);
409        return;
410    }
411
412    const menuItem = event.target.closest('[data-fv-item-id]');
413    if (menuItem) {
414        closeMenu(inst);
415        dispatchAction(inst, menuItem.dataset.fvItemId);
416        return;
417    }
418
419    const customAction = event.target.closest('[data-fv-action-id]');
420    if (customAction) {
421        dispatchAction(inst, customAction.dataset.fvActionId);
422        return;
423    }
424
425    const standard = event.target.closest('[data-fv-action]');
426    if (!standard) return;
427    const action = standard.dataset.fvAction;
428    if (action === 'close') close();
429    else if (action === 'download' || action === 'fallback-download') triggerDownload(inst);
430    else if (action === 'newtab') openInNewTab(inst);
431}
432
433// ── Public API ───────────────────────────────────────────────────────
434
435/**
436 * Open the file viewer overlay.
437 * @param {Object} options
438 * @param {string} options.url - File URL (required)
439 * @param {string} options.contentType - MIME type (required)
440 * @param {string} options.fileName - Display name (required)
441 * @param {string} [options.downloadUrl] - Separate download URL
442 * @param {string} [options.fileIcon] - Lucide icon name override
443 * @param {Object} [options.infoBar] - Optional info bar below header
444 * @param {string} [options.infoBar.icon] - Lucide icon name
445 * @param {string} [options.infoBar.label] - Bold label text
446 * @param {string} [options.infoBar.date] - Date text
447 * @param {string} [options.infoBar.detail] - Detail text (e.g. "From: John")
448 * @param {Array}  [options.toolbarActions] - Extra toolbar buttons
449 * @param {Function} [options.onAction] - Callback when extra action clicked: (actionId) => void
450 * @param {AbortSignal} [options.signal] - Optional owner signal; abort closes the viewer
451 */
452async function open(options = {}) {
453    const { url, contentType, fileName, downloadUrl, fileIcon, infoBar, toolbarActions, onAction, signal } = options;
454    if (!url || signal?.aborted) return;
455
456    const triggerEl = document.activeElement instanceof HTMLElement ? document.activeElement : null;
457
458    // A superseding open closes the current instance immediately and
459    // invalidates any earlier open() still awaiting templates; the ticket is
460    // claimed AFTER that bump so this open stays current.
461    close();
462    const instanceId = ++openSequence;
463
464    await ensureTemplates();
465    if (instanceId !== openSequence || signal?.aborted) return; // superseded or cancelled during template load
466
467    let overlayTmpl;
468    try {
469        overlayTmpl = $.templates('#tpl-file-viewer-overlay');
470        if (!overlayTmpl) throw new Error('overlay template missing');
471    } catch (error) {
472        console.error('[FileViewer] overlay template unavailable:', error);
473        return;
474    }
475
476    // View model — caller-supplied strings render through the encoded {{>}} form.
477    const actions = (toolbarActions || []).map((action, index) => {
478        let lastGroup = null;
479        return {
480            id: String(action.id),
481            icon: action.icon || 'circle',
482            label: action.label || '',
483            hasItems: Array.isArray(action.items) && action.items.length > 0,
484            menuId: `fv-menu-${instanceId}-${index}`,
485            items: (action.items || []).map((item) => {
486                const showDivider = !!(item.group && item.group !== lastGroup && lastGroup !== null);
487                lastGroup = item.group || lastGroup;
488                return { id: String(item.id), label: item.label || '', icon: item.icon || '', showDivider };
489            }),
490        };
491    });
492
493    const vm = {
494        fileName: fileName || 'File Preview',
495        fileIcon: fileIcon || getFileIconName(fileName),
496        infoBar: infoBar ? {
497            icon: infoBar.icon || 'mail',
498            label: infoBar.label || '',
499            date: infoBar.date || '',
500            detail: infoBar.detail || '',
501        } : null,
502        actions,
503        hasExtraActions: actions.length > 0,
504    };
505
506    // Neutral mount root — the only JS-created node.
507    const container = ensureOverlayContainer();
508    const rootEl = document.createElement('div');
509    rootEl.id = 'fv-root';
510    rootEl.innerHTML = overlayTmpl.render(vm);
511    container.appendChild(rootEl);
512    afterRender(rootEl);
513
514    const inst = {
515        id: instanceId,
516        rootEl,
517        state: { url, contentType, fileName, downloadUrl: downloadUrl || url },
518        onAction: onAction || null,
519        ownedUrls: new Set(),
520        workController: new AbortController(),
521        officeLoadCount: 0,
522        openMenu: null,
523        owner: null,
524        signal: signal || null,
525        signalListener: null,
526    };
527    // The caller-created transient blob: preview is viewer-owned for cleanup;
528    // ordinary API/SAS/external URLs are never revoked.
529    if (url.startsWith('blob:')) inst.ownedUrls.add(url);
530    if (downloadUrl?.startsWith('blob:')) inst.ownedUrls.add(downloadUrl);
531
532    current = inst;
533
534    inst.owner = createModalInteractionOwner({
535        element: rootEl,
536        trigger: triggerEl,
537        onEscape: () => { if (current === inst) close(); },
538    });
539
540    rootEl.addEventListener('click', (event) => handleRootClick(event, inst));
541
542    renderContent(inst);
543
544    if (signal) {
545        inst.signalListener = () => { if (current === inst) close(); };
546        signal.addEventListener('abort', inst.signalListener, { once: true });
547    }
548}
549
550/** Close the file viewer overlay. Idempotent; stale callbacks stay silent. */
551function close() {
552    openSequence += 1; // invalidate any open() still awaiting templates
553    const inst = current;
554    if (!inst) return;
555    current = null;
556
557    inst.workController.abort(); // cancels in-flight preview/download fetches
558    if (inst.signal && inst.signalListener) inst.signal.removeEventListener('abort', inst.signalListener);
559    inst.signalListener = null;
560    closeMenu(inst);
561    inst.ownedUrls.forEach((ownedUrl) => URL.revokeObjectURL(ownedUrl));
562    inst.ownedUrls.clear();
563    inst.owner?.destroy(); // unregister surface, release counted scroll lock once, restore exact focus
564    inst.owner = null;
565    inst.rootEl.remove();
566    inst.onAction = null;
567}
568
569/** Destroy the component entirely (for page cleanup). */
570function destroy() {
571    close();
572}
573
574export const FileViewer = {
575    open,
576    close,
577    destroy,
578    canPreview,
579    get isOpen() { return !!current; },
580    get fileName() { return current?.state.fileName; }
581};

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.