返回 presentation-ai
useRootImageActions.ts
根目录 / src / hooks / presentation / useRootImageActions.ts
1 "use client";
2
3 import { DndPlugin, type DragItemNode } from "@platejs/dnd";
4 import { ImagePlugin } from "@platejs/media/react";
5 import { useEditorRef } from "platejs/react";
6 import { type ResizeCallback } from "re-resizable";
7 import { useCallback, useId, useMemo } from "react";
8 import { type DragSourceMonitor } from "react-dnd";
9
10 import {
11 BASE_HEIGHT,
12 BASE_WIDTH_PERCENTAGE,
13 getRootImageCropSettings,
14 getRootImageObjectStyles,
15 getRootImageSizeStyle,
16 MAX_HEIGHT,
17 MAX_WIDTH_PERCENTAGE,
18 MIN_HEIGHT,
19 MIN_WIDTH_PERCENTAGE,
20 } from "@/components/notebook/presentation/editor/custom-elements/root-image-layout";
21 import { useDraggable } from "@/components/notebook/presentation/editor/dnd/hooks/useDraggable";
22 import {
23 type LayoutType,
24 type PlateSlide,
25 type RootImage,
26 } from "@/components/notebook/presentation/utils/parser";
27 import { type ImageCropSettings } from "@/components/notebook/presentation/utils/types";
28 import { useDebouncedSave } from "@/hooks/presentation/useDebouncedSave";
29 import { usePresentationState } from "@/states/presentation-state";
30
31 export {
32 BASE_HEIGHT,
33 BASE_WIDTH_PERCENTAGE,
34 MAX_HEIGHT,
35 MAX_WIDTH_PERCENTAGE,
36 MIN_HEIGHT,
37 MIN_WIDTH_PERCENTAGE,
38 } from "@/components/notebook/presentation/editor/custom-elements/root-image-layout";
39
40 type UseRootImageActionsOptions = {
41 image?: RootImage;
42 layoutType?: LayoutType | string;
43 maxHeightPx?: number;
44 slideId?: string;
45 };
46
47 function getMeasuredEditorHeight(resizeElement: HTMLElement) {
48 const slideRoot = resizeElement.closest<HTMLElement>(
49 '[data-slide-content="true"]',
50 );
51 const editorRegion = slideRoot?.querySelector<HTMLElement>(
52 '[data-presentation-editor-region="true"]',
53 );
54 const editorHeight = editorRegion?.getBoundingClientRect().height;
55
56 return typeof editorHeight === "number" && editorHeight > 0
57 ? Math.round(editorHeight)
58 : undefined;
59 }
60
61 function getMaxAllowedRootImageHeight(
62 resizeElement: HTMLElement,
63 maxHeightPx?: number,
64 ) {
65 const measuredEditorHeight = getMeasuredEditorHeight(resizeElement);
66
67 if (typeof maxHeightPx === "number" && maxHeightPx > 0) {
68 return Math.min(MAX_HEIGHT, maxHeightPx);
69 }
70
71 if (typeof measuredEditorHeight === "number") {
72 return Math.min(MAX_HEIGHT, measuredEditorHeight);
73 }
74
75 return MAX_HEIGHT;
76 }
77
78 function constrainRootImageHeight(height: number, maxAllowedHeight: number) {
79 return Math.min(maxAllowedHeight, Math.max(MIN_HEIGHT, height));
80 }
81
82 export function useRootImageActions(
83 slideId: string,
84 options: UseRootImageActionsOptions = {},
85 ) {
86 const { image, layoutType, maxHeightPx } = options;
87
88 const setSlides = usePresentationState((s) => s.setSlides);
89 const startRootImageGeneration = usePresentationState(
90 (s) => s.startRootImageGeneration,
91 );
92 const rootImageGeneration = usePresentationState(
93 (s) => s.rootImageGeneration,
94 );
95 const { saveImmediately } = useDebouncedSave();
96
97 const editor = useEditorRef();
98
99 const size = useMemo(
100 () => ({
101 w: image?.size?.w ?? undefined,
102 h: image?.size?.h ?? undefined,
103 }),
104 [image?.size?.h, image?.size?.w],
105 );
106
107 const computedGen = useMemo(
108 () => (slideId ? rootImageGeneration[slideId] : undefined),
109 [rootImageGeneration, slideId],
110 );
111 const matchingComputedGen = useMemo(() => {
112 if (!computedGen) {
113 return undefined;
114 }
115
116 const imageQuery = image?.query?.trim();
117 return !imageQuery || computedGen.query.trim() === imageQuery
118 ? computedGen
119 : undefined;
120 }, [computedGen, image?.query]);
121 const computedImageUrl = useMemo(() => {
122 // If it's an embed, return the embed URL directly
123 if (image?.embedType) {
124 return image.url;
125 }
126 // Otherwise, use the generated or existing image URL
127 return matchingComputedGen?.url ?? image?.url;
128 }, [matchingComputedGen?.url, image?.url, image?.embedType]);
129
130 // Get crop settings from image or use defaults
131 const cropSettings: ImageCropSettings = useMemo(
132 () =>
133 image
134 ? getRootImageCropSettings(image)
135 : {
136 objectFit: "cover",
137 objectPosition: { x: 50, y: 50 },
138 zoom: 1,
139 },
140 [image],
141 );
142
143 // Derived styles
144 const imageStyles: React.CSSProperties = useMemo(
145 () =>
146 image
147 ? getRootImageObjectStyles(image)
148 : {
149 objectFit: cropSettings.objectFit,
150 objectPosition: `${cropSettings.objectPosition.x}% ${cropSettings.objectPosition.y}%`,
151 transform: `scale(${cropSettings.zoom ?? 1})`,
152 transformOrigin: `${cropSettings.objectPosition.x}% ${cropSettings.objectPosition.y}%`,
153 height: "100%",
154 width: "100%",
155 display: "block",
156 },
157 [cropSettings, image],
158 );
159
160 const sizeStyle: React.CSSProperties = useMemo(() => {
161 if (image) {
162 return getRootImageSizeStyle(image, layoutType);
163 }
164
165 if (!size.h && !size.w) {
166 if (layoutType === "vertical") {
167 return { height: BASE_HEIGHT, width: "100%" } as const;
168 }
169 return { width: BASE_WIDTH_PERCENTAGE, height: "auto" } as const;
170 }
171 if (layoutType === "vertical") {
172 return { height: size.h ?? BASE_HEIGHT, width: "100%" } as const;
173 }
174 return { width: size.w ?? BASE_WIDTH_PERCENTAGE, height: "auto" } as const;
175 }, [image, layoutType, size.h, size.w]);
176
177 const updateCropSettings = useCallback(
178 (settings: ImageCropSettings) => {
179 const { slides } = usePresentationState.getState();
180 const updatedSlides = slides.map((slide: PlateSlide) => {
181 if (slide.id === slideId) {
182 return {
183 ...slide,
184 rootImage: {
185 ...slide.rootImage!,
186 cropSettings: settings,
187 },
188 };
189 }
190 return slide;
191 });
192 setSlides(updatedSlides);
193 setTimeout(() => {
194 void saveImmediately();
195 }, 100);
196 },
197 [saveImmediately, setSlides, slideId],
198 );
199
200 const replaceImageUrl = useCallback(
201 (url: string, query: string) => {
202 const { slides } = usePresentationState.getState();
203 const resetCrop: ImageCropSettings = {
204 objectFit: "cover",
205 objectPosition: { x: 50, y: 50 },
206 zoom: 1,
207 };
208 const updatedSlides = slides.map((slide: PlateSlide) => {
209 if (slide.id === slideId) {
210 return {
211 ...slide,
212 rootImage: {
213 ...(slide.rootImage ?? { query }),
214 url,
215 cropSettings: resetCrop,
216 },
217 };
218 }
219 return slide;
220 });
221 setSlides(updatedSlides);
222 void saveImmediately();
223 },
224 [saveImmediately, setSlides, slideId],
225 );
226
227 const removeRootImage = useCallback(
228 (matchUrls?: string[]) => {
229 const { slides, clearRootImageGeneration } =
230 usePresentationState.getState();
231 const updatedSlides = slides.map((slide: PlateSlide) => {
232 if (slide.id === slideId) {
233 if (!slide.rootImage) return slide;
234 if (matchUrls && !matchUrls.includes(slide.rootImage.url ?? "")) {
235 return slide;
236 }
237 const { rootImage: _rootImage, ...rest } = slide as PlateSlide & {
238 rootImage?: PlateSlide["rootImage"];
239 };
240 if (slideId) {
241 clearRootImageGeneration(slideId);
242 }
243 return rest as PlateSlide;
244 }
245 return slide;
246 });
247 setSlides(updatedSlides);
248 },
249 [setSlides, slideId],
250 );
251
252 const removeRootImageFromSlide = useCallback(() => {
253 // For charts (no URL), remove without URL matching
254 if (image?.chartType && !image?.url) {
255 removeRootImage();
256 } else {
257 const urls = [image?.url, computedImageUrl].filter((u): u is string =>
258 Boolean(u),
259 );
260 removeRootImage(urls);
261 }
262 }, [computedImageUrl, image?.url, image?.chartType, removeRootImage]);
263
264 const updateRootImageSize = useCallback(
265 (newSize: { w?: string; h?: number }) => {
266 const { slides } = usePresentationState.getState();
267 const updatedSlides = slides.map((slide: PlateSlide) => {
268 if (slide.id === slideId) {
269 return {
270 ...slide,
271 rootImage: {
272 ...slide.rootImage!,
273 size: {
274 ...slide.rootImage?.size,
275 ...newSize,
276 },
277 },
278 };
279 }
280 return slide;
281 });
282 setSlides(updatedSlides);
283 void saveImmediately();
284 },
285 [setSlides, slideId],
286 );
287
288 const onResize = useCallback<ResizeCallback>(
289 (_e, _direction, ref, d) => {
290 if (layoutType !== "vertical") return;
291
292 const nextHeight = (size?.h ?? BASE_HEIGHT) + d.height;
293 const constrainedHeight = constrainRootImageHeight(
294 nextHeight,
295 getMaxAllowedRootImageHeight(ref, maxHeightPx),
296 );
297
298 ref.style.height = `${constrainedHeight}px`;
299 },
300 [layoutType, maxHeightPx, size?.h],
301 );
302
303 // Resizable handler logic moved here
304 const onResizeStop = useCallback<ResizeCallback>(
305 (_e, _direction, ref, d) => {
306 if (layoutType === "vertical") {
307 const nextHeight = (size?.h ?? BASE_HEIGHT) + d.height;
308 const constrainedHeight = constrainRootImageHeight(
309 nextHeight,
310 getMaxAllowedRootImageHeight(ref, maxHeightPx),
311 );
312 updateRootImageSize({ h: constrainedHeight });
313 } else {
314 const parentElementRect = ref.parentElement!.getBoundingClientRect();
315 const parentWidth = parentElementRect.width;
316 const width = parseFloat(size?.w ?? BASE_WIDTH_PERCENTAGE);
317 const originalWidth = parentWidth * (width / 100);
318 const changeInWidth = d.width;
319 const newWidth = originalWidth + changeInWidth;
320 const newWidthPercentage = (newWidth / parentWidth) * 100;
321 // Enforce min/max width percentage constraints
322 const constrainedWidthPercentage = Math.max(
323 MIN_WIDTH_PERCENTAGE,
324 Math.min(MAX_WIDTH_PERCENTAGE, newWidthPercentage),
325 );
326 const nextWidth = `${constrainedWidthPercentage}%`;
327 updateRootImageSize({ w: nextWidth });
328 }
329 },
330 [layoutType, maxHeightPx, size?.h, size?.w, updateRootImageSize],
331 );
332
333 // Drag-and-drop logic moved here
334 const id = useId();
335 const dragElement = useMemo(() => {
336 // If it's a chart, create a chart drag element
337 if (image?.chartType && image?.chartData) {
338 return {
339 id: id,
340 type: image.chartType,
341 data: image.chartData,
342 variant: image.chartOptions?.variant,
343 children: [{ text: "" }],
344 };
345 }
346 // Otherwise, create an image drag element
347 return {
348 id: id,
349 type: ImagePlugin.key,
350 url: computedImageUrl,
351 query: image?.query,
352 cropSettings: cropSettings,
353 children: [{ text: "" }],
354 };
355 }, [
356 computedImageUrl,
357 cropSettings,
358 id,
359 image?.query,
360 image?.chartType,
361 image?.chartData,
362 image?.chartOptions?.variant,
363 ]);
364
365 const onDragEnd = useCallback(
366 (_item: DragItemNode, monitor: DragSourceMonitor) => {
367 const dropResult: { droppedInLayoutZone: boolean } =
368 monitor.getDropResult()!;
369 if (monitor.didDrop() && !dropResult?.droppedInLayoutZone) {
370 // Remove the entire root image (including layout) when dragging to editor
371 removeRootImageFromSlide();
372 }
373 editor.setOption(DndPlugin, "isDragging", false);
374 },
375 [editor, removeRootImageFromSlide],
376 );
377
378 const { isDragging, handleRef } = useDraggable({
379 element: dragElement,
380 drag: { end: onDragEnd },
381 });
382
383 return {
384 // Derived data
385 computedGen: matchingComputedGen,
386 computedImageUrl,
387 cropSettings,
388 imageStyles,
389 sizeStyle,
390 isDragging,
391 handleRef,
392
393 // Actions
394 startRootImageGeneration,
395 updateCropSettings,
396 replaceImageUrl,
397 removeRootImage,
398 removeRootImageFromSlide,
399 updateRootImageSize,
400 onResize,
401 onResizeStop,
402 dragId: id,
403 };
404 }
405
405 lines TYPESCRIPT