返回 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 <button
203 type="button"
204 ref={setActivatorNodeRef}
205 disabled={structureDisabled}
206 onClick={(event) => event.stopPropagation()}
207 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 ${
208 isDragging ? 'cursor-grabbing' : 'cursor-grab'
209 }`}
210 aria-label={dragHandleLabel}
211 title={dragHandleLabel}
212 {...attributes}
213 {...listeners}
214 >
215 <Move className={`h-4 w-4 ${isDragging ? 'opacity-60' : ''}`} />
216 </button>
217 <div className="flex items-center gap-1">
218 {canExportPptx ? (
219 <DropdownMenu>
220 <Tooltip>
221 <TooltipTrigger asChild>
222 <DropdownMenuTrigger asChild>
223 <button
224 type="button"
225 disabled={disabled || isExportingPptx}
226 onClick={(event) => event.stopPropagation()}
227 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"
228 aria-label={exportLabel}
229 >
230 <Presentation className="h-3.5 w-3.5" />
231 </button>
232 </DropdownMenuTrigger>
233 </TooltipTrigger>
234 <TooltipContent side="bottom">{exportLabel}</TooltipContent>
235 </Tooltip>
236 <DropdownMenuContent
237 side="bottom"
238 align="end"
239 className="min-w-[9rem]"
240 onClick={(event) => event.stopPropagation()}
241 >
242 <DropdownMenuItem onSelect={() => onExportPagePptx(page)}>
243 <Presentation className="h-3.5 w-3.5 shrink-0 text-[#5f6b50]" />
244 <span className="whitespace-nowrap">{exportEditableLabel}</span>
245 </DropdownMenuItem>
246 <DropdownMenuItem onSelect={() => onExportPagePptx(page, { imageOnly: true })}>
247 <ImageIcon className="h-3.5 w-3.5 shrink-0 text-[#7c6a4c]" />
248 <span className="whitespace-nowrap">{exportImageOnlyLabel}</span>
249 </DropdownMenuItem>
250 </DropdownMenuContent>
251 </DropdownMenu>
252 ) : null}
253 <button
254 type="button"
255 disabled={disabled}
256 onClick={(event) => {
257 event.stopPropagation()
258 onRenamePage(page)
259 }}
260 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"
261 aria-label={renameLabel}
262 title={renameLabel}
263 >
264 <PencilLine className="h-3.5 w-3.5" />
265 </button>
266 <button
267 type="button"
268 disabled={disabled || structureDisabled}
269 onClick={(event) => {
270 event.stopPropagation()
271 onDuplicatePage(page)
272 }}
273 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"
274 aria-label={duplicateLabel}
275 title={duplicateLabel}
276 >
277 <Copy className="h-3.5 w-3.5" />
278 </button>
279 <button
280 type="button"
281 disabled={disabled || structureDisabled || pageCount <= 1}
282 onClick={(event) => {
283 event.stopPropagation()
284 onDeletePage(page)
285 }}
286 className="rounded bg-white/90 p-1 shadow-sm transition-colors hover:bg-[#f5f1e8] disabled:cursor-not-allowed disabled:opacity-50"
287 aria-label={deleteLabel}
288 title={deleteLabel}
289 >
290 <Trash2 className="h-4 w-4" />
291 </button>
292 </div>
293 </div>
294 <BrowseCard page={page} previewVersion={previewVersion} renderPreview={renderPreview} />
295 </div>
296 )
297 }
298
299 export function BrowseView(props: { sessionId: string }): React.JSX.Element {
300 const { sessionId } = props
301 const t = useT()
302 const currentPages = useGenerateStore((state) => state.currentPages)
303 const currentSession = useSessionStore((state) => state.currentSession)
304 const slideSize = trySessionSlideSize(currentSession)
305 const canExportPptx = slideSize ? isDefaultSlideSize(slideSize) : false
306 const isGenerating = useGenerateStore((state) => state.isGenerating)
307 const isDeckEditing = useGenerateStore((state) => Boolean(state.deckEditJobs[sessionId]))
308 const pageEditJob = useGenerateStore((state) => state.pageEditJobs[sessionId] || null)
309 const pageBeautifyJob = useGenerateStore((state) => state.pageBeautifyJobs[sessionId] || null)
310 const styleSwitchJob = useGenerateStore((state) => state.styleSwitchJobs[sessionId] || null)
311 const previewKey = useSessionDetailUiStore((state) => state.previewKey)
312 const thumbnailVersions = useSessionDetailUiStore((state) => state.thumbnailVersions)
313 const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage)
314 const addingPageId = useSessionDetailUiStore((state) => state.addingPageId)
315 const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage)
316 const retryingSinglePageId = useSessionDetailUiStore((state) => state.retryingSinglePageId)
317 const isManagingPages = useSessionDetailUiStore((state) => state.isManagingPages)
318 const { reorder: reorderSessionPages } = useSessionReorderPages(sessionId)
319 const pageActions = useSessionPageActions(sessionId)
320
321 const pages = useMemo(() => normalizePagesForSelection(currentPages), [currentPages])
322 const hasPageScopedGeneration =
323 isAddingPage ||
324 isRetryingSinglePage ||
325 Boolean(pageEditJob) ||
326 Boolean(pageBeautifyJob) ||
327 Boolean(styleSwitchJob)
328 const isSessionWideGenerating = isGenerating && !hasPageScopedGeneration
329 const structureDisabled =
330 isGenerating || isDeckEditing || isAddingPage || isRetryingSinglePage || isManagingPages
331 const isPageActionDisabled = (page: SessionPreviewPage): boolean =>
332 isSessionWideGenerating ||
333 isDeckEditing ||
334 isManagingPages ||
335 isPageGenerationLocked(page.id, {
336 isAddingPage,
337 addingPageId,
338 isRetryingSinglePage,
339 retryingSinglePageId
340 }) ||
341 pageEditJob?.pageId === page.pageId ||
342 pageBeautifyJob?.pageId === page.pageId ||
343 isStyleSwitchPageLocked(styleSwitchJob, page.pageId)
344
345 const pageIds = useMemo(() => new Set(pages.map((page) => page.id)), [pages])
346 const [visibleIds, setVisibleIds] = useState<Set<string>>(() => new Set())
347 const observerRef = useRef<IntersectionObserver | null>(null)
348 const viewportRef = useRef<HTMLDivElement>(null)
349 const cardRefsRef = useRef<Map<string, HTMLElement>>(new Map())
350 const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }))
351
352 useEffect(() => {
353 setVisibleIds((current) => {
354 const next = new Set(Array.from(current).filter((id) => pageIds.has(id)))
355 return next.size === current.size ? current : next
356 })
357 }, [pageIds])
358
359 useEffect(() => {
360 if (pages.length === 0) return
361
362 const observer = new IntersectionObserver(
363 (entries) => {
364 setVisibleIds((prev) => {
365 const next = new Set(prev)
366 let changed = false
367 for (const entry of entries) {
368 const id = (entry.target as HTMLElement).dataset.browseCardId
369 if (!id) continue
370 if (entry.isIntersecting) {
371 if (!next.has(id)) {
372 next.add(id)
373 changed = true
374 }
375 } else if (next.delete(id)) {
376 changed = true
377 }
378 }
379 return changed ? next : prev
380 })
381 },
382 {
383 root: viewportRef.current,
384 rootMargin: '200px 100px',
385 threshold: 0
386 }
387 )
388 observerRef.current = observer
389
390 for (const el of cardRefsRef.current.values()) {
391 observer.observe(el)
392 }
393
394 return () => {
395 observer.disconnect()
396 observerRef.current = null
397 }
398 }, [pages.length])
399
400 const renderableIds = useMemo(
401 () => limitBrowsePreviewIds(visibleIds, VISIBLE_CACHE),
402 [visibleIds]
403 )
404
405 // Stable per-id ref callbacks (cached) so the composed ref in
406 // SortableBrowseCard stays referentially stable across renders.
407 const cardRefCallbacksRef = useRef<Map<string, (el: HTMLElement | null) => void>>(new Map())
408 const getCardRef = useCallback((pageId: string) => {
409 let cb = cardRefCallbacksRef.current.get(pageId)
410 if (!cb) {
411 cb = (el: HTMLElement | null) => {
412 const map = cardRefsRef.current
413 if (el) {
414 map.set(pageId, el)
415 observerRef.current?.observe(el)
416 } else {
417 const old = map.get(pageId)
418 if (old) {
419 observerRef.current?.unobserve(old)
420 map.delete(pageId)
421 }
422 }
423 }
424 cardRefCallbacksRef.current.set(pageId, cb)
425 }
426 return cb
427 }, [])
428
429 if (pages.length === 0) {
430 return (
431 <div className="flex flex-1 items-center justify-center">
432 <p className="text-sm text-[#8a9a7b]">{t('sessionDetail.pagesEmpty')}</p>
433 </div>
434 )
435 }
436
437 const onDragEnd = (event: DragEndEvent): void => {
438 const { active, over } = event
439 if (!over || active.id === over.id) return
440 if (structureDisabled) return
441 const oldIndex = pages.findIndex((page) => page.id === String(active.id))
442 const newIndex = pages.findIndex((page) => page.id === String(over.id))
443 if (oldIndex < 0 || newIndex < 0 || oldIndex === newIndex) return
444 const next = arrayMove(pages, oldIndex, newIndex)
445 void reorderSessionPages(
446 next.map((page) => page.id),
447 String(active.id)
448 )
449 }
450
451 return (
452 <div className="flex min-h-0 flex-1 flex-col">
453 <ScrollArea className="flex-1" viewportRef={viewportRef}>
454 <div className="p-6">
455 <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
456 <SortableContext items={pages.map((page) => page.id)} strategy={rectSortingStrategy}>
457 <div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-5">
458 {pages.map((page) => {
459 const previewVersion = previewKey + (thumbnailVersions[page.pageId] || 0)
460 return (
461 <SortableBrowseCard
462 key={page.id}
463 page={page}
464 previewVersion={previewVersion}
465 renderPreview={renderableIds.has(page.id)}
466 disabled={isPageActionDisabled(page)}
467 structureDisabled={structureDisabled}
468 dragHandleLabel={t('pageManagement.dragHandle')}
469 exportLabel={t('sessionDetail.exportSinglePagePptx')}
470 exportEditableLabel={t('sessionDetail.exportPptxEditable')}
471 exportImageOnlyLabel={t('sessionDetail.exportPptxImageOnly')}
472 isExportingPptx={pageActions.isExportingPptx}
473 canExportPptx={canExportPptx}
474 pageCount={pages.length}
475 onExportPagePptx={pageActions.exportPagePptx}
476 onRenamePage={pageActions.renamePage}
477 onDuplicatePage={pageActions.duplicatePage}
478 onDeletePage={pageActions.deletePage}
479 registerRef={getCardRef(page.id)}
480 renameLabel={t('pageManagement.editPageTitle')}
481 duplicateLabel={t('pageManagement.duplicatePage')}
482 deleteLabel={t('pageManagement.deletePage')}
483 />
484 )
485 })}
486 </div>
487 </SortableContext>
488 </DndContext>
489 </div>
490 </ScrollArea>
491 </div>
492 )
493 }
494
494 lines Plain Text