Files
simstudioai--sim/apps/sim/lib/pptx-renderer/core/viewer.ts
T
wehub-resource-sync d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

995 lines
32 KiB
TypeScript

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<string, string>()
private chartInstances = new Set<ECharts>()
private currentSlide = 0
private _fitMode: FitMode
private _isRendering = false
private zoomFactor = 1
private renderChain: Promise<void> = 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<number>()
private slideHandles = new Map<number, SlideHandle>()
private activeRenderMode: 'list' | 'slide' | null = null
private listOptions: Required<ListRenderOptions> = {
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<void> {
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<void> {
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<void> {
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<PptxViewer> {
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<void> {
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<void>((resolve) =>
requestAnimationFrame(() => {
this.suppressScrollChange = false
resolve()
})
)
this.ensureListSlideMountedFn?.(this.currentSlide)
const targetChild = this.container.querySelector<HTMLElement>(
`[data-slide-index="${this.currentSlide}"]`
)
if (targetChild) {
targetChild.scrollIntoView(scrollOptions ?? { behavior: 'smooth', block: 'center' })
}
}
}
async setZoom(percent: number): Promise<void> {
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<void> {
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<K extends keyof PptxViewerEventMap>(
type: K,
listener: (event: PptxViewerEventMap[K]) => void
): this {
this.addEventListener(type, listener as EventListener)
return this
}
off<K extends keyof PptxViewerEventMap>(
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<void> {
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<void> {
return new Promise<void>((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<void> {
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<void> {
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<HTMLElement>('[data-slide-index]')
if (!items.length) return
const ratios = new Map<number, number>()
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<HTMLElement>('[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<ArrayBuffer> {
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<ArrayBuffer> }
if (typeof blobLike.arrayBuffer === 'function') {
return blobLike.arrayBuffer()
}
if (typeof FileReader !== 'undefined') {
return new Promise<ArrayBuffer>((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')
}