返回 presentation-ai
icon-list-item.tsx
1 "use client";
2
3 import { ImageIcon, Plus } from "lucide-react";
4 import { NodeApi, PathApi } from "platejs";
5 import {
6 PlateElement,
7 useReadOnly,
8 type PlateElementProps,
9 } from "platejs/react";
10 import { useEffect, useMemo, useRef, useState } from "react";
11
12 import { IconPicker } from "@/components/ui/icon-picker";
13 import { Spinner } from "@/components/ui/spinner";
14 import {
15 getElementImageGenerationTarget,
16 getPresentationImageGenerationKey,
17 resolvePresentationImageGenerationSource,
18 } from "@/lib/presentation/image-generation";
19 import { cn } from "@/lib/utils";
20 import {
21 usePresentationState,
22 type ImageEditorMode,
23 } from "@/states/presentation-state";
24 import { type TIconListItemElement } from "../plugins/icon-list-plugin";
25 import {
26 getAlignmentClasses,
27 getIconListMediaSize,
28 getIconListOrientation,
29 getIconListVariant,
30 } from "../utils";
31 import { PALETTE_DROP_MUTABLE_KEY } from "../utils/paletteDrop";
32 import { PresentationIcon } from "./presentation-icon";
33 import { getPresentationImageCropStyles } from "./presentation-image-layout";
34
35 // IconItem component for individual items in the icons list
36 export const IconListElement = (
37 props: PlateElementProps<TIconListItemElement>,
38 ) => {
39 const parentPath = PathApi.parent(props.path);
40 const parentElement = NodeApi.get(props.editor, parentPath);
41 const {
42 alignment = "left",
43 mediaSize,
44 orientation,
45 variant,
46 children = [],
47 } = parentElement as {
48 alignment?: "left" | "center" | "right";
49 children?: unknown[];
50 mediaSize?: unknown;
51 orientation?: unknown;
52 variant?: unknown;
53 };
54 const { icon } = props.element;
55 const itemPrompt = props.element.prompt ?? props.element.query ?? "";
56 const itemUrl = props.element.url;
57 const resolvedVariant = getIconListVariant(variant);
58 const resolvedOrientation = getIconListOrientation(
59 orientation,
60 children.length,
61 );
62 const itemRef = useRef<HTMLDivElement | null>(null);
63 const isReadOnly = useReadOnly();
64 const [isFullRowItem, setIsFullRowItem] = useState(children.length <= 1);
65 const effectiveOrientation =
66 resolvedOrientation === "top" && isFullRowItem
67 ? "side"
68 : resolvedOrientation;
69 const resolvedMediaSize = getIconListMediaSize(mediaSize);
70 const imageSource = usePresentationState((s) => s.imageSource);
71 const imageModel = usePresentationState((s) => s.imageModel);
72 const stockImageProvider = usePresentationState((s) => s.stockImageProvider);
73 const startPresentationImageGeneration = usePresentationState(
74 (s) => s.startPresentationImageGeneration,
75 );
76 const rootImageGeneration = usePresentationState(
77 (s) => s.rootImageGeneration,
78 );
79 const openPresentationImageEditor = usePresentationState(
80 (s) => s.openPresentationImageEditor,
81 );
82 const presentationImageEditorInitialMode = usePresentationState(
83 (s) => s.presentationImageEditorInitialMode,
84 );
85 const setImageSearchState = usePresentationState(
86 (s) => s.setImageSearchState,
87 );
88 const slideId = String(props.editor.id ?? "");
89 const elementId =
90 typeof props.element.id === "string" ? props.element.id : undefined;
91 const generationTarget = useMemo(
92 () =>
93 slideId && elementId
94 ? getElementImageGenerationTarget(slideId, elementId)
95 : null,
96 [elementId, slideId],
97 );
98 const generationKey = generationTarget
99 ? getPresentationImageGenerationKey(generationTarget)
100 : null;
101 const computedGen = generationKey
102 ? rootImageGeneration[generationKey]
103 : undefined;
104 const computedImageUrl =
105 computedGen?.status === "success" && computedGen.url
106 ? computedGen.url
107 : itemUrl;
108 const isGenerating =
109 computedGen?.status === "queued" || computedGen?.status === "generating";
110 const hasGenerationFailed =
111 computedGen?.status === "error" ||
112 props.element.imageGenerationStatus === "failed";
113
114 const handleIconSelect = (iconName: string) => {
115 const itemPath = props.editor.api.findPath(props.element);
116 if (!itemPath) return;
117 props.editor.tf.setNodes({ icon: iconName }, { at: itemPath });
118 };
119
120 const handleOpenImageEditor = (mode: ImageEditorMode) => {
121 if (isReadOnly || !elementId) {
122 return;
123 }
124
125 const boundUpdateElement = (updateProps: Record<string, unknown>) => {
126 const queryPatch =
127 typeof updateProps.query === "string"
128 ? { prompt: updateProps.query }
129 : {};
130
131 props.editor.tf.setNodes(
132 {
133 ...updateProps,
134 ...queryPatch,
135 [PALETTE_DROP_MUTABLE_KEY]: false,
136 },
137 {
138 at: [],
139 match: (node) => node.id === elementId,
140 },
141 );
142 };
143
144 if (mode === "search") {
145 setImageSearchState({
146 mode: props.element.stockImageProvider ?? stockImageProvider,
147 });
148 }
149
150 openPresentationImageEditor(
151 mode,
152 boundUpdateElement,
153 {
154 ...props.element,
155 query: itemPrompt,
156 url: computedImageUrl,
157 },
158 {
159 height: resolvedMediaSize,
160 width: resolvedMediaSize,
161 },
162 );
163 };
164
165 useEffect(() => {
166 const itemElement = itemRef.current;
167 const gridElement = itemElement?.closest("[data-icon-list-grid='true']");
168
169 if (!itemElement || !(gridElement instanceof HTMLElement)) {
170 return;
171 }
172
173 const updateRowState = () => {
174 setIsFullRowItem(
175 itemElement.offsetWidth >= gridElement.clientWidth * 0.85,
176 );
177 };
178
179 updateRowState();
180
181 const observer = new ResizeObserver(updateRowState);
182 observer.observe(itemElement);
183 observer.observe(gridElement);
184
185 return () => {
186 observer.disconnect();
187 };
188 }, []);
189
190 useEffect(() => {
191 if (
192 resolvedVariant !== "image" ||
193 !generationTarget ||
194 !itemPrompt ||
195 computedImageUrl ||
196 hasGenerationFailed
197 ) {
198 return;
199 }
200
201 if (computedGen?.query === itemPrompt) {
202 return;
203 }
204
205 const source = resolvePresentationImageGenerationSource({
206 globalImageSource: imageSource,
207 imageSource: props.element.imageSource,
208 });
209
210 startPresentationImageGeneration(generationTarget, itemPrompt, {
211 imageModel,
212 source,
213 ...(source === "stock"
214 ? {
215 stockImageProvider:
216 props.element.stockImageProvider ?? stockImageProvider,
217 }
218 : {}),
219 });
220 }, [
221 computedGen?.query,
222 computedImageUrl,
223 generationTarget,
224 hasGenerationFailed,
225 imageModel,
226 imageSource,
227 itemPrompt,
228 props.element.imageSource,
229 props.element.stockImageProvider,
230 resolvedVariant,
231 startPresentationImageGeneration,
232 stockImageProvider,
233 ]);
234
235 const mediaStyle = {
236 height: resolvedMediaSize,
237 width: resolvedMediaSize,
238 };
239 const mediaIconSize = Math.round(resolvedMediaSize);
240 const hasIcon = Boolean(icon?.trim());
241 const removeIcon = () => {
242 const itemPath = props.editor.api.findPath(props.element);
243 if (!itemPath) return;
244 props.editor.tf.setNodes({ icon: "" }, { at: itemPath });
245 };
246 const mediaElement =
247 resolvedVariant === "image" ? (
248 <div
249 className="flex shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-md border bg-muted/30 shadow-xs"
250 data-decor="true"
251 onClick={(event) => {
252 event.stopPropagation();
253
254 if (presentationImageEditorInitialMode) {
255 event.preventDefault();
256 handleOpenImageEditor(presentationImageEditorInitialMode);
257 }
258 }}
259 onDoubleClick={(event) => {
260 event.preventDefault();
261 event.stopPropagation();
262
263 const mode: ImageEditorMode =
264 props.element.imageSource === "search"
265 ? "search"
266 : props.element.imageSource === "gif"
267 ? "gif"
268 : "generate";
269
270 handleOpenImageEditor(mode);
271 }}
272 onContextMenu={(event) => {
273 event.preventDefault();
274 event.stopPropagation();
275
276 const mode: ImageEditorMode =
277 props.element.imageSource === "search"
278 ? "search"
279 : props.element.imageSource === "gif"
280 ? "gif"
281 : "generate";
282
283 handleOpenImageEditor(mode);
284 }}
285 onMouseDown={(event) => {
286 event.preventDefault();
287 event.stopPropagation();
288 }}
289 style={mediaStyle}
290 >
291 {isGenerating && !computedImageUrl ? (
292 <Spinner className="size-5" />
293 ) : computedImageUrl ? (
294 // biome-ignore lint/performance/noImgElement: Generated editor thumbnail needs direct sizing.
295 <img
296 alt={itemPrompt}
297 className="size-full object-cover"
298 decoding="async"
299 loading="lazy"
300 src={computedImageUrl}
301 style={getPresentationImageCropStyles(props.element.cropSettings)}
302 />
303 ) : (
304 <ImageIcon
305 aria-hidden="true"
306 className={cn(
307 "text-muted-foreground",
308 hasGenerationFailed && "text-destructive",
309 )}
310 size={mediaIconSize}
311 />
312 )}
313 </div>
314 ) : (
315 <div className="relative shrink-0" data-decor="true" style={mediaStyle}>
316 {hasIcon ? (
317 <>
318 <PresentationIcon
319 icon={icon}
320 size={mediaIconSize}
321 className="flex size-full items-center justify-center"
322 iconClassName="size-full"
323 />
324 {!isReadOnly ? (
325 <IconPicker
326 defaultIcon={icon}
327 onIconSelect={(iconName) => handleIconSelect(iconName)}
328 onIconRemove={removeIcon}
329 className="absolute inset-0 size-full border-0 bg-transparent! p-0 opacity-0 shadow-none hover:bg-transparent!"
330 iconClassName="size-full"
331 iconPixelSize={mediaIconSize}
332 size="md"
333 />
334 ) : null}
335 </>
336 ) : (
337 <IconPicker
338 defaultIcon={icon}
339 hidePlaceholderWhenEmpty
340 onIconSelect={(iconName) => handleIconSelect(iconName)}
341 onIconRemove={removeIcon}
342 className="size-full border-0 bg-transparent! p-0 shadow-none hover:bg-transparent! hover:opacity-80"
343 iconClassName="size-full"
344 iconPixelSize={mediaIconSize}
345 placeholder={
346 <Plus
347 className="size-full text-muted-foreground"
348 size={mediaIconSize}
349 />
350 }
351 size="md"
352 />
353 )}
354 </div>
355 );
356
357 return (
358 <PlateElement {...props}>
359 <div
360 ref={itemRef}
361 className={cn("group group/icon-item relative w-full")}
362 >
363 <div
364 className={cn(
365 "flex w-full gap-4",
366 effectiveOrientation === "top"
367 ? "flex-col items-start"
368 : "items-start",
369 effectiveOrientation === "side" &&
370 alignment === "right" &&
371 "flex-row-reverse",
372 alignment === "center" && "justify-center",
373 effectiveOrientation === "top" &&
374 alignment === "center" &&
375 "items-center",
376 effectiveOrientation === "top" &&
377 alignment === "right" &&
378 "items-end",
379 )}
380 >
381 {mediaElement}
382
383 <div className={cn("min-w-0 flex-1", getAlignmentClasses(alignment))}>
384 {props.children}
385 </div>
386 </div>
387 </div>
388 </PlateElement>
389 );
390 };
391
391 lines Plain Text