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.