返回 presentation-ai
OutlineList.tsx
1 import {
2 closestCenter,
3 DndContext,
4 KeyboardSensor,
5 PointerSensor,
6 useSensor,
7 useSensors,
8 type DragEndEvent,
9 } from "@dnd-kit/core";
10 import {
11 arrayMove,
12 SortableContext,
13 sortableKeyboardCoordinates,
14 verticalListSortingStrategy,
15 } from "@dnd-kit/sortable";
16 import { LayoutGrid, Plus } from "lucide-react";
17 import { useCallback, useEffect, useMemo, useState } from "react";
18
19 import { Badge } from "@/components/ui/badge";
20 import { Button } from "@/components/ui/button";
21 import { Skeleton } from "@/components/ui/skeleton";
22 import { usePresentationState } from "@/states/presentation-state";
23 import {
24 getTemplateSelectionIds,
25 TEMPLATE_DEFINITIONS,
26 } from "../../utils/templates";
27 import { OutlineItem } from "./OutlineItem";
28 import { OutlineTemplateModal } from "./OutlineTemplateModal";
29 import { persistOutlineLayoutSelection } from "./persistOutlineLayoutSelection";
30
31 interface OutlineItemType {
32 id: string;
33 title: string;
34 }
35
36 function areItemTitlesEqual(
37 items: OutlineItemType[],
38 titles: string[],
39 ): boolean {
40 return (
41 items.length === titles.length &&
42 items.every((item, index) => item.title === titles[index])
43 );
44 }
45
46 function areDefaultOutlineIds(items: OutlineItemType[]): boolean {
47 return items.every((item, index) => item.id === (index + 1).toString());
48 }
49
50 function shouldAdoptPersistedOutlineIds(
51 items: OutlineItemType[],
52 persistedIds: string[],
53 ): boolean {
54 return (
55 persistedIds.length === items.length &&
56 areDefaultOutlineIds(items) &&
57 items.some((item, index) => item.id !== persistedIds[index])
58 );
59 }
60
61 function createOutlineItems(
62 titles: string[],
63 existingItems: OutlineItemType[] = [],
64 persistedIds: string[] = [],
65 ): OutlineItemType[] {
66 return titles.map((title, index) => ({
67 id:
68 existingItems[index]?.id ?? persistedIds[index] ?? (index + 1).toString(),
69 title,
70 }));
71 }
72
73 export function OutlineList() {
74 const {
75 outline: initialItems,
76 setOutline,
77 numSlides,
78 isGeneratingOutline,
79 webSearchEnabled,
80 selectedSlideTemplates,
81 outlineItemIds,
82 outlineTemplateOverrides,
83 setOutlineTemplateOverride,
84 setOutlineItemIds,
85 } = usePresentationState();
86
87 const [items, setItems] = useState<OutlineItemType[]>(() =>
88 createOutlineItems(initialItems, [], outlineItemIds),
89 );
90
91 const [isTemplateModalOpen, setIsTemplateModalOpen] = useState(false);
92 const [singleModeOutlineId, setSingleModeOutlineId] = useState<string | null>(
93 null,
94 );
95
96 // Get available templates for dropdown (filtered to selected ones)
97 const availableTemplates = useMemo(() => {
98 const selectedTemplates: Array<{ id: string; name: string }> = [];
99 const selectedTemplateIds = new Set(selectedSlideTemplates);
100
101 for (const template of TEMPLATE_DEFINITIONS) {
102 const hasSelectedId = getTemplateSelectionIds(template).some((id) =>
103 selectedTemplateIds.has(id),
104 );
105
106 if (hasSelectedId) {
107 selectedTemplates.push({ id: template.id, name: template.name });
108 }
109 }
110
111 return selectedTemplates;
112 }, [selectedSlideTemplates]);
113
114 useEffect(() => {
115 setItems((previousItems) => {
116 if (areItemTitlesEqual(previousItems, initialItems)) {
117 if (shouldAdoptPersistedOutlineIds(previousItems, outlineItemIds)) {
118 return createOutlineItems(initialItems, [], outlineItemIds);
119 }
120
121 return previousItems;
122 }
123
124 return createOutlineItems(initialItems, previousItems, outlineItemIds);
125 });
126 }, [initialItems, outlineItemIds]);
127
128 useEffect(() => {
129 setOutlineItemIds(items.map((item) => item.id));
130 }, [items, setOutlineItemIds]);
131
132 const sensors = useSensors(
133 useSensor(PointerSensor),
134 useSensor(KeyboardSensor, {
135 coordinateGetter: sortableKeyboardCoordinates,
136 }),
137 );
138
139 const syncOutline = useCallback(
140 (nextItems: OutlineItemType[]) => {
141 setOutline(nextItems.map((item) => item.title));
142 },
143 [setOutline],
144 );
145
146 const handleDragEnd = useCallback(
147 (event: DragEndEvent) => {
148 const { active, over } = event;
149
150 if (over && active.id !== over.id) {
151 const oldIndex = items.findIndex((item) => item.id === active.id);
152 const newIndex = items.findIndex((item) => item.id === over.id);
153
154 if (oldIndex < 0 || newIndex < 0) {
155 return;
156 }
157
158 const nextItems = arrayMove(items, oldIndex, newIndex);
159 setItems(nextItems);
160 syncOutline(nextItems);
161 }
162 },
163 [items, syncOutline],
164 );
165
166 const handleTitleChange = useCallback(
167 (id: string, newTitle: string) => {
168 const nextItems = items.map((item) =>
169 item.id === id ? { ...item, title: newTitle } : item,
170 );
171 setItems(nextItems);
172 syncOutline(nextItems);
173 },
174 [items, syncOutline],
175 );
176
177 const handleAddCard = useCallback(() => {
178 const newId = crypto.randomUUID();
179 const nextItems = [...items, { id: newId, title: "New Card" }];
180 setItems(nextItems);
181 syncOutline(nextItems);
182 }, [items, syncOutline]);
183
184 const handleDeleteCard = useCallback(
185 (id: string) => {
186 const nextItems = items.filter((item) => item.id !== id);
187 setItems(nextItems);
188 syncOutline(nextItems);
189 },
190 [items, syncOutline],
191 );
192
193 const handleTemplateChange = useCallback(
194 (outlineId: string, templateId: string | null) => {
195 setOutlineTemplateOverride(outlineId, templateId);
196 void persistOutlineLayoutSelection();
197 },
198 [setOutlineTemplateOverride],
199 );
200
201 const content = useMemo(() => {
202 const totalSlides = numSlides;
203 const loadedCount = items.length;
204 const remainingCount = Math.max(0, totalSlides - loadedCount);
205
206 const showLoadingSkeletons = isGeneratingOutline && remainingCount > 0;
207
208 return (
209 <DndContext
210 sensors={sensors}
211 collisionDetection={closestCenter}
212 onDragEnd={handleDragEnd}
213 >
214 <SortableContext
215 items={items.map((item) => item.id)}
216 strategy={verticalListSortingStrategy}
217 >
218 <div className="space-y-2">
219 {items.map((item, index) => {
220 const selectedTemplateId =
221 outlineTemplateOverrides[item.id] ?? null;
222
223 return (
224 <OutlineItem
225 key={item.id}
226 id={item.id}
227 index={index + 1}
228 title={item.title}
229 onTitleChange={handleTitleChange}
230 onDelete={handleDeleteCard}
231 showLayoutControl={
232 availableTemplates.length > 0 || selectedTemplateId !== null
233 }
234 selectedTemplateId={selectedTemplateId}
235 onTemplateChange={(templateId) => {
236 handleTemplateChange(item.id, templateId);
237 }}
238 availableTemplates={availableTemplates}
239 disabled={isGeneratingOutline}
240 onOpenTemplateModal={() => {
241 setSingleModeOutlineId(item.id);
242 setIsTemplateModalOpen(true);
243 }}
244 />
245 );
246 })}
247 </div>
248 </SortableContext>
249 {/* Show loading skeletons only when actually generating */}
250 {showLoadingSkeletons &&
251 Array.from({ length: remainingCount }).map((_, index) => (
252 <Skeleton key={`loading-${index}`} className="h-16 w-full" />
253 ))}
254 </DndContext>
255 );
256 }, [
257 items,
258 numSlides,
259 isGeneratingOutline,
260 sensors,
261 handleDragEnd,
262 handleTitleChange,
263 handleDeleteCard,
264 outlineTemplateOverrides,
265 handleTemplateChange,
266 availableTemplates,
267 ]);
268
269 const hideOutline =
270 webSearchEnabled && items.length === 0 && !isGeneratingOutline;
271 if (hideOutline) {
272 return null;
273 }
274
275 return (
276 <div className="space-y-4">
277 <div className="flex items-center justify-between">
278 <h2 className="text-sm text-foreground">Outline</h2>
279 <div className="flex items-center gap-2">
280 {isGeneratingOutline && items.length > 0 && (
281 <span className="animate-pulse text-xs text-muted-foreground">
282 Generating&hellip;
283 </span>
284 )}
285 <Button
286 variant="ghost"
287 size="sm"
288 onClick={() => {
289 setSingleModeOutlineId(null);
290 setIsTemplateModalOpen(true);
291 }}
292 className="h-7 gap-1.5 px-2 text-xs"
293 >
294 <LayoutGrid className="size-3.5" />
295 Layouts
296 {selectedSlideTemplates.length > 0 && (
297 <Badge
298 variant="secondary"
299 className="ml-0.5 h-4 px-1 text-[10px]"
300 >
301 {selectedSlideTemplates.length}
302 </Badge>
303 )}
304 </Button>
305 </div>
306 </div>
307
308 {content}
309
310 <button
311 type="button"
312 onClick={handleAddCard}
313 disabled={isGeneratingOutline}
314 className="flex w-full items-center justify-center gap-2 rounded-md bg-muted/50 py-3 text-muted-foreground transition-colors hover:bg-muted disabled:opacity-50"
315 >
316 <Plus size={20} />
317 Add card
318 </button>
319
320 <div className="flex justify-between text-sm text-muted-foreground">
321 <span>{items.length} cards total</span>
322 <span>
323 {items.reduce((acc, item) => acc + item.title.length, 0)}/20000
324 </span>
325 </div>
326
327 <OutlineTemplateModal
328 isOpen={isTemplateModalOpen}
329 onClose={() => {
330 setIsTemplateModalOpen(false);
331 setSingleModeOutlineId(null);
332 }}
333 mode={singleModeOutlineId ? "single" : "global"}
334 outlineId={singleModeOutlineId ?? undefined}
335 />
336 </div>
337 );
338 }
339
339 lines Plain Text