返回 oh-my-ppt
BrowseView.tsx
根目录 / src / renderer / src / components / session-detail / browse / BrowseView.tsx
1 import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
2 import {
3 Copy,
4 Image as ImageIcon,
5 Loader2,
6 Move,
7 PencilLine,
8 Presentation,
9 Trash2
10 } from 'lucide-react'
11 import {
12 DndContext,
13 PointerSensor,
14 closestCenter,
15 useSensor,
16 useSensors,
17 type DragEndEvent
18 } from '@dnd-kit/core'
19 import { SortableContext, arrayMove, rectSortingStrategy, useSortable } from '@dnd-kit/sortable'
20 import { CSS } from '@dnd-kit/utilities'
21 import {
22 isStyleSwitchPageLocked,
23 useGenerateStore,
24 useSessionDetailUiStore,
25 useSessionStore
26 } from '@renderer/store'
27 import { isPageGenerationLocked, normalizePagesForSelection } from '../shared'
28 import type { SessionPreviewPage } from '../shared/types'
29 import { PreviewIframe } from '../../preview/PreviewIframe'
30 import { ScrollArea } from '../../ui/ScrollArea'
31 import { useT } from '@renderer/i18n'
32 import { limitBrowsePreviewIds } from './browse-preview-utils'
33 import { useSessionReorderPages } from '../hooks/useSessionReorderPages'
34 import { useSessionPageActions } from '../hooks/useSessionPageActions'
35 import { Tooltip, TooltipContent, TooltipTrigger } from '../../ui/Tooltip'
36 import { isDefaultSlideSize, trySessionSlideSize } from '@shared/slide-size'
37 import {
38 DropdownMenu,
39 DropdownMenuContent,
40 DropdownMenuItem,
41 DropdownMenuTrigger
42 } from '../../ui/DropdownMenu'
43
44 /** Keep recently-scrolled-past webviews alive as buffer */
45 const VISIBLE_CACHE = 20
46
47 const BrowseCard = memo(function BrowseCard({
48 page,
49 previewVersion,
50 renderPreview
51 }: {
52 page: SessionPreviewPage
53 previewVersion: number
54 renderPreview: boolean
55 }): React.JSX.Element {
56 const currentSession = useSessionStore((state) => state.currentSession)
57 const slideSize = trySessionSlideSize(currentSession)
58 const isGeneratingPlaceholder = page.status === 'generating' || page.status === 'pending'
59 if (!slideSize) {
60 return (
61 <div className="group overflow-hidden rounded-[4px] bg-white/60 shadow-[0_4px_16px_rgba(93,107,77,0.08)]">
62 <div className="relative flex h-[220px] w-full items-center justify-center overflow-hidden rounded-t-[4px] bg-[#f5f1e8]/88" />
63 </div>
64 )
65 }
66 const thumbnailFitStyle =
67 slideSize.width >= slideSize.height
68 ? { width: '100%', aspectRatio: `${slideSize.width}/${slideSize.height}` }
69 : { height: '100%', aspectRatio: `${slideSize.width}/${slideSize.height}` }
70 return (
71 <div className="group overflow-hidden rounded-[4px] bg-white/60 shadow-[0_4px_16px_rgba(93,107,77,0.08)] transition-shadow hover:shadow-[0_8px_24px_rgba(93,107,77,0.14)]">
72 <div
73 className="relative flex h-[220px] w-full items-center justify-center overflow-hidden rounded-t-[4px] bg-[#f5f1e8]/88"
74 style={{ contain: 'paint' }}
75 >
76 <div className="relative max-h-full max-w-full overflow-hidden" style={thumbnailFitStyle}>
77 {isGeneratingPlaceholder ? (
78 <div
79 className="flex h-full w-full items-center justify-center bg-[#f8f4eb] text-[#5d6b4d]"
80 aria-live="polite"
81 >
82 <Loader2 className="h-5 w-5 animate-spin" />
83 </div>
84 ) : renderPreview ? (
85 <PreviewIframe
86 key={`browse-${page.id}-${previewVersion}`}
87 src={page.sourceUrl}
88 htmlPath={page.htmlPath}
89 pageId={page.pageId}
90 title={`browse-page-${page.pageNumber}`}
91 slideSize={slideSize}
92 inspectable={false}
93 thumbnail
94 />
95 ) : (
96 <div className="flex h-full w-full items-center justify-center text-[11px] font-semibold uppercase tracking-[0.12em] text-[#8a9a7b]">
97 P{page.pageNumber}
98 </div>
99 )}
100 </div>
101 </div>
102 <div className="px-3 py-2.5">
103 <span className="inline-block rounded-full bg-[#d4e4c1]/60 px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.08em] text-[#5c6c47]">
104 P{page.pageNumber}
105 </span>
106 <p
107 className="mt-1 text-[12px] font-medium leading-4 text-[#4c5d3d]"
108 style={{
109 display: '-webkit-box',
110 WebkitLineClamp: 2,
111 WebkitBoxOrient: 'vertical',
112 overflow: 'hidden',
113 // leading-4 = 1rem/line; reserve 2 lines so short titles don't
114 // shrink the card and misalign the row.
115 minHeight: 'calc(2 * 1rem)'
116 }}
117 >
118 {page.title}
119 </p>
120 </div>
121 </div>
122 )
123 })
124
125 function SortableBrowseCard({
126 page,
127 previewVersion,
128 renderPreview,
129 disabled,
130 structureDisabled,
131 dragHandleLabel,
132 exportLabel,
133 exportEditableLabel,
134 exportImageOnlyLabel,
135 isExportingPptx,
136 canExportPptx,
137 pageCount,
138 onExportPagePptx,
139 onRenamePage,
140 onDuplicatePage,
141 onDeletePage,
142 registerRef,
143 renameLabel,
144 duplicateLabel,
145 deleteLabel
146 }: {
147 page: SessionPreviewPage
148 previewVersion: number
149 renderPreview: boolean
150 disabled: boolean
151 structureDisabled: boolean
152 dragHandleLabel: string
153 exportLabel: string
154 exportEditableLabel: string
155 exportImageOnlyLabel: string
156 isExportingPptx: boolean
157 canExportPptx: boolean
158 pageCount: number
159 onExportPagePptx: (page: SessionPreviewPage, options?: { imageOnly?: boolean }) => void
160 onRenamePage: (page: SessionPreviewPage) => void
161 onDuplicatePage: (page: SessionPreviewPage) => void
162 onDeletePage: (page: SessionPreviewPage) => void
163 registerRef: (el: HTMLElement | null) => void
164 renameLabel: string
165 duplicateLabel: string
166 deleteLabel: string
167 }): React.JSX.Element {
168 const {
169 attributes,
170 listeners,
171 setActivatorNodeRef,
172 setNodeRef,
173 transform,
174 transition,
175 isDragging
176 } = useSortable({
177 id: page.id,
178 disabled: structureDisabled
179 })
180 // Stable composed ref so React doesn't detach/reattach on every render
181 // (which would re-register every dnd-kit droppable + re-observe every card
182 // on each scroll-driven render). Requires registerRef to be stable.
183 const setRef = useCallback(
184 (el: HTMLElement | null) => {
185 setNodeRef(el)
186 registerRef(el)
187 },
188 [setNodeRef, registerRef]
189 )
190 return (
191 <div
192 ref={setRef}
193 data-browse-card-id={page.id}
194 style={{
195 transform: CSS.Transform.toString(transform),
196 transition,
197 opacity: isDragging ? 0.8 : 1
198 }}
199 className="group relative"
200 >
201 <div className="absolute inset-x-2 top-2 z-10 flex items-start justify-between opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
202 <Tooltip>
203 <TooltipTrigger asChild>
204 <button
205 type="button"
206 ref={setActivatorNodeRef}
207 disabled={structureDisabled}
208 onClick={(event) => event.stopPropagation()}
209 className={`inline-flex h-8 w-8 items-center justify-center rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50 ${
210 isDragging ? 'cursor-grabbing' : 'cursor-grab'
211 }`}
212 aria-label={dragHandleLabel}
213 {...attributes}
214 {...listeners}
215 >
216 <Move className={`h-4 w-4 ${isDragging ? 'opacity-60' : ''}`} />
217 </button>
218 </TooltipTrigger>
219 <TooltipContent side="bottom" align="start">
220 {dragHandleLabel}
221 </TooltipContent>
222 </Tooltip>
223 <div className="flex items-center gap-1">
224 {canExportPptx ? (
225 <DropdownMenu>
226 <Tooltip>
227 <TooltipTrigger asChild>
228 <DropdownMenuTrigger asChild>
229 <button
230 type="button"
231 disabled={disabled || isExportingPptx}
232 onClick={(event) => event.stopPropagation()}
233 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
234 aria-label={exportLabel}
235 >
236 <Presentation className="h-3.5 w-3.5" />
237 </button>
238 </DropdownMenuTrigger>
239 </TooltipTrigger>
240 <TooltipContent side="bottom">{exportLabel}</TooltipContent>
241 </Tooltip>
242 <DropdownMenuContent
243 side="bottom"
244 align="end"
245 className="min-w-[9rem]"
246 onClick={(event) => event.stopPropagation()}
247 >
248 <DropdownMenuItem onSelect={() => onExportPagePptx(page)}>
249 <Presentation className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
250 <span className="whitespace-nowrap">{exportEditableLabel}</span>
251 </DropdownMenuItem>
252 <DropdownMenuItem onSelect={() => onExportPagePptx(page, { imageOnly: true })}>
253 <ImageIcon className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
254 <span className="whitespace-nowrap">{exportImageOnlyLabel}</span>
255 </DropdownMenuItem>
256 </DropdownMenuContent>
257 </DropdownMenu>
258 ) : null}
259 <Tooltip>
260 <TooltipTrigger asChild>
261 <button
262 type="button"
263 disabled={disabled}
264 onClick={(event) => {
265 event.stopPropagation()
266 onRenamePage(page)
267 }}
268 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
269 aria-label={renameLabel}
270 >
271 <PencilLine className="h-3.5 w-3.5" />
272 </button>
273 </TooltipTrigger>
274 <TooltipContent side="bottom">{renameLabel}</TooltipContent>
275 </Tooltip>
276 <Tooltip>
277 <TooltipTrigger asChild>
278 <button
279 type="button"
280 disabled={disabled || structureDisabled}
281 onClick={(event) => {
282 event.stopPropagation()
283 onDuplicatePage(page)
284 }}
285 className="rounded bg-white/90 p-1 text-[#5d6b4d] shadow-sm transition-colors hover:bg-[#f5f1e8] hover:text-[#3e4a32] disabled:cursor-not-allowed disabled:opacity-50"
286 aria-label={duplicateLabel}
287 >
288 <Copy className="h-3.5 w-3.5" />
289 </button>
290 </TooltipTrigger>
291 <TooltipContent side="bottom">{duplicateLabel}</TooltipContent>
292 </Tooltip>
293 <Tooltip>
294 <TooltipTrigger asChild>
295 <button
296 type="button"
297 disabled={disabled || structureDisabled || pageCount <= 1}
298 onClick={(event) => {
299 event.stopPropagation()
300 onDeletePage(page)
301 }}
302 className="rounded bg-white/90 p-1 shadow-sm transition-colors hover:bg-[#f5f1e8] disabled:cursor-not-allowed disabled:opacity-50"
303 aria-label={deleteLabel}
304 >
305 <Trash2 className="h-4 w-4" />
306 </button>
307 </TooltipTrigger>
308 <TooltipContent side="bottom" align="end">
309 {deleteLabel}
310 </TooltipContent>
311 </Tooltip>
312 </div>
313 </div>
314 <BrowseCard page={page} previewVersion={previewVersion} renderPreview={renderPreview} />
315 </div>
316 )
317 }
318
319 export function BrowseView(props: { sessionId: string }): React.JSX.Element {
320 const { sessionId } = props
321 const t = useT()
322 const currentPages = useGenerateStore((state) => state.currentPages)
323 const currentSession = useSessionStore((state) => state.currentSession)
324 const slideSize = trySessionSlideSize(currentSession)
325 const canExportPptx = slideSize ? isDefaultSlideSize(slideSize) : false
326 const isGenerating = useGenerateStore((state) => state.isGenerating)
327 const isDeckEditing = useGenerateStore((state) => Boolean(state.deckEditJobs[sessionId]))
328 const pageEditJob = useGenerateStore((state) => state.pageEditJobs[sessionId] || null)
329 const styleSwitchJob = useGenerateStore((state) => state.styleSwitchJobs[sessionId] || null)
330 const previewKey = useSessionDetailUiStore((state) => state.previewKey)
331 const thumbnailVersions = useSessionDetailUiStore((state) => state.thumbnailVersions)
332 const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage)
333 const addingPageId = useSessionDetailUiStore((state) => state.addingPageId)
334 const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage)
335 const retryingSinglePageId = useSessionDetailUiStore((state) => state.retryingSinglePageId)
336 const isManagingPages = useSessionDetailUiStore((state) => state.isManagingPages)
337 const { reorder: reorderSessionPages } = useSessionReorderPages(sessionId)
338 const pageActions = useSessionPageActions(sessionId)
339
340 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
341 const hasPageScopedGeneration =
342 isAddingPage ||
343 isRetryingSinglePage ||
344 Boolean(pageEditJob) ||
345 Boolean(styleSwitchJob)
346 const isSessionWideGenerating = isGenerating && !hasPageScopedGeneration
347 const structureDisabled =
348 isGenerating || isDeckEditing || isAddingPage || isRetryingSinglePage || isManagingPages
349 const isPageActionDisabled = (page: SessionPreviewPage): boolean =>
350 isSessionWideGenerating ||
351 isDeckEditing ||
352 isManagingPages ||
353 isPageGenerationLocked(page.id, {
354 isAddingPage,
355 addingPageId,
356 isRetryingSinglePage,
357 retryingSinglePageId
358 }) ||
359 pageEditJob?.pageId === page.pageId ||
360 isStyleSwitchPageLocked(styleSwitchJob, page.pageId)
361
362 const pageIds = useMemo(() => new Set(pages.map((page) => page.id)), [pages])
363 const [visibleIds, setVisibleIds] = useState<Set<string>>(() => new Set())
364 const observerRef = useRef<IntersectionObserver | null>(null)
365 const viewportRef = useRef<HTMLDivElement>(null)
366 const cardRefsRef = useRef<Map<string, HTMLElement>>(new Map())
367 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
368
369 useEffect(() => {
370 setVisibleIds((current) => {
371 const next = new Set(Array.from(current).filter((id) => pageIds.has(id)))
372 return next.size === current.size ? current : next
373 })
374 }, [pageIds])
375
376 useEffect(() => {
377 if (pages.length === 0) return
378
379 const observer = new IntersectionObserver(
380 (entries) => {
381 setVisibleIds((prev) => {
382 const next = new Set(prev)
383 let changed = false
384 for (const entry of entries) {
385 const id = (entry.target as HTMLElement).dataset.browseCardId
386 if (!id) continue
387 if (entry.isIntersecting) {
388 if (!next.has(id)) {
389 next.add(id)
390 changed = true
391 }
392 } else if (next.delete(id)) {
393 changed = true
394 }
395 }
396 return changed ? next : prev
397 })
398 },
399 {
400 root: viewportRef.current,
401 rootMargin: '200px 100px',
402 threshold: 0
403 }
404 )
405 observerRef.current = observer
406
407 for (const el of cardRefsRef.current.values()) {
408 observer.observe(el)
409 }
410
411 return () => {
412 observer.disconnect()
413 observerRef.current = null
414 }
415 }, [pages.length])
416
417 const renderableIds = useMemo(
418 () => limitBrowsePreviewIds(visibleIds, VISIBLE_CACHE),
419 [visibleIds]
420 )
421
422 // Stable per-id ref callbacks (cached) so the composed ref in
423 // SortableBrowseCard stays referentially stable across renders.
424 const cardRefCallbacksRef = useRef<Map<string, (el: HTMLElement | null) => void>>(new Map())
425 const getCardRef = useCallback((pageId: string) => {
426 let cb = cardRefCallbacksRef.current.get(pageId)
427 if (!cb) {
428 cb = (el: HTMLElement | null) => {
429 const map = cardRefsRef.current
430 if (el) {
431 map.set(pageId, el)
432 observerRef.current?.observe(el)
433 } else {
434 const old = map.get(pageId)
435 if (old) {
436 observerRef.current?.unobserve(old)
437 map.delete(pageId)
438 }
439 }
440 }
441 cardRefCallbacksRef.current.set(pageId, cb)
442 }
443 return cb
444 }, [])
445
446 if (pages.length === 0) {
447 return (
448 <div className="flex flex-1 items-center justify-center">
449 <p className="text-sm text-[#8a9a7b]">{t('sessionDetail.pagesEmpty')}</p>
450 </div>
451 )
452 }
453
454 const onDragEnd = (event: DragEndEvent): void => {
455 const { active, over } = event
456 if (!over || active.id === over.id) return
457 if (structureDisabled) return
458 const oldIndex = pages.findIndex((page) => page.id === String(active.id))
459 const newIndex = pages.findIndex((page) => page.id === String(over.id))
460 if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
461 const next = arrayMove(pages, oldIndex, newIndex)
462 void reorderSessionPages(
463 next.map((page) => page.id),
464 String(active.id)
465 )
466 }
467
468 return (
469 <div className="flex min-h-0 flex-1 flex-col">
470 <ScrollArea className="flex-1" viewportRef={viewportRef}>
471 <div className="p-6">
472 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
473 <SortableContext items={pages.map((page) => page.id)} strategy={rectSortingStrategy}>
474 <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
475 {pages.map((page) => {
476 const previewVersion = previewKey + (thumbnailVersions[page.pageId] || 0)
477 return (
478 <SortableBrowseCard
479 key={page.id}
480 page={page}
481 previewVersion={previewVersion}
482 renderPreview={renderableIds.has(page.id)}
483 disabled={isPageActionDisabled(page)}
484 structureDisabled={structureDisabled}
485 dragHandleLabel={t('pageManagement.dragHandle')}
486 exportLabel={t('sessionDetail.exportSinglePagePptx')}
487 exportEditableLabel={t('sessionDetail.exportPptxEditable')}
488 exportImageOnlyLabel={t('sessionDetail.exportPptxImageOnly')}
489 isExportingPptx={pageActions.isExportingPptx}
490 canExportPptx={canExportPptx}
491 pageCount={pages.length}
492 onExportPagePptx={pageActions.exportPagePptx}
493 onRenamePage={pageActions.renamePage}
494 onDuplicatePage={pageActions.duplicatePage}
495 onDeletePage={pageActions.deletePage}
496 registerRef={getCardRef(page.id)}
497 renameLabel={t('pageManagement.editPageTitle')}
498 duplicateLabel={t('pageManagement.duplicatePage')}
499 deleteLabel={t('pageManagement.deletePage')}
500 />
501 )
502 })}
503 </div>
504 </SortableContext>
505 </DndContext>
506 </div>
507 </ScrollArea>
508 </div>
509 )
510 }
511
511 lines Plain Text