返回 presentation-ai
OutlineItem.tsx
1 import { useSortable } from "@dnd-kit/sortable";
2 import { CSS } from "@dnd-kit/utilities";
3 import { Check, ChevronDown, GripVertical, Plus, X } from "lucide-react";
4 import { memo, useState } from "react";
5
6 import ProseMirrorEditor from "@/components/prose-mirror/ProseMirrorEditor";
7 import {
8 Popover,
9 PopoverContent,
10 PopoverTrigger,
11 } from "@/components/ui/popover";
12 import { cn } from "@/lib/utils";
13 import {
14 getTemplateSelectionIds,
15 TEMPLATE_DEFINITIONS,
16 } from "../../utils/templates";
17
18 interface OutlineItemProps {
19 id: string;
20 index: number;
21 title: string;
22 onTitleChange: (id: string, newTitle: string) => void;
23 onDelete: (id: string) => void;
24 // Template selection props
25 showLayoutControl?: boolean;
26 selectedTemplateId?: string | null;
27 onTemplateChange?: (templateId: string | null) => void;
28 availableTemplates?: Array<{ id: string; name: string }>;
29 onOpenTemplateModal?: () => void;
30 disabled?: boolean;
31 }
32
33 // Wrap the component with memo to prevent unnecessary re-renders
34 export const OutlineItem = memo(function OutlineItem({
35 id,
36 index,
37 title,
38 onTitleChange,
39 onDelete,
40 showLayoutControl = false,
41 selectedTemplateId = null,
42 onTemplateChange,
43 availableTemplates = [],
44 onOpenTemplateModal,
45 disabled = false,
46 }: OutlineItemProps) {
47 const [isDropdownOpen, setIsDropdownOpen] = useState(false);
48
49 const {
50 attributes,
51 listeners,
52 setNodeRef,
53 transform,
54 transition,
55 isDragging,
56 } = useSortable({ id });
57
58 const style = {
59 transform: CSS.Translate.toString(transform),
60 transition,
61 };
62
63 const handleProseMirrorChange = (newContent: string) => {
64 onTitleChange(id, newContent);
65 };
66
67 const handleTemplateSelect = (templateId: string | null) => {
68 onTemplateChange?.(templateId);
69 setIsDropdownOpen(false);
70 };
71
72 const handleMoreClick = () => {
73 setIsDropdownOpen(false);
74 onOpenTemplateModal?.();
75 };
76
77 // Get display name for selected template
78 const selectedTemplateName = selectedTemplateId
79 ? (availableTemplates.find((t) => t.id === selectedTemplateId)?.name ??
80 TEMPLATE_DEFINITIONS.find((template) =>
81 getTemplateSelectionIds(template).includes(selectedTemplateId),
82 )?.name ??
83 "Custom")
84 : "Auto";
85
86 return (
87 <div
88 ref={setNodeRef}
89 style={style}
90 className={cn(
91 "group flex items-center gap-4 rounded-md bg-muted p-4",
92 isDragging && "opacity-50",
93 )}
94 >
95 <div
96 {...attributes}
97 {...listeners}
98 className="cursor-move text-muted-foreground hover:text-foreground"
99 >
100 <GripVertical size={20} />
101 </div>
102 <span className="min-w-6 text-primary">{index}</span>
103 <div className="flex-1">
104 <ProseMirrorEditor
105 content={title}
106 onChange={handleProseMirrorChange}
107 isEditing={!disabled}
108 className="prose-headings:m-0 prose-headings:text-lg prose-headings:font-semibold prose-p:m-0 prose-ol:m-0 prose-ul:m-0 prose-li:m-0"
109 showFloatingToolbar={!disabled}
110 />
111 </div>
112 {showLayoutControl && (
113 <Popover open={isDropdownOpen} onOpenChange={setIsDropdownOpen}>
114 <PopoverTrigger asChild>
115 <button
116 type="button"
117 disabled={disabled}
118 className={cn(
119 "flex h-8 items-center gap-1.5 rounded-md border border-border/50 bg-background/50 px-3 text-xs font-medium text-primary transition-colors",
120 "hover:border-border hover:bg-background/80 hover:text-foreground",
121 isDropdownOpen && "border-border bg-background/80 text-primary",
122 disabled && "pointer-events-none opacity-50",
123 )}
124 >
125 <span className="max-w-20 truncate">{selectedTemplateName}</span>
126 <ChevronDown className="size-3.5 opacity-70" />
127 </button>
128 </PopoverTrigger>
129 <PopoverContent
130 align="end"
131 sideOffset={4}
132 className="w-80 overflow-hidden p-1.5"
133 >
134 <div className="scrollbar-thin max-h-96 space-y-0.5 overflow-y-auto overscroll-contain pr-1 scrollbar-thumb-muted scrollbar-track-transparent">
135 {/* Auto option */}
136 <button
137 type="button"
138 onClick={() => handleTemplateSelect(null)}
139 className={cn(
140 "flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent",
141 !selectedTemplateId && "bg-accent",
142 )}
143 >
144 <div className="flex h-24 w-28 shrink-0 items-center justify-center overflow-hidden rounded border border-border bg-card">
145 <span className="text-[10px] text-muted-foreground">
146 Auto
147 </span>
148 </div>
149 <span className="flex-1 text-sm">Auto (AI)</span>
150 {!selectedTemplateId && (
151 <Check className="size-4 shrink-0 text-primary" />
152 )}
153 </button>
154
155 {/* Template options with previews */}
156 {availableTemplates.map((template) => {
157 const templateDef = TEMPLATE_DEFINITIONS.find(
158 (t) => t.id === template.id,
159 );
160 const isSelected = templateDef
161 ? getTemplateSelectionIds(templateDef).includes(
162 selectedTemplateId ?? "",
163 )
164 : selectedTemplateId === template.id;
165 return (
166 <button
167 key={template.id}
168 type="button"
169 onClick={() => handleTemplateSelect(template.id)}
170 className={cn(
171 "flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-accent",
172 isSelected && "bg-accent",
173 )}
174 >
175 <div className="h-24 w-28 shrink-0 overflow-hidden rounded border border-border bg-card">
176 {templateDef?.preview}
177 </div>
178 <span className="flex-1 truncate text-sm">
179 {template.name}
180 </span>
181 {isSelected && (
182 <Check className="size-4 shrink-0 text-primary" />
183 )}
184 </button>
185 );
186 })}
187
188 {/* More option */}
189 <button
190 type="button"
191 onClick={handleMoreClick}
192 className="flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-primary transition-colors hover:bg-accent"
193 >
194 <div className="flex h-24 w-28 shrink-0 items-center justify-center overflow-hidden rounded border border-dashed border-primary/50 bg-primary/5">
195 <Plus className="size-4 text-primary" />
196 </div>
197 <span className="flex-1 text-sm">More Layouts</span>
198 </button>
199 </div>
200 </PopoverContent>
201 </Popover>
202 )}
203 <button
204 type="button"
205 onClick={() => onDelete(id)}
206 disabled={disabled}
207 className="text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 hover:text-red-400 disabled:pointer-events-none disabled:opacity-30"
208 >
209 <X size={20} />
210 </button>
211 </div>
212 );
213 });
214
215 // Add a display name for debugging purposes
216 OutlineItem.displayName = "OutlineItem";
217
217 lines Plain Text