返回 presentation-ai
presentation-image-element.tsx
根目录 / src / components / notebook / presentation / editor / custom-elements / presentation-image-element.tsx
1 "use client";
2
3 import { Image, useMediaState } from "@platejs/media/react";
4 import { ResizableProvider, ResizeHandle } from "@platejs/resizable";
5 import { type TImageElement } from "platejs";
6 import {
7 PlateElement,
8 useEditorRef,
9 useReadOnly,
10 withHOC,
11 type PlateElementProps,
12 } from "platejs/react";
13 import { useEffect, useMemo, useRef } from "react";
14
15 import {
16 mediaResizeHandleVariants,
17 Resizable,
18 } from "@/components/plate/ui/resize-handle";
19 import { Spinner } from "@/components/ui/spinner";
20 import {
21 getElementImageGenerationTarget,
22 getPresentationImageGenerationKey,
23 resolvePresentationImageGenerationSource,
24 } from "@/lib/presentation/image-generation";
25 import { cn } from "@/lib/utils";
26 import {
27 usePresentationState,
28 type ImageEditorMode,
29 type PresentationStockImageProvider,
30 } from "@/states/presentation-state";
31 import { type ImageCropSettings } from "../../utils/types";
32 import { useDraggable } from "../dnd/hooks/useDraggable";
33 import { getPresentationImageCropStyles } from "./presentation-image-layout";
34 import { PresentationImagePlaceholder } from "./presentation-image-placeholder";
35
36 type PresentationImageNode = TImageElement & {
37 id?: string;
38 query?: string;
39 cropSettings?: ImageCropSettings;
40 imageSource?: "generate" | "search" | "gif" | "upload";
41 stockImageProvider?: PresentationStockImageProvider;
42 imageGenerationStatus?: "failed";
43 };
44
45 export interface PresentationImageElementProps extends PlateElementProps<PresentationImageNode> {
46 nodeProps?: Record<string, unknown>;
47 }
48
49 export const PresentationImageElement = withHOC(
50 ResizableProvider,
51 function PresentationImageElement({
52 children,
53 className,
54 nodeProps,
55 ref,
56 ...props
57 }: PresentationImageElementProps) {
58 const { align = "center", focused, readOnly, selected } = useMediaState();
59 const { isDragging, handleRef } = useDraggable({
60 element: props.element,
61 });
62 const imageRef = useRef<HTMLDivElement | null>(null);
63 const editor = useEditorRef();
64 const slideId = String(props.editor.id ?? "");
65
66 const imageSource = usePresentationState((s) => s.imageSource);
67 const imageModel = usePresentationState((s) => s.imageModel);
68 const stockImageProvider = usePresentationState(
69 (s) => s.stockImageProvider,
70 );
71 const setImageSearchState = usePresentationState(
72 (s) => s.setImageSearchState,
73 );
74 const openPresentationImageEditor = usePresentationState(
75 (s) => s.openPresentationImageEditor,
76 );
77 const presentationImageEditorInitialMode = usePresentationState(
78 (s) => s.presentationImageEditorInitialMode,
79 );
80 const startPresentationImageGeneration = usePresentationState(
81 (s) => s.startPresentationImageGeneration,
82 );
83 const rootImageGeneration = usePresentationState(
84 (s) => s.rootImageGeneration,
85 );
86
87 const isReadOnly = useReadOnly();
88 const generationTarget = useMemo(
89 () =>
90 slideId && props.element.id
91 ? getElementImageGenerationTarget(slideId, props.element.id)
92 : null,
93 [props.element.id, slideId],
94 );
95 const generationKey = generationTarget
96 ? getPresentationImageGenerationKey(generationTarget)
97 : null;
98 const computedGen = generationKey
99 ? rootImageGeneration[generationKey]
100 : undefined;
101 const computedImageUrl =
102 computedGen?.status === "success" && computedGen.url
103 ? computedGen.url
104 : props.element.url;
105 const isGenerating =
106 computedGen?.status === "queued" || computedGen?.status === "generating";
107 const hasGenerationFailed =
108 computedGen?.status === "error" ||
109 props.element.imageGenerationStatus === "failed";
110
111 const cropSettings: ImageCropSettings = props.element.cropSettings || {
112 objectFit: "cover",
113 objectPosition: { x: 50, y: 50 },
114 zoom: 1,
115 };
116
117 const handleOpenEditor = (mode: ImageEditorMode) => {
118 if (isReadOnly) return;
119 if (props.element.id) {
120 const boundUpdateElement = (updateProps: Record<string, unknown>) => {
121 editor.tf.setNodes(updateProps as Partial<TImageElement>, {
122 at: [],
123 match: (n) => n.id === props.element.id,
124 });
125 };
126
127 if (mode === "search") {
128 setImageSearchState({
129 mode: props.element.stockImageProvider ?? stockImageProvider,
130 });
131 }
132
133 openPresentationImageEditor(
134 mode,
135 boundUpdateElement,
136 {
137 ...props.element,
138 url: computedImageUrl,
139 },
140 getPresentationImageElementFrame(props.element),
141 );
142 }
143 };
144
145 useEffect(() => {
146 if (
147 !generationTarget ||
148 !props.element.query ||
149 props.element.url ||
150 computedImageUrl ||
151 hasGenerationFailed
152 ) {
153 return;
154 }
155
156 if (computedGen?.query === props.element.query) {
157 return;
158 }
159
160 const source = resolvePresentationImageGenerationSource({
161 globalImageSource: imageSource,
162 imageSource: props.element.imageSource,
163 });
164
165 startPresentationImageGeneration(generationTarget, props.element.query, {
166 imageModel,
167 source,
168 ...(source === "stock"
169 ? {
170 stockImageProvider:
171 props.element.stockImageProvider ?? stockImageProvider,
172 }
173 : {}),
174 });
175 }, [
176 computedGen?.query,
177 computedImageUrl,
178 generationTarget,
179 hasGenerationFailed,
180 imageModel,
181 imageSource,
182 props.element.imageSource,
183 props.element.query,
184 props.element.stockImageProvider,
185 props.element.url,
186 startPresentationImageGeneration,
187 stockImageProvider,
188 ]);
189
190 const imageStyles = getPresentationImageCropStyles(cropSettings);
191
192 if (isReadOnly) {
193 return (
194 <PlateElement ref={ref} className={cn(className)} {...props}>
195 <div ref={imageRef}>
196 <Resizable
197 align={align}
198 options={{
199 align,
200 readOnly,
201 }}
202 >
203 {computedImageUrl ? (
204 <div className="my-4 text-center">
205 <Image
206 ref={handleRef}
207 className={cn("h-auto max-w-full")}
208 alt={props.element.query ?? ""}
209 src={computedImageUrl}
210 loading="lazy"
211 decoding="async"
212 style={{
213 ...imageStyles,
214 borderRadius: "var(--presentation-border-radius, 0.5rem)",
215 boxShadow:
216 "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))",
217 }}
218 {...nodeProps}
219 />
220 </div>
221 ) : (
222 <PresentationImagePlaceholder
223 className="pointer-events-auto h-full w-full rounded-[inherit]"
224 element={props.element}
225 imageNotFound={hasGenerationFailed}
226 />
227 )}
228 {children}
229 </Resizable>
230 </div>
231 </PlateElement>
232 );
233 }
234
235 return (
236 <PlateElement ref={ref} className={cn(className)} {...props}>
237 <div ref={imageRef}>
238 <Resizable
239 align={align}
240 options={{
241 align,
242 readOnly,
243 }}
244 className={cn("flex", !props.element.width && "w-full")}
245 >
246 <ResizeHandle
247 className={mediaResizeHandleVariants({ direction: "left" })}
248 options={{ direction: "left" }}
249 />
250 {isGenerating && !computedImageUrl ? (
251 <div className="relative min-h-50 w-full">
252 <div className="absolute inset-0 flex items-center justify-center rounded-sm bg-muted">
253 <div className="flex flex-col items-center gap-2">
254 <Spinner className="h-6 w-6" />
255 <span className="text-sm text-muted-foreground">
256 Generating image...
257 </span>
258 </div>
259 </div>
260 </div>
261 ) : !computedImageUrl ? (
262 <div
263 ref={handleRef}
264 className={cn(
265 "my-4 aspect-video w-full",
266 focused && selected && "ring-2 ring-ring ring-offset-2",
267 )}
268 style={{
269 borderRadius: "var(--presentation-border-radius, 0.5rem)",
270 }}
271 {...nodeProps}
272 >
273 <PresentationImagePlaceholder
274 className="pointer-events-auto h-full w-full rounded-[inherit]"
275 element={props.element}
276 imageNotFound={hasGenerationFailed}
277 />
278 </div>
279 ) : (
280 <div className="my-4 flex-1 text-center">
281 <Image
282 ref={handleRef}
283 className={cn(
284 "h-auto w-full",
285 "cursor-pointer",
286 focused && selected && "ring-2 ring-ring ring-offset-2",
287 isDragging && "opacity-50",
288 )}
289 alt={props.element.query ?? ""}
290 src={computedImageUrl}
291 loading="lazy"
292 decoding="async"
293 onClick={(event) => {
294 if (!presentationImageEditorInitialMode) {
295 return;
296 }
297
298 event.preventDefault();
299 event.stopPropagation();
300 handleOpenEditor(presentationImageEditorInitialMode);
301 }}
302 onDoubleClick={() => {
303 const mode: ImageEditorMode =
304 props.element.imageSource === "search"
305 ? "search"
306 : props.element.imageSource === "gif"
307 ? "gif"
308 : "generate";
309 handleOpenEditor(mode);
310 }}
311 style={{
312 ...imageStyles,
313 borderRadius: "var(--presentation-border-radius, 0.5rem)",
314 boxShadow:
315 "var(--presentation-card-shadow, 0 1px 3px rgba(0,0,0,0.12))",
316 }}
317 onError={(e) => {
318 console.error(
319 "Presentation image failed to load:",
320 e,
321 computedImageUrl,
322 );
323 }}
324 {...nodeProps}
325 />
326 </div>
327 )}
328 <ResizeHandle
329 className={mediaResizeHandleVariants({
330 direction: "right",
331 })}
332 options={{ direction: "right" }}
333 />
334 {children}
335 </Resizable>
336 </div>
337 </PlateElement>
338 );
339 },
340 );
341
342 function getPresentationImageElementFrame(element: PresentationImageNode):
343 | {
344 height: number;
345 width: number;
346 }
347 | undefined {
348 const width = typeof element.width === "number" ? element.width : undefined;
349 const height =
350 typeof element.height === "number" ? element.height : undefined;
351
352 if (width && height) {
353 return { height, width };
354 }
355
356 return undefined;
357 }
358
358 lines Plain Text