@@ -8,6 +8,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace'
88import { getFileExtension } from '@/lib/uploads/utils/file-utils'
99import {
1010 useUpdateWorkspaceFileContent ,
11+ useWorkspaceFileBinary ,
1112 useWorkspaceFileContent ,
1213} from '@/hooks/queries/workspace-files'
1314import { useAutosave } from '@/hooks/use-autosave'
@@ -48,17 +49,29 @@ const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4849const IMAGE_PREVIEWABLE_MIME_TYPES = new Set ( [ 'image/png' , 'image/jpeg' , 'image/gif' , 'image/webp' ] )
4950const IMAGE_PREVIEWABLE_EXTENSIONS = new Set ( [ 'png' , 'jpg' , 'jpeg' , 'gif' , 'webp' ] )
5051
51- type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported'
52+ const PPTX_PREVIEWABLE_MIME_TYPES = new Set ( [
53+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation' ,
54+ ] )
55+ const PPTX_PREVIEWABLE_EXTENSIONS = new Set ( [ 'pptx' ] )
56+
57+ type FileCategory =
58+ | 'text-editable'
59+ | 'iframe-previewable'
60+ | 'image-previewable'
61+ | 'pptx-previewable'
62+ | 'unsupported'
5263
5364function resolveFileCategory ( mimeType : string | null , filename : string ) : FileCategory {
5465 if ( mimeType && TEXT_EDITABLE_MIME_TYPES . has ( mimeType ) ) return 'text-editable'
5566 if ( mimeType && IFRAME_PREVIEWABLE_MIME_TYPES . has ( mimeType ) ) return 'iframe-previewable'
5667 if ( mimeType && IMAGE_PREVIEWABLE_MIME_TYPES . has ( mimeType ) ) return 'image-previewable'
68+ if ( mimeType && PPTX_PREVIEWABLE_MIME_TYPES . has ( mimeType ) ) return 'pptx-previewable'
5769
5870 const ext = getFileExtension ( filename )
5971 if ( TEXT_EDITABLE_EXTENSIONS . has ( ext ) ) return 'text-editable'
6072 if ( IFRAME_PREVIEWABLE_EXTENSIONS . has ( ext ) ) return 'iframe-previewable'
6173 if ( IMAGE_PREVIEWABLE_EXTENSIONS . has ( ext ) ) return 'image-previewable'
74+ if ( PPTX_PREVIEWABLE_EXTENSIONS . has ( ext ) ) return 'pptx-previewable'
6275
6376 return 'unsupported'
6477}
@@ -124,6 +137,10 @@ export function FileViewer({
124137 return < ImagePreview file = { file } />
125138 }
126139
140+ if ( category === 'pptx-previewable' ) {
141+ return < PptxPreview file = { file } workspaceId = { workspaceId } streamingContent = { streamingContent } />
142+ }
143+
127144 return < UnsupportedPreview file = { file } />
128145}
129146
@@ -163,7 +180,12 @@ function TextEditor({
163180 isLoading,
164181 error,
165182 dataUpdatedAt,
166- } = useWorkspaceFileContent ( workspaceId , file . id , file . key )
183+ } = useWorkspaceFileContent (
184+ workspaceId ,
185+ file . id ,
186+ file . key ,
187+ file . type === 'text/x-pptxgenjs'
188+ )
167189
168190 const updateContent = useUpdateWorkspaceFileContent ( )
169191
@@ -417,6 +439,167 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
417439 )
418440}
419441
442+ function PptxPreview ( {
443+ file,
444+ workspaceId,
445+ streamingContent,
446+ } : {
447+ file : WorkspaceFileRecord
448+ workspaceId : string
449+ streamingContent ?: string
450+ } ) {
451+ const {
452+ data : fileData ,
453+ isLoading : isFetching ,
454+ error : fetchError ,
455+ dataUpdatedAt,
456+ } = useWorkspaceFileBinary ( workspaceId , file . id , file . key )
457+
458+ const [ slides , setSlides ] = useState < string [ ] > ( [ ] )
459+ const [ rendering , setRendering ] = useState ( false )
460+ const [ renderError , setRenderError ] = useState < string | null > ( null )
461+
462+ useEffect ( ( ) => {
463+ let cancelled = false
464+
465+ async function render ( ) {
466+ try {
467+ setRendering ( true )
468+ setRenderError ( null )
469+
470+ if ( streamingContent !== undefined ) {
471+ const PptxGenJS = ( await import ( 'pptxgenjs' ) ) . default
472+ const pptx = new PptxGenJS ( )
473+ const fn = new Function ( 'pptx' , `return (async () => { ${ streamingContent } })()` )
474+ await fn ( pptx )
475+ const arrayBuffer = ( await pptx . write ( { outputType : 'arraybuffer' } ) ) as ArrayBuffer
476+ if ( cancelled ) return
477+ const { PPTXViewer } = await import ( 'pptxviewjs' )
478+ const data = new Uint8Array ( arrayBuffer )
479+ const probe = document . createElement ( 'canvas' )
480+ const probeViewer = new PPTXViewer ( { canvas : probe } )
481+ await probeViewer . loadFile ( data )
482+ const count = probeViewer . getSlideCount ( )
483+ if ( cancelled || count === 0 ) return
484+ const dpr = window . devicePixelRatio || 1
485+ const W = Math . round ( 1920 * dpr )
486+ const H = Math . round ( 1080 * dpr )
487+ const images : string [ ] = [ ]
488+ for ( let i = 0 ; i < count ; i ++ ) {
489+ if ( cancelled ) break
490+ const canvas = document . createElement ( 'canvas' )
491+ canvas . width = W
492+ canvas . height = H
493+ const viewer = new PPTXViewer ( { canvas } )
494+ await viewer . loadFile ( data )
495+ if ( i > 0 ) await viewer . goToSlide ( i )
496+ else await viewer . render ( )
497+ images . push ( canvas . toDataURL ( 'image/png' ) )
498+ }
499+ if ( ! cancelled ) setSlides ( images )
500+ return
501+ }
502+
503+ if ( ! fileData ) return
504+ const { PPTXViewer } = await import ( 'pptxviewjs' )
505+ if ( cancelled ) return
506+
507+ const data = new Uint8Array ( fileData ! )
508+ const probe = document . createElement ( 'canvas' )
509+ const probeViewer = new PPTXViewer ( { canvas : probe } )
510+ await probeViewer . loadFile ( data )
511+ const count = probeViewer . getSlideCount ( )
512+ if ( cancelled || count === 0 ) return
513+
514+ const dpr = window . devicePixelRatio || 1
515+ const W = Math . round ( 1920 * dpr )
516+ const H = Math . round ( 1080 * dpr )
517+ const images : string [ ] = [ ]
518+
519+ for ( let i = 0 ; i < count ; i ++ ) {
520+ if ( cancelled ) break
521+ const canvas = document . createElement ( 'canvas' )
522+ canvas . width = W
523+ canvas . height = H
524+ const viewer = new PPTXViewer ( { canvas } )
525+ await viewer . loadFile ( data )
526+ if ( i > 0 ) await viewer . goToSlide ( i )
527+ else await viewer . render ( )
528+ images . push ( canvas . toDataURL ( 'image/png' ) )
529+ }
530+
531+ if ( ! cancelled ) setSlides ( images )
532+ } catch ( err ) {
533+ if ( ! cancelled ) {
534+ const msg = err instanceof Error ? err . message : 'Failed to render presentation'
535+ logger . error ( 'PPTX render failed' , { error : msg } )
536+ setRenderError ( msg )
537+ }
538+ } finally {
539+ if ( ! cancelled ) setRendering ( false )
540+ }
541+ }
542+
543+ render ( )
544+ return ( ) => {
545+ cancelled = true
546+ }
547+ } , [ fileData , dataUpdatedAt , streamingContent ] )
548+
549+ const error = fetchError
550+ ? fetchError instanceof Error
551+ ? fetchError . message
552+ : 'Failed to load file'
553+ : renderError
554+ const loading = isFetching || rendering
555+
556+ if ( error ) {
557+ return (
558+ < div className = 'flex flex-1 flex-col items-center justify-center gap-[8px]' >
559+ < p className = 'font-medium text-[14px] text-[var(--text-body)]' >
560+ Failed to preview presentation
561+ </ p >
562+ < p className = 'text-[13px] text-[var(--text-muted)]' > { error } </ p >
563+ </ div >
564+ )
565+ }
566+
567+ if ( loading && slides . length === 0 ) {
568+ return (
569+ < div className = 'flex flex-1 items-center justify-center bg-[var(--surface-1)]' >
570+ < div className = 'flex flex-col items-center gap-[8px]' >
571+ < div
572+ className = 'h-[18px] w-[18px] animate-spin rounded-full'
573+ style = { {
574+ background :
575+ 'conic-gradient(from 0deg, hsl(var(--muted-foreground)) 0deg 120deg, transparent 120deg 180deg, hsl(var(--muted-foreground)) 180deg 300deg, transparent 300deg 360deg)' ,
576+ mask : 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))' ,
577+ WebkitMask :
578+ 'radial-gradient(farthest-side, transparent calc(100% - 1.5px), black calc(100% - 1.5px))' ,
579+ } }
580+ />
581+ < p className = 'text-[13px] text-[var(--text-muted)]' > Loading presentation...</ p >
582+ </ div >
583+ </ div >
584+ )
585+ }
586+
587+ return (
588+ < div className = 'flex-1 overflow-y-auto bg-[var(--surface-1)] p-[24px]' >
589+ < div className = 'mx-auto flex max-w-[960px] flex-col gap-[16px]' >
590+ { slides . map ( ( src , i ) => (
591+ < img
592+ key = { i }
593+ src = { src }
594+ alt = { `Slide ${ i + 1 } ` }
595+ className = 'w-full rounded-md shadow-lg'
596+ />
597+ ) ) }
598+ </ div >
599+ </ div >
600+ )
601+ }
602+
420603function UnsupportedPreview ( { file } : { file : WorkspaceFileRecord } ) {
421604 const ext = getFileExtension ( file . name )
422605
0 commit comments