初始化模版工程
This commit is contained in:
270
components/ppt-editor/index.tsx
Normal file
270
components/ppt-editor/index.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea, ScrollBar } from '@/components/ui/scroll-area'
|
||||
import { useSize } from './hooks/useSize'
|
||||
import { PPTEditProvider, type SlideJson } from '@/components/html-editor/context'
|
||||
import { PPTEditToolBar } from '@/components/html-editor/components/toolbar-web'
|
||||
import { usePPTEditor } from '@/components/ppt-editor/hooks/usePPTEditor'
|
||||
import { FloatingToolbar } from './FloatingToolbar'
|
||||
import type { TaskArtifact } from '../nova-sdk'
|
||||
|
||||
export interface SlideItem {
|
||||
content: string
|
||||
file_name: string
|
||||
file_type: string
|
||||
id: string
|
||||
path : string
|
||||
title: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface PptPreviewProps {
|
||||
/** conversationId */
|
||||
taskId: string | null | undefined
|
||||
/** PPT 文件的 URL */
|
||||
url: string
|
||||
/** TaskArtifact */
|
||||
artifact: TaskArtifact
|
||||
/** 是否可编辑 */
|
||||
editable?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* PPT 预览组件
|
||||
*/
|
||||
export const PptPreview = ({ url, artifact, taskId, editable = true}: PptPreviewProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [slideList, setSlideList] = useState<SlideItem[]>([])
|
||||
const [sliderJson,setSliderJson] = useState<SlideJson>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!url) return
|
||||
|
||||
setLoading(true)
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const slides = data.slide_list || []
|
||||
setSlideList(slides)
|
||||
setSliderJson(data)
|
||||
})
|
||||
.catch(() => setSlideList([]))
|
||||
.finally(() => setLoading(false))
|
||||
}, [url])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
|
||||
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
<span className="text-sm mt-2">加载中...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!slideList || slideList.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-8 text-muted-foreground h-full">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p className="text-sm">暂无幻灯片内容</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PPTEditProvider slides={slideList}>
|
||||
<div className='relative h-full' ref={containerRef}>
|
||||
{editable && <PPTEditToolBar containerRef={containerRef} />}
|
||||
<PptSlideViewer
|
||||
slideList={slideList}
|
||||
currentIndex={currentIndex}
|
||||
setCurrentIndex={setCurrentIndex}
|
||||
artifact={artifact}
|
||||
sliderJson={sliderJson}
|
||||
taskId={taskId}
|
||||
editable={editable}
|
||||
/>
|
||||
</div>
|
||||
</PPTEditProvider>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
function PptSlideViewer({
|
||||
slideList,
|
||||
currentIndex,
|
||||
setCurrentIndex,
|
||||
artifact,
|
||||
sliderJson,
|
||||
taskId,
|
||||
editable
|
||||
}: {
|
||||
slideList: SlideItem[]
|
||||
currentIndex: number
|
||||
setCurrentIndex: (index: number) => void
|
||||
artifact: TaskArtifact
|
||||
sliderJson: SlideJson | undefined
|
||||
taskId:string | null | undefined
|
||||
editable: boolean
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const size = useSize(containerRef)
|
||||
const [iframeHeight, setIframeHeight] = useState(720)
|
||||
const [loadState, setLoadState] = useState<'loading' | 'loaded' | 'error'>('loading')
|
||||
|
||||
const currentSlide = slideList[currentIndex]
|
||||
const scale = size ? size.width / 1280 : 1
|
||||
|
||||
const handleIframeLoad = (event: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||
const iframe = event.currentTarget
|
||||
try {
|
||||
const actualHeight = iframe.contentDocument?.documentElement.scrollHeight
|
||||
if (actualHeight && actualHeight > 0) {
|
||||
setIframeHeight(actualHeight)
|
||||
}
|
||||
setLoadState('loaded')
|
||||
} catch (error) {
|
||||
console.warn('Cannot access iframe content:', error)
|
||||
setLoadState('loaded')
|
||||
}
|
||||
}
|
||||
|
||||
const handleIframeError = () => {
|
||||
setLoadState('error')
|
||||
}
|
||||
|
||||
// 切换幻灯片时重置加载状态
|
||||
React.useEffect(() => {
|
||||
setLoadState('loading')
|
||||
setIframeHeight(720)
|
||||
}, [currentIndex])
|
||||
|
||||
const editState = usePPTEditor(currentSlide.id, iframeRef, scale, sliderJson, artifact, taskId, editable)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 悬浮工具栏 */}
|
||||
{editable && (
|
||||
<div style={{ width: 'max-content', margin: '0 auto', display: 'flex', justifyContent: 'center', padding: '12px 0 0', zIndex: 100, position: 'relative' }}>
|
||||
<FloatingToolbar
|
||||
canUndo={editState.canUndo}
|
||||
canRedo={editState.canRedo}
|
||||
onUndo={() => editState.undo.current()}
|
||||
onRedo={() => editState.redo.current()}
|
||||
onSave={editState.manualSave}
|
||||
isSaving={editState.isSaving}
|
||||
saveType={editState.saveType}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 主预览区 */}
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden py-4">
|
||||
<ScrollArea className="flex-1 w-full px-4 rounded-lg">
|
||||
<div className="flex min-h-full w-full justify-center">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative w-full flex-none overflow-hidden rounded-lg border border-solid border-border bg-card shadow-[0_18px_42px_var(--editor-shadow)]"
|
||||
style={{
|
||||
height: scale ? `${iframeHeight * scale}px` : '720px',
|
||||
}}
|
||||
>
|
||||
{loadState === 'loading' && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-card/92 backdrop-blur-sm">
|
||||
<div className="w-8 h-8 border-2 border-muted border-t-primary rounded-full animate-spin" />
|
||||
<p className="text-muted-foreground text-sm mt-2">加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={currentSlide.content}
|
||||
className={cn(
|
||||
'w-[1280px] border-0 origin-top-left transition-opacity duration-300 ease-in-out',
|
||||
loadState === 'loading' ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
title={`Slide ${currentIndex + 1}`}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
style={{
|
||||
height: `${iframeHeight}px`,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 页码和导航 */}
|
||||
<div className="mt-4 flex shrink-0 items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
||||
disabled={currentIndex === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currentIndex + 1} / {slideList.length}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setCurrentIndex(Math.min(slideList.length - 1, currentIndex + 1))}
|
||||
disabled={currentIndex === slideList.length - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 缩略图列表 */}
|
||||
{slideList.length > 1 && (
|
||||
<div className="shrink-0 border-t border-border/80 bg-secondary/35 p-3">
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex gap-2 pb-1 w-max">
|
||||
{slideList.map((slide, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
className={cn(
|
||||
'relative aspect-video w-48 shrink-0 overflow-hidden rounded-lg border-2 bg-card transition-all shadow-[0_10px_24px_var(--editor-shadow)]',
|
||||
currentIndex === index
|
||||
? 'border-primary shadow-md ring-2 ring-primary/20'
|
||||
: 'border-transparent hover:border-muted'
|
||||
)}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
>
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
<iframe
|
||||
srcDoc={slide.content}
|
||||
className="w-full h-full border-0 pointer-events-none origin-top-left"
|
||||
title={`Thumbnail ${index + 1}`}
|
||||
sandbox="allow-same-origin"
|
||||
style={{
|
||||
transform: 'scale(1)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-transparent pointer-events-none" />
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 bg-black/55 py-1 text-center text-xs text-white">
|
||||
{index + 1}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PptPreview
|
||||
Reference in New Issue
Block a user