ManimCat / frontend /src /studio /plot /preview /PlotPreviewPanel.tsx
Bin29's picture
Sync from main: e764154 feat(plot-skill): add math-exam-diagram SKILL.md for exam-style math figures
abcf568
import { useCallback, useEffect, useMemo, useState, type MouseEvent as ReactMouseEvent } from 'react'
import { copyImageAssetToClipboard, exportImageAsset } from '../../../components/image-preview/image-asset'
import { useI18n } from '../../../i18n'
import type {
StudioFileAttachment,
StudioPermissionDecision,
StudioPermissionRequest,
StudioRun,
StudioSession,
StudioTask,
StudioWork,
StudioWorkResult,
} from '../../protocol/studio-agent-types'
import { truncateStudioText } from '../../theme'
import { PlotPreviewLightbox } from '../lightbox/PlotPreviewLightbox'
import type { PlotPreviewVariant, PlotWorkListItem } from '../types'
import { PlotHistoryStrip } from './PlotHistoryStrip'
import { PlotPreviewContextMenu } from './PlotPreviewContextMenu'
import { PlotPreviewSurface } from './PlotPreviewSurface'
import { usePlotPreviewImage } from './use-plot-preview-image'
interface PlotPreviewPanelProps {
session: StudioSession | null
works: PlotWorkListItem[]
selectedWorkId: string | null
work: StudioWork | null
result: StudioWorkResult | null
latestRun: StudioRun | null
tasks: StudioTask[]
requests: StudioPermissionRequest[]
replyingPermissionIds: Record<string, boolean>
latestAssistantText: string
errorMessage?: string | null
onSelectWork: (workId: string) => void
onReorderWorks: (workIds: string[]) => void
onReply: (requestId: string, reply: StudioPermissionDecision) => Promise<void> | void
onSendPreviewToComposer?: (attachment: { url: string; name: string; mimeType?: string }) => void
variant?: PlotPreviewVariant
}
export function PlotPreviewPanel({
session,
works,
selectedWorkId,
result,
onSelectWork,
onReorderWorks,
onSendPreviewToComposer,
variant = 'default',
}: PlotPreviewPanelProps) {
const { t } = useI18n()
const isTLayout = variant === 't-layout-top'
const isMinimal = variant === 'pure-minimal-top'
const [lightboxOpen, setLightboxOpen] = useState(false)
const [draggingWorkId, setDraggingWorkId] = useState<string | null>(null)
const [selectedImageIndex, setSelectedImageIndex] = useState(0)
const [previewMotionKey, setPreviewMotionKey] = useState(0)
const [previewContextMenu, setPreviewContextMenu] = useState({ open: false, x: 0, y: 0 })
const [exportingFormat, setExportingFormat] = useState<'png' | 'svg' | 'pdf' | null>(null)
const [copyingFormat, setCopyingFormat] = useState<'png' | 'svg' | null>(null)
const stripItems = works.slice(0, 12)
const historyImages = useMemo(() => {
return stripItems.flatMap((entry) => (
getImageAttachments(entry.result?.attachments).map((attachment, imageIndex) => ({
workId: entry.work.id,
attachment,
title: entry.work.title,
imageIndex,
}))
))
}, [stripItems])
const currentWorkImages = useMemo(() => getImageAttachments(result?.attachments), [result?.attachments])
const currentImagePathsKey = currentWorkImages.map((attachment) => attachment.path).join('|')
const clampedImageIndex = currentWorkImages.length === 0
? 0
: Math.min(selectedImageIndex, currentWorkImages.length - 1)
const selectedHistoryIndex = historyImages.findIndex((entry) => (
entry.workId === selectedWorkId && entry.imageIndex === clampedImageIndex
))
const activeHistoryIndex = selectedHistoryIndex >= 0
? selectedHistoryIndex
: historyImages.findIndex((entry) => entry.workId === selectedWorkId)
const activeHistoryEntry = historyImages[activeHistoryIndex] ?? null
const previewAttachment = currentWorkImages[clampedImageIndex] ?? activeHistoryEntry?.attachment ?? null
const { previewSrc: previewDisplaySrc } = usePlotPreviewImage(previewAttachment?.path)
const outputPath = formatOutputPath(previewAttachment, session, t('studio.plot.inlinePreview'), t('studio.plot.waitingOutputFile'))
const handlePreviewExport = useCallback(async (format: 'png' | 'svg' | 'pdf') => {
if (!previewAttachment?.path || exportingFormat) {
return
}
setPreviewContextMenu({ open: false, x: 0, y: 0 })
setExportingFormat(format)
try {
await exportImageAsset({
source: previewAttachment.path,
format,
index: clampedImageIndex,
fallbackName: previewAttachment.name,
})
} catch (error) {
console.error(`Failed to export ${format}`, error)
} finally {
setExportingFormat(null)
}
}, [clampedImageIndex, exportingFormat, previewAttachment])
const handlePreviewCopy = useCallback(async (format: 'png' | 'svg') => {
if (!previewAttachment?.path || copyingFormat) {
return
}
setPreviewContextMenu({ open: false, x: 0, y: 0 })
setCopyingFormat(format)
try {
await copyImageAssetToClipboard({
source: previewAttachment.path,
format,
})
} catch (error) {
console.error(`Failed to copy ${format}`, error)
} finally {
setCopyingFormat(null)
}
}, [copyingFormat, previewAttachment])
useEffect(() => {
setSelectedImageIndex(0)
}, [selectedWorkId, result?.id])
useEffect(() => {
setSelectedImageIndex((current) => {
if (currentWorkImages.length === 0) {
return current === 0 ? current : 0
}
const next = Math.min(current, currentWorkImages.length - 1)
return next === current ? current : next
})
}, [currentImagePathsKey, currentWorkImages.length])
useEffect(() => {
if (!previewAttachment?.path) {
return
}
setPreviewMotionKey((current) => current + 1)
}, [previewAttachment?.path, result?.id])
const handlePrev = useCallback(() => {
if (historyImages.length <= 1) {
return
}
const baseIndex = activeHistoryIndex >= 0 ? activeHistoryIndex : 0
const nextIndex = baseIndex <= 0 ? historyImages.length - 1 : baseIndex - 1
const nextEntry = historyImages[nextIndex]
onSelectWork(nextEntry.workId)
setSelectedImageIndex(nextEntry.imageIndex)
}, [activeHistoryIndex, historyImages, onSelectWork])
const handleNext = useCallback(() => {
if (historyImages.length <= 1) {
return
}
const baseIndex = activeHistoryIndex >= 0 ? activeHistoryIndex : 0
const nextIndex = baseIndex >= historyImages.length - 1 ? 0 : baseIndex + 1
const nextEntry = historyImages[nextIndex]
onSelectWork(nextEntry.workId)
setSelectedImageIndex(nextEntry.imageIndex)
}, [activeHistoryIndex, historyImages, onSelectWork])
useEffect(() => {
if (lightboxOpen || historyImages.length <= 1) {
return undefined
}
const handleWindowKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) {
return
}
const target = event.target as HTMLElement | null
if (
target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable
) {
return
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
handlePrev()
return
}
if (event.key === 'ArrowRight') {
event.preventDefault()
handleNext()
}
}
window.addEventListener('keydown', handleWindowKeyDown, true)
return () => window.removeEventListener('keydown', handleWindowKeyDown, true)
}, [handleNext, handlePrev, historyImages.length, lightboxOpen])
const moveWork = (targetWorkId: string) => {
if (!draggingWorkId || draggingWorkId === targetWorkId) {
return
}
const nextIds = stripItems.map((entry) => entry.work.id)
const fromIndex = nextIds.indexOf(draggingWorkId)
const toIndex = nextIds.indexOf(targetWorkId)
if (fromIndex === -1 || toIndex === -1) {
return
}
const reordered = [...nextIds]
const [moved] = reordered.splice(fromIndex, 1)
reordered.splice(toIndex, 0, moved)
onReorderWorks(reordered)
}
const handleSurfaceContextMenu = (event: ReactMouseEvent<HTMLDivElement>) => {
event.preventDefault()
if (!previewAttachment?.path) {
return
}
setPreviewContextMenu({
open: true,
x: event.clientX,
y: event.clientY,
})
}
return (
<section className={`relative flex h-full min-h-0 min-w-0 flex-col overflow-hidden ${isTLayout || isMinimal ? 'bg-transparent' : 'bg-bg-primary/40 backdrop-blur-sm'}`}>
{!isTLayout && !isMinimal && (
<div className="relative shrink-0 px-8 pb-3 pt-8">
<div className="flex items-center justify-between">
<div className="group flex items-center gap-3">
<div className="h-1.5 w-1.5 rounded-full bg-accent/40" />
<div className="min-w-0 font-mono text-[10px] uppercase tracking-[0.2em] text-text-secondary/40 transition-colors group-hover:text-text-secondary/70">
{outputPath}
</div>
<PlotCornerPaw className="h-3.5 w-3.5 text-text-secondary/20 transition-colors duration-500 group-hover:text-text-secondary/32" />
</div>
</div>
</div>
)}
<div className={`flex min-h-0 min-w-0 flex-1 flex-col ${isTLayout || isMinimal ? 'p-0' : 'px-6 pb-6 pt-2 sm:px-8 lg:px-10'}`}>
<div className="relative min-h-0 flex-1">
<div className={`flex h-full items-center justify-center ${isTLayout || isMinimal ? '' : 'min-h-[360px] sm:min-h-[460px] lg:min-h-[560px]'}`}>
<PlotPreviewSurface
key={`${previewMotionKey}:${previewAttachment?.path ?? 'empty'}`}
attachment={previewAttachment}
previewSrc={previewDisplaySrc}
result={result}
canNavigate={historyImages.length > 1}
currentIndex={activeHistoryIndex >= 0 ? activeHistoryIndex : 0}
total={historyImages.length}
variant={variant}
onOpen={() => setLightboxOpen(true)}
onContextMenu={handleSurfaceContextMenu}
onPrev={handlePrev}
onNext={handleNext}
/>
</div>
</div>
{!isTLayout && !isMinimal && (
<PlotHistoryStrip
entries={historyImages}
selectedWorkId={selectedWorkId}
selectedImageIndex={clampedImageIndex}
draggingWorkId={draggingWorkId}
onSelect={(workId, imageIndex) => {
onSelectWork(workId)
setSelectedImageIndex(imageIndex)
}}
onDragStart={setDraggingWorkId}
onDrop={(workId) => {
moveWork(workId)
setDraggingWorkId(null)
}}
onDragEnd={() => setDraggingWorkId(null)}
/>
)}
</div>
<PlotPreviewLightbox
isOpen={lightboxOpen}
activeImage={previewDisplaySrc ?? previewAttachment?.path}
activeIndex={activeHistoryIndex >= 0 ? activeHistoryIndex : 0}
total={historyImages.length}
editableFilename={previewAttachment?.name}
canNavigate={historyImages.length > 1}
onPrev={handlePrev}
onNext={handleNext}
onSendPreviewToComposer={onSendPreviewToComposer}
onClose={() => {
setLightboxOpen(false)
}}
/>
<PlotPreviewContextMenu
isOpen={previewContextMenu.open}
x={previewContextMenu.x}
y={previewContextMenu.y}
hasPreview={Boolean(previewAttachment?.path)}
copyingFormat={copyingFormat}
exportingFormat={exportingFormat}
onCopy={(format) => {
void handlePreviewCopy(format)
}}
onExport={(format) => {
void handlePreviewExport(format)
}}
onOpenLightbox={() => {
setPreviewContextMenu({ open: false, x: 0, y: 0 })
setLightboxOpen(true)
}}
onClose={() => setPreviewContextMenu({ open: false, x: 0, y: 0 })}
/>
</section>
)
}
function getImageAttachments(attachments: StudioFileAttachment[] | undefined): StudioFileAttachment[] {
return (attachments ?? []).filter(isImageAttachment)
}
function formatOutputPath(
attachment: StudioFileAttachment | null | undefined,
session: StudioSession | null,
inlinePreviewLabel: string,
waitingOutputLabel: string,
) {
if (attachment?.name) {
return attachment.name
}
if (attachment?.path) {
if (attachment.path.startsWith('data:')) {
return inlinePreviewLabel
}
return truncateStudioText(attachment.path, 88)
}
return session?.directory ?? waitingOutputLabel
}
function isImageAttachment(attachment: { path: string; mimeType?: string } | undefined) {
if (!attachment) {
return false
}
return attachment.mimeType?.startsWith('image/') || isImagePath(attachment.path)
}
function isImagePath(path?: string) {
return Boolean(path && /\.(png|jpg|jpeg|gif|webp|svg)$/i.test(path))
}
function PlotCornerPaw({ className = '' }: { className?: string }) {
return (
<svg viewBox="0 0 64 64" aria-hidden="true" className={`studio-paw-float ${className}`.trim()}>
<g fill="currentColor">
<ellipse cx="20" cy="18" rx="6" ry="8" transform="rotate(-18 20 18)" />
<ellipse cx="32" cy="13" rx="6" ry="8" />
<ellipse cx="44" cy="18" rx="6" ry="8" transform="rotate(18 44 18)" />
<ellipse cx="18" cy="31" rx="5" ry="7" transform="rotate(-30 18 31)" />
<path d="M32 28c-10 0-18 7-18 16 0 7 6 11 11 11 3 0 5-1 7-3 2 2 4 3 7 3 5 0 11-4 11-11 0-9-8-16-18-16Z" />
</g>
</svg>
)
}