| 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> |
| ) |
| } |
|
|