返回 presentation-ai
domSlideScanner.ts
根目录 / src / components / presentation / export / domSlideScanner.ts
1 /**
2 * DOM Slide Scanner
3 * Scans rendered slide DOM to extract element positions, SVGs, and styles
4 */
5
6 import { toPng } from "html-to-image";
7
8 import { type PlateSlide } from "@/components/notebook/presentation/utils/parser";
9 import { proxyPresentationImageUrl } from "@/lib/image-proxy";
10 import { usePresentationState } from "@/states/presentation-state";
11 import { walkSlideContent } from "./contentWalker";
12 import { extractPresentationStyles } from "./cssVariableResolver";
13 import { getEChartSvgDataUrl } from "./echartSvgExport";
14 import {
15 type ElementPosition,
16 type RootImageData,
17 type ScanResult,
18 } from "./types";
19 import { getOptimalPixelRatio } from "./utils";
20
21 /**
22 * Scan a slide's DOM and extract all exportable elements
23 * @param slide - The slide to scan
24 * @returns ScanResult with all elements and their positions
25 */
26 async function scanSlide(slide: PlateSlide): Promise<ScanResult | null> {
27 const slideId = slide.id;
28 // Find the slide container
29 const slideElement = document.querySelector(`#presentation-root-${slideId}`);
30 if (!slideElement) {
31 console.warn(`Slide container not found for slide: ${slideId}`);
32 return null;
33 }
34
35 // Get slide dimensions
36 const slideRect = slideElement.getBoundingClientRect();
37 const sourceSize = getUntransformedSize(slideElement, slideRect);
38
39 const styles = extractPresentationStyles(slideElement);
40 let backgroundImageUrl: string | undefined;
41 // Only use background image if the layout supports it
42 if (slide.layoutType === "background") {
43 backgroundImageUrl = styles.backgroundImageUrl;
44 }
45
46 // Scan for root image
47 const rootImage = await scanRootImageFromSlide(slideElement, slideId);
48
49 // Scan for exportable elements using PlateJS content walker
50 // This uses the slide content JSON to identify elements, but DOM for positions
51 const elements = await walkSlideContent(slide.content, slideElement);
52
53 return {
54 slideId,
55 width: slideRect.width,
56 height: slideRect.height,
57 sourceWidth: sourceSize.width,
58 sourceHeight: sourceSize.height,
59 elements,
60 styles,
61 rootImage,
62 backgroundImageUrl,
63 };
64 }
65
66 function getUntransformedSize(
67 element: Element,
68 fallbackRect: DOMRect,
69 ): { width: number; height: number } {
70 if (element instanceof HTMLElement) {
71 const width =
72 element.offsetWidth || element.clientWidth || fallbackRect.width;
73 const height =
74 element.offsetHeight || element.clientHeight || fallbackRect.height;
75
76 return { width, height };
77 }
78
79 return { width: fallbackRect.width, height: fallbackRect.height };
80 }
81
82 /**
83 * Get position relative to slide container (as percentage 0-100)
84 */
85 function getRelativePosition(
86 element: Element,
87 slideRect: DOMRect,
88 ): ElementPosition {
89 const rect = element.getBoundingClientRect();
90 return {
91 x: ((rect.left - slideRect.left) / slideRect.width) * 100,
92 y: ((rect.top - slideRect.top) / slideRect.height) * 100,
93 width: (rect.width / slideRect.width) * 100,
94 height: (rect.height / slideRect.height) * 100,
95 };
96 }
97
98 function waitForImageLoad(image: HTMLImageElement): Promise<void> {
99 if (image.complete && image.naturalWidth > 0) {
100 return Promise.resolve();
101 }
102
103 return new Promise((resolve) => {
104 const finish = () => {
105 image.removeEventListener("load", finish);
106 image.removeEventListener("error", finish);
107 resolve();
108 };
109
110 image.addEventListener("load", finish);
111 image.addEventListener("error", finish);
112 });
113 }
114
115 async function captureRootImageForExport(
116 rootImageContainer: Element,
117 slide: PlateSlide,
118 ): Promise<string> {
119 const imageElements = Array.from(rootImageContainer.querySelectorAll("img"));
120 const replacements: Array<{
121 crossOrigin: string | null;
122 image: HTMLImageElement;
123 src: string;
124 }> = [];
125
126 try {
127 for (const imageElement of imageElements) {
128 const originalSrc = imageElement.currentSrc || imageElement.src;
129 const proxiedSrc = proxyPresentationImageUrl(
130 originalSrc,
131 slide.rootImage,
132 { absolute: true },
133 );
134
135 if (!proxiedSrc || proxiedSrc === originalSrc) {
136 continue;
137 }
138
139 replacements.push({
140 crossOrigin: imageElement.crossOrigin,
141 image: imageElement,
142 src: imageElement.src,
143 });
144 imageElement.crossOrigin = "anonymous";
145 imageElement.src = proxiedSrc;
146 }
147
148 await Promise.all(
149 replacements.map((replacement) => waitForImageLoad(replacement.image)),
150 );
151
152 return await toPng(rootImageContainer as HTMLElement, {
153 backgroundColor: "transparent",
154 cacheBust: true,
155 quality: 1,
156 pixelRatio: getOptimalPixelRatio(),
157 skipFonts: true,
158 });
159 } finally {
160 for (const replacement of replacements) {
161 replacement.image.crossOrigin = replacement.crossOrigin;
162 replacement.image.src = replacement.src;
163 }
164 }
165 }
166
167 /**
168 * Scan root image from the slide element
169 */
170 async function scanRootImageFromSlide(
171 slideElement: Element,
172 slideId: string,
173 ): Promise<RootImageData | undefined> {
174 const { slides } = usePresentationState.getState();
175 const slide = slides.find((s) => s.id === slideId);
176
177 if (!slide || !slide.rootImage) return undefined;
178
179 const slideRect = slideElement.getBoundingClientRect();
180
181 // Look for root image container with data-root-image attribute
182 // In root-image.tsx, this is on the Resizable component which has the correct dimensions
183 // In root-image-static.tsx, this is on a nested div, but the parent has the sizing
184 const rootImageContainer = slideElement.querySelector(
185 `[data-root-image="${slideId}"]`,
186 );
187
188 if (!rootImageContainer) return undefined;
189
190 // Determine the correct container to measure for position
191 // The data-root-image element could be:
192 // 1. The Resizable component itself (in root-image.tsx) - has class "shrink-0"
193 // 2. A nested div inside a sized parent (in root-image-static.tsx)
194 let imageContainer: Element = rootImageContainer;
195
196 // If the element itself has shrink-0, it's the Resizable and we use it directly
197 // Otherwise, check if the parent has the sizing (for static version)
198 if (!rootImageContainer.classList.contains("shrink-0")) {
199 const parent = rootImageContainer.parentElement;
200 if (parent?.classList.contains("shrink-0")) {
201 imageContainer = parent;
202 }
203 }
204
205 const position = getRelativePosition(imageContainer, slideRect);
206
207 // Try to find the original image URL
208 const imgElement = imageContainer.querySelector("img");
209 const originalUrl = imgElement?.src || undefined;
210
211 try {
212 const chartSvgDataUrl = getEChartSvgDataUrl(rootImageContainer);
213 if (chartSvgDataUrl) {
214 return {
215 url: chartSvgDataUrl,
216 position,
217 isBase64: true,
218 originalUrl,
219 imageSource: slide.rootImage.imageSource,
220 stockImageProvider: slide.rootImage.stockImageProvider,
221 };
222 }
223
224 // Non-chart root media still needs a DOM capture to preserve object-fit,
225 // object-position, cropping, and embeds.
226 const base64Data = await captureRootImageForExport(
227 rootImageContainer,
228 slide,
229 );
230 return {
231 url: base64Data,
232 position,
233 isBase64: true, // Flag to indicate this is already a captured image
234 originalUrl,
235 imageSource: slide.rootImage.imageSource,
236 stockImageProvider: slide.rootImage.stockImageProvider,
237 };
238 } catch (error) {
239 console.warn("Failed to capture root image element", error);
240 return undefined;
241 }
242 }
243
244 /**
245 * Scan all slides in the presentation
246 * Uses parallel processing for better performance
247 */
248 export async function scanAllSlides(
249 slides: PlateSlide[],
250 onProgress?: (completed: number, total: number) => void,
251 ): Promise<ScanResult[]> {
252 const total = slides.length;
253 let completed = 0;
254 const results: ScanResult[] = [];
255
256 for (const slide of slides) {
257 const result = await scanSlide(slide);
258 if (result) {
259 results.push(result);
260 }
261 completed++;
262 onProgress?.(completed, total);
263 }
264
265 return results;
266 }
267
267 lines TYPESCRIPT