chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,994 @@
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user