| 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 |