import { getErrorMessage } from '@sim/utils/errors' import type { ECharts } from 'echarts' import { isAllowedExternalUrl } from '@/lib/core/security/url-safety' import { buildPresentation, type PresentationData } from '../model/presentation' import type { ZipParseLimits } from '../parser/zip-parser' import { parseZip } from '../parser/zip-parser' import type { SlideHandle } from '../renderer/slide-renderer' import { renderSlide as renderSlideInternal } from '../renderer/slide-renderer' export type { SlideHandle } from '../renderer/slide-renderer' export type FitMode = 'contain' | 'none' export type PreviewInput = ArrayBuffer | Uint8Array | Blob export interface ViewerOptions { width?: number /** Scaling mode. contain = fit container width, none = use intrinsic slide size. */ fitMode?: FitMode /** Initial zoom percentage. Effective scale = fitScale * zoomPercent/100. */ zoomPercent?: number /** * Scroll container element used as IntersectionObserver root in list mode * (both windowed mounting and scroll-based slide tracking). * When omitted, the viewport (null root) is used. */ scrollContainer?: HTMLElement /** Optional ZIP parsing limits for controlling resource usage and DoS surface. */ zipLimits?: ZipParseLimits onSlideChange?: (index: number) => void onSlideRendered?: (index: number, element: HTMLElement) => void onSlideError?: (index: number, error: unknown) => void onSlideUnmounted?: (index: number) => void onNodeError?: (nodeId: string, error: unknown) => void onRenderStart?: () => void onRenderComplete?: () => void } export interface ListRenderOptions { windowed?: boolean batchSize?: number initialSlides?: number overscanViewport?: number /** Show "Slide N" labels below each slide. Default `false`. */ showSlideLabels?: boolean } export interface PptxViewerEventMap { renderstart: Event rendercomplete: Event slidechange: CustomEvent<{ index: number }> sliderendered: CustomEvent<{ index: number; element: HTMLElement }> slideerror: CustomEvent<{ index: number; error: unknown }> slideunmounted: CustomEvent<{ index: number }> nodeerror: CustomEvent<{ nodeId: string; error: unknown }> } export class PptxViewer extends EventTarget { protected container: HTMLElement private viewerOptions: ViewerOptions private presentation: PresentationData | null = null private mediaUrlCache = new Map() private chartInstances = new Set() private currentSlide = 0 private _fitMode: FitMode private _isRendering = false private zoomFactor = 1 private renderChain: Promise = Promise.resolve() private cleanupListMount?: () => void private cleanupScrollObserver?: () => void private suppressScrollChange = false private ensureListSlideMountedFn?: (index: number) => void private resizeObserver?: ResizeObserver private windowResizeHandler?: () => void private resizeRafId: number | null = null private lastMeasuredContainerWidth = 0 private mountedSlides = new Set() private slideHandles = new Map() private activeRenderMode: 'list' | 'slide' | null = null private listOptions: Required = { windowed: false, batchSize: 12, initialSlides: 4, overscanViewport: 1.5, showSlideLabels: false, } constructor(container: HTMLElement, options?: ViewerOptions) { super() this.container = container this.viewerOptions = options ?? {} const zoomPercent = this.normalizeZoomPercent(options?.zoomPercent ?? 100) this._fitMode = options?.fitMode ?? 'contain' this.zoomFactor = zoomPercent / 100 // Register shorthand callbacks as event listeners if (options?.onSlideChange) { const cb = options.onSlideChange this.addEventListener('slidechange', ((e: CustomEvent) => cb(e.detail.index)) as EventListener) } if (options?.onSlideRendered) { const cb = options.onSlideRendered this.addEventListener('sliderendered', ((e: CustomEvent) => cb(e.detail.index, e.detail.element)) as EventListener) } if (options?.onSlideError) { const cb = options.onSlideError this.addEventListener('slideerror', ((e: CustomEvent) => cb(e.detail.index, e.detail.error)) as EventListener) } if (options?.onSlideUnmounted) { const cb = options.onSlideUnmounted this.addEventListener('slideunmounted', ((e: CustomEvent) => cb(e.detail.index)) as EventListener) } if (options?.onNodeError) { const cb = options.onNodeError this.addEventListener('nodeerror', ((e: CustomEvent) => cb(e.detail.nodeId, e.detail.error)) as EventListener) } if (options?.onRenderStart) { const cb = options.onRenderStart this.addEventListener('renderstart', () => cb()) } if (options?.onRenderComplete) { const cb = options.onRenderComplete this.addEventListener('rendercomplete', () => cb()) } } // ----------------------------------------------------------------------- // Event dispatch helpers // ----------------------------------------------------------------------- private emitRenderStart(): void { this._isRendering = true this.dispatchEvent(new Event('renderstart')) } private emitRenderComplete(): void { this._isRendering = false this.dispatchEvent(new Event('rendercomplete')) } private emitSlideChange(index: number): void { this.dispatchEvent(new CustomEvent('slidechange', { detail: { index } })) } private emitSlideRendered(index: number, element: HTMLElement): void { this.dispatchEvent(new CustomEvent('sliderendered', { detail: { index, element } })) } private emitSlideError(index: number, error: unknown): void { this.dispatchEvent(new CustomEvent('slideerror', { detail: { index, error } })) } private emitSlideUnmounted(index: number): void { this.dispatchEvent(new CustomEvent('slideunmounted', { detail: { index } })) } private emitNodeError(nodeId: string, error: unknown): void { this.dispatchEvent(new CustomEvent('nodeerror', { detail: { nodeId, error } })) } // ----------------------------------------------------------------------- // Public: load / render modes // ----------------------------------------------------------------------- /** * Load a parsed presentation model. Does NOT render — call `renderList()` or * `renderSlide()` afterwards. */ load(presentation: PresentationData): void { this.presentation = presentation this.setupAdaptiveResize() } /** * Render all slides in a scrollable list. */ async renderList(options?: ListRenderOptions): Promise { this.activeRenderMode = 'list' this.listOptions = { windowed: options?.windowed ?? false, batchSize: this.normalizeBatchSize(options?.batchSize ?? 12), initialSlides: this.normalizePositiveInt(options?.initialSlides ?? 4, 4), overscanViewport: this.normalizePositiveFloat(options?.overscanViewport ?? 1.5, 1.5), showSlideLabels: options?.showSlideLabels ?? false, } await this.queueRender() } /** * Render a single slide (no built-in nav UI). */ async renderSlide(index?: number): Promise { this.activeRenderMode = 'slide' if (index !== undefined && this.presentation) { this.currentSlide = Math.max(0, Math.min(index, this.presentation.slides.length - 1)) } await this.queueRender() } // ----------------------------------------------------------------------- // Instance open // ----------------------------------------------------------------------- async open( input: PreviewInput, options?: { renderMode?: 'list' | 'slide' listOptions?: ListRenderOptions signal?: AbortSignal } ): Promise { const signal = options?.signal const checkAborted = () => { if (signal?.aborted) { throw new DOMException('Preview aborted', 'AbortError') } } checkAborted() // Clean up previous state this.destroy() const buffer = await normalizePreviewInput(input) checkAborted() const files = await parseZip(buffer, this.viewerOptions.zipLimits) checkAborted() const presentation = buildPresentation(files) checkAborted() this.load(presentation) const renderMode = options?.renderMode ?? 'list' if (renderMode === 'slide') { await this.renderSlide(0) } else { await this.renderList(options?.listOptions) } checkAborted() } // ----------------------------------------------------------------------- // Static factory // ----------------------------------------------------------------------- static async open( input: PreviewInput, container: HTMLElement, options?: ViewerOptions & { renderMode?: 'list' | 'slide' listOptions?: ListRenderOptions signal?: AbortSignal } ): Promise { const viewer = new PptxViewer(container, options) await viewer.open(input, { renderMode: options?.renderMode, listOptions: options?.listOptions, signal: options?.signal, }) return viewer } // ----------------------------------------------------------------------- // Navigation // ----------------------------------------------------------------------- async goToSlide(index: number, scrollOptions?: ScrollIntoViewOptions): Promise { if (!this.presentation) return const prev = this.currentSlide this.currentSlide = Math.max(0, Math.min(index, this.presentation.slides.length - 1)) if (this.currentSlide !== prev) { this.emitSlideChange(this.currentSlide) } if (this.activeRenderMode === 'slide') { const { scale, displayWidth, displayHeight } = this.getDisplayMetrics() this.renderSingleSlide(scale, displayWidth, displayHeight) } else { this.suppressScrollChange = true await new Promise((resolve) => requestAnimationFrame(() => { this.suppressScrollChange = false resolve() }) ) this.ensureListSlideMountedFn?.(this.currentSlide) const targetChild = this.container.querySelector( `[data-slide-index="${this.currentSlide}"]` ) if (targetChild) { targetChild.scrollIntoView(scrollOptions ?? { behavior: 'smooth', block: 'center' }) } } } async setZoom(percent: number): Promise { const normalized = this.normalizeZoomPercent(percent) const nextFactor = normalized / 100 if (nextFactor === this.zoomFactor) return this.zoomFactor = nextFactor await this.queueRender() } async setFitMode(mode: FitMode): Promise { if (this._fitMode === mode) return this._fitMode = mode if (mode === 'none') { this.lastMeasuredContainerWidth = 0 } await this.queueRender() } // ----------------------------------------------------------------------- // Getters // ----------------------------------------------------------------------- get presentationData(): PresentationData | null { return this.presentation } get slideCount(): number { return this.presentation?.slides.length ?? 0 } get slideWidth(): number { return this.presentation?.width ?? 0 } get slideHeight(): number { return this.presentation?.height ?? 0 } get currentSlideIndex(): number { return this.currentSlide } get isRendering(): boolean { return this._isRendering } get zoomPercent(): number { return this.zoomFactor * 100 } get fitMode(): FitMode { return this._fitMode } // ----------------------------------------------------------------------- // Typed event helpers // ----------------------------------------------------------------------- on( type: K, listener: (event: PptxViewerEventMap[K]) => void ): this { this.addEventListener(type, listener as EventListener) return this } off( type: K, listener: (event: PptxViewerEventMap[K]) => void ): this { this.removeEventListener(type, listener as EventListener) return this } isSlideMounted(index: number): boolean { return this.mountedSlides.has(index) } getMountedSlides(): number[] { return [...this.mountedSlides].sort((a, b) => a - b) } // ----------------------------------------------------------------------- // External slide rendering // ----------------------------------------------------------------------- /** * Render a single slide into an external container element. * Useful for React/Vue integration, thumbnail generation, etc. * * **Ownership:** The caller owns the returned {@link SlideHandle} and is * responsible for calling `handle.dispose()` when the slide is no longer * needed. `destroy()` does NOT automatically dispose externally-rendered * handles. */ renderSlideToContainer( index: number, container: HTMLElement, scale?: number ): SlideHandle | null { if (!this.presentation) return null const slide = this.presentation.slides[index] if (!slide) return null const handle = renderSlideInternal(this.presentation, slide, { onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error), onNavigate: (target) => this.handleNavigate(target), mediaUrlCache: this.mediaUrlCache, chartInstances: this.chartInstances, }) if (scale !== undefined && scale !== 1) { handle.element.style.transform = `scale(${scale})` handle.element.style.transformOrigin = 'top left' } container.appendChild(handle.element) this.emitSlideRendered(index, handle.element) return handle } /** * Hook called after rendering a single slide. Override in subclasses to * append additional UI (e.g. navigation buttons). */ protected afterSingleSlideRender(): void { // No-op in base class } // ----------------------------------------------------------------------- // Cleanup // ----------------------------------------------------------------------- destroy(): void { this.teardownAdaptiveResize() this.cleanupScrollObserver?.() this.cleanupScrollObserver = undefined this.cleanupListMount?.() this.cleanupListMount = undefined this.ensureListSlideMountedFn = undefined this.mountedSlides.clear() for (const handle of this.slideHandles.values()) { handle.dispose() } this.slideHandles.clear() this.disposeAllCharts() for (const url of this.mediaUrlCache.values()) { URL.revokeObjectURL(url) } this.mediaUrlCache.clear() this.container.innerHTML = '' this.presentation = null this.activeRenderMode = null } [Symbol.dispose](): void { this.destroy() } // ----------------------------------------------------------------------- // Internal: rendering pipeline // ----------------------------------------------------------------------- private normalizeZoomPercent(percent: number): number { if (!Number.isFinite(percent)) return 100 return Math.max(10, Math.min(400, percent)) } private normalizeBatchSize(val: number): number { return Number.isInteger(val) && val > 0 ? val : 12 } private normalizePositiveInt(val: number, fallback: number): number { return Number.isInteger(val) && val > 0 ? val : fallback } private normalizePositiveFloat(val: number, fallback: number): number { return Number.isFinite(val) && val > 0 ? val : fallback } private getDisplayMetrics(): { scale: number; displayWidth: number; displayHeight: number } { if (!this.presentation) { return { scale: 1, displayWidth: 0, displayHeight: 0 } } const fitWidth = this.viewerOptions.width ?? (this.container.clientWidth || 960) if (this._fitMode === 'contain' && this.viewerOptions.width === undefined) { this.lastMeasuredContainerWidth = fitWidth } const fitScale = this._fitMode === 'contain' ? fitWidth / this.presentation.width : 1 const scale = fitScale * this.zoomFactor return { scale, displayWidth: this.presentation.width * scale, displayHeight: this.presentation.height * scale, } } private async queueRender(): Promise { this.renderChain = this.renderChain.then(async () => { if (!this.presentation) return this.emitRenderStart() try { const { scale, displayWidth, displayHeight } = this.getDisplayMetrics() this.cleanupScrollObserver?.() this.cleanupScrollObserver = undefined this.cleanupListMount?.() this.cleanupListMount = undefined this.ensureListSlideMountedFn = undefined this.mountedSlides.clear() for (const handle of this.slideHandles.values()) { handle.dispose() } this.slideHandles.clear() this.disposeAllCharts() this.container.innerHTML = '' this.container.style.position = 'relative' if (this.activeRenderMode === 'slide') { this.renderSingleSlide(scale, displayWidth, displayHeight) } else if (this.listOptions.windowed) { await this.renderAllSlidesWindowed(scale, displayWidth, displayHeight) } else { await this.renderAllSlidesFull(scale, displayWidth, displayHeight) } // Post-render width correction: appending slides may cause a scrollbar // to appear on the page, narrowing the container. If the measured width // changed, patch wrapper sizes and scale transforms in-place so content // is not clipped by the (now narrower) container. if (this.activeRenderMode !== 'slide') { this.correctListMetricsIfNeeded() } this.emitSlideChange(this.currentSlide) } finally { this.emitRenderComplete() } }) return this.renderChain } private handleContainerResize(): void { if (!this.presentation) return if (this._fitMode !== 'contain') return if (this.viewerOptions.width !== undefined) return const nextWidth = this.container.clientWidth || 0 if (!nextWidth || nextWidth === this.lastMeasuredContainerWidth) return this.lastMeasuredContainerWidth = nextWidth if (this.resizeRafId !== null) { cancelAnimationFrame(this.resizeRafId) } this.resizeRafId = requestAnimationFrame(() => { this.resizeRafId = null void this.queueRender() }) } private setupAdaptiveResize(): void { this.teardownAdaptiveResize() if (typeof ResizeObserver !== 'undefined') { const observer = new ResizeObserver(() => this.handleContainerResize()) observer.observe(this.container) this.resizeObserver = observer return } this.windowResizeHandler = () => this.handleContainerResize() window.addEventListener('resize', this.windowResizeHandler) } private teardownAdaptiveResize(): void { this.resizeObserver?.disconnect() this.resizeObserver = undefined if (this.windowResizeHandler) { window.removeEventListener('resize', this.windowResizeHandler) this.windowResizeHandler = undefined } if (this.resizeRafId !== null) { cancelAnimationFrame(this.resizeRafId) this.resizeRafId = null } } /** * Yield to the event loop between render batches so the browser can paint. * * We intentionally do NOT rely solely on `requestAnimationFrame`: rAF callbacks * are paused while the document is hidden (backgrounded tab). A render kicked * off in that state would otherwise stall forever on the batch yield and the * `open()` promise would never settle (perma-loading preview). The `setTimeout` * fallback fires regardless of visibility, so the render always completes — * matching the server-side renderer, which has no visibility dependency. */ private yieldToNextFrame(): Promise { return new Promise((resolve) => { let settled = false const finish = () => { if (settled) return settled = true resolve() } if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(finish) } setTimeout(finish, 32) }) } private disposeAllCharts(): void { for (const chart of this.chartInstances) { if (!chart.isDisposed()) { chart.dispose() } } this.chartInstances.clear() } private createListSlideItem( index: number, displayWidth: number, displayHeight: number ): { item: HTMLDivElement; wrapper: HTMLDivElement } { const item = document.createElement('div') item.dataset.slideIndex = String(index) item.style.cssText = 'width: fit-content; margin: 0 auto 20px;' const wrapper = document.createElement('div') wrapper.style.cssText = ` width: ${displayWidth}px; height: ${displayHeight}px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); overflow: hidden; position: relative; background: #fff; ` item.appendChild(wrapper) if (this.listOptions.showSlideLabels) { const label = document.createElement('div') label.style.cssText = 'text-align: center; padding: 4px; font-size: 12px; color: #666;' label.textContent = `Slide ${index + 1}` item.appendChild(label) } return { item, wrapper } } private mountListSlide( index: number, wrapper: HTMLDivElement, scale: number, _displayWidth: number, _displayHeight: number ): void { if (!this.presentation) return if (wrapper.dataset.mounted === '1') return wrapper.dataset.mounted = '1' wrapper.innerHTML = '' this.mountedSlides.add(index) const slide = this.presentation.slides[index] try { const handle = renderSlideInternal(this.presentation, slide, { onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error), onNavigate: (target) => this.handleNavigate(target), mediaUrlCache: this.mediaUrlCache, chartInstances: this.chartInstances, }) this.slideHandles.set(index, handle) handle.element.style.transform = `scale(${scale})` handle.element.style.transformOrigin = 'top left' wrapper.appendChild(handle.element) this.emitSlideRendered(index, handle.element) } catch (e) { this.emitSlideError(index, e) wrapper.style.background = '#fff3f3' wrapper.style.display = 'flex' wrapper.style.alignItems = 'center' wrapper.style.justifyContent = 'center' wrapper.style.border = '2px dashed #ff6b6b' wrapper.style.color = '#cc0000' wrapper.style.fontSize = '14px' wrapper.textContent = `Slide ${index + 1}: Render Error - ${getErrorMessage(e)}` } } private unmountListSlide(index: number, wrapper: HTMLDivElement, displayHeight: number): void { if (wrapper.dataset.mounted !== '1') return wrapper.dataset.mounted = '0' this.mountedSlides.delete(index) const handle = this.slideHandles.get(index) if (handle) { handle.dispose() this.slideHandles.delete(index) } wrapper.innerHTML = '' wrapper.style.background = '#fff' wrapper.style.display = '' wrapper.style.alignItems = '' wrapper.style.justifyContent = '' wrapper.style.border = '' wrapper.style.color = '' wrapper.style.fontSize = '' wrapper.style.height = `${displayHeight}px` this.emitSlideUnmounted(index) } private async renderAllSlidesFull( scale: number, displayWidth: number, displayHeight: number ): Promise { if (!this.presentation) return const batchSize = this.listOptions.batchSize let batchFragment = document.createDocumentFragment() for (let i = 0; i < this.presentation.slides.length; i++) { const { item, wrapper } = this.createListSlideItem(i, displayWidth, displayHeight) this.mountListSlide(i, wrapper, scale, displayWidth, displayHeight) batchFragment.appendChild(item) if ((i + 1) % batchSize === 0) { this.container.appendChild(batchFragment) batchFragment = document.createDocumentFragment() await this.yieldToNextFrame() } } if (batchFragment.childNodes.length > 0) { this.container.appendChild(batchFragment) } this.setupScrollSlideTracking() } private async renderAllSlidesWindowed( scale: number, displayWidth: number, displayHeight: number ): Promise { if (!this.presentation) return const batchSize = this.listOptions.batchSize let batchFragment = document.createDocumentFragment() const wrappers: HTMLDivElement[] = [] for (let i = 0; i < this.presentation.slides.length; i++) { const { item, wrapper } = this.createListSlideItem(i, displayWidth, displayHeight) wrappers.push(wrapper) batchFragment.appendChild(item) if ((i + 1) % batchSize === 0) { this.container.appendChild(batchFragment) batchFragment = document.createDocumentFragment() await this.yieldToNextFrame() } } if (batchFragment.childNodes.length > 0) { this.container.appendChild(batchFragment) } const mount = (idx: number): void => { if (idx < 0 || idx >= wrappers.length) return this.mountListSlide(idx, wrappers[idx], scale, displayWidth, displayHeight) } const unmount = (idx: number): void => { if (idx < 0 || idx >= wrappers.length) return this.unmountListSlide(idx, wrappers[idx], displayHeight) } const initial = this.listOptions.initialSlides for (let i = 0; i < Math.min(initial, wrappers.length); i++) mount(i) this.ensureListSlideMountedFn = mount const IO = window.IntersectionObserver if (!IO) { for (let i = initial; i < wrappers.length; i++) mount(i) this.setupScrollSlideTracking() return } const ioRoot = this.viewerOptions.scrollContainer ?? null const overscanViewport = this.listOptions.overscanViewport const rootHeight = ioRoot ? ioRoot.clientHeight : window.innerHeight const rootMargin = `${Math.round(rootHeight * overscanViewport)}px 0px` const observer = new IO( (entries) => { for (const entry of entries) { const item = (entry.target as HTMLElement).parentElement const index = Number(item?.dataset.slideIndex ?? '-1') if (Number.isNaN(index) || index < 0) continue if (entry.isIntersecting) { mount(index) } else { unmount(index) } } }, { root: ioRoot, rootMargin, threshold: 0 } ) wrappers.forEach((wrapper) => { observer.observe(wrapper) }) this.cleanupListMount = () => { observer.disconnect() this.ensureListSlideMountedFn = undefined } this.setupScrollSlideTracking() } private setupScrollSlideTracking(): void { if (this.activeRenderMode === 'slide') return const IO = window.IntersectionObserver if (!IO) return const items = this.container.querySelectorAll('[data-slide-index]') if (!items.length) return const ratios = new Map() const ioRoot = this.viewerOptions.scrollContainer ?? null const observer = new IO( (entries) => { for (const entry of entries) { const idx = Number((entry.target as HTMLElement).dataset.slideIndex ?? '-1') if (Number.isNaN(idx) || idx < 0) continue ratios.set(idx, entry.intersectionRatio) } if (this.suppressScrollChange) return let bestIdx = -1 let bestRatio = -1 for (const [idx, ratio] of ratios) { if (ratio > bestRatio) { bestRatio = ratio bestIdx = idx } } if (bestIdx >= 0 && bestIdx !== this.currentSlide) { this.currentSlide = bestIdx this.emitSlideChange(bestIdx) } }, { root: ioRoot, threshold: [0, 0.25, 0.5, 0.75, 1.0] } ) items.forEach((item) => observer.observe(item)) this.cleanupScrollObserver = () => { observer.disconnect() } } private renderSingleSlide(scale: number, displayWidth: number, displayHeight: number): void { if (!this.presentation) return const slide = this.presentation.slides[this.currentSlide] if (!slide) return for (const handle of this.slideHandles.values()) { handle.dispose() } this.slideHandles.clear() this.disposeAllCharts() this.container.innerHTML = '' this.mountedSlides.clear() this.mountedSlides.add(this.currentSlide) const wrapper = document.createElement('div') wrapper.style.cssText = ` width: ${displayWidth}px; height: ${displayHeight}px; margin: 0 auto; overflow: hidden; position: relative; box-shadow: 0 2px 8px rgba(0,0,0,0.15); ` try { const handle = renderSlideInternal(this.presentation, slide, { onNodeError: (nodeId, error) => this.emitNodeError(nodeId, error), onNavigate: (target) => this.handleNavigate(target), mediaUrlCache: this.mediaUrlCache, chartInstances: this.chartInstances, }) this.slideHandles.set(this.currentSlide, handle) handle.element.style.transform = `scale(${scale})` handle.element.style.transformOrigin = 'top left' wrapper.appendChild(handle.element) this.emitSlideRendered(this.currentSlide, handle.element) } catch (e) { this.emitSlideError(this.currentSlide, e) wrapper.style.background = '#fff3f3' wrapper.style.display = 'flex' wrapper.style.alignItems = 'center' wrapper.style.justifyContent = 'center' wrapper.style.border = '2px dashed #ff6b6b' wrapper.style.color = '#cc0000' wrapper.style.fontSize = '14px' wrapper.textContent = `Slide ${this.currentSlide + 1}: Render Error - ${getErrorMessage(e)}` } this.container.appendChild(wrapper) this.afterSingleSlideRender() } /** * After list-mode rendering, a scrollbar may appear on the page body * (or a scroll ancestor), narrowing the container. If the container's * clientWidth now differs from the width used to compute the initial * scale, patch every wrapper's dimensions and each slide element's * transform in-place — no DOM rebuild required. */ private correctListMetricsIfNeeded(): void { if (!this.presentation) return if (this._fitMode !== 'contain') return if (this.viewerOptions.width !== undefined) return const currentWidth = this.container.clientWidth || 0 if (!currentWidth || currentWidth === this.lastMeasuredContainerWidth) return // Width changed — recompute metrics this.lastMeasuredContainerWidth = currentWidth const fitScale = currentWidth / this.presentation.width const newScale = fitScale * this.zoomFactor const newDisplayW = this.presentation.width * newScale const newDisplayH = this.presentation.height * newScale // Patch every slide wrapper in the list const items = this.container.querySelectorAll('[data-slide-index]') for (const item of items) { const wrapper = item.firstElementChild as HTMLElement | null if (!wrapper) continue wrapper.style.width = `${newDisplayW}px` wrapper.style.height = `${newDisplayH}px` // The slide element is the first child of the wrapper const slideEl = wrapper.firstElementChild as HTMLElement | null if (slideEl) { slideEl.style.transform = `scale(${newScale})` } } } private handleNavigate(target: { slideIndex?: number; url?: string }): void { if (target.slideIndex !== undefined) { this.goToSlide(target.slideIndex) } else if (target.url && isAllowedExternalUrl(target.url)) { window.open(target.url, '_blank', 'noopener,noreferrer') } } } // ----------------------------------------------------------------------- // Standalone helper (shared with Renderer.ts) // ----------------------------------------------------------------------- async function normalizePreviewInput(input: PreviewInput): Promise { if (input instanceof ArrayBuffer) return input if (input instanceof Uint8Array) { const bytes = new Uint8Array(input.byteLength) bytes.set(input) return bytes.buffer } const blobLike = input as Blob & { arrayBuffer?: () => Promise } if (typeof blobLike.arrayBuffer === 'function') { return blobLike.arrayBuffer() } if (typeof FileReader !== 'undefined') { return new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result as ArrayBuffer) reader.onerror = () => reject(reader.error ?? new Error('Failed to read Blob input')) reader.readAsArrayBuffer(blobLike) }) } if (typeof Response !== 'undefined') { return new Response(blobLike).arrayBuffer() } throw new Error('Blob preview input is not supported in this runtime') }