import { useT } from "@agent-native/core/client/i18n"; import { useState, useEffect, useMemo, useRef, useLayoutEffect, useCallback, type ReactNode, } from "react"; import ReactMarkdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import { Skeleton } from "@/components/ui/skeleton"; import type { Slide } from "@/context/DeckContext"; import { type AspectRatio, getAspectRatioDims } from "@/lib/aspect-ratios"; import { sanitizeCssValue, sanitizeSlideHtml, sanitizeSlideUrl, } from "@/lib/sanitize-slide-html"; import type { DesignSystemData } from "../../../shared/api"; import { ExcalidrawThumbnail, parseExcalidrawData } from "./ExcalidrawSlide"; import { MermaidRenderer } from "./MermaidRenderer"; interface SlideRendererProps { slide: Slide; className?: string; /** If true, renders at full slide resolution and scales down via CSS to fit the container */ thumbnail?: boolean; /** Design system to inject as CSS custom properties */ designSystem?: DesignSystemData; /** Deck aspect ratio (defaults to 16:9 when omitted) */ aspectRatio?: AspectRatio; /** Fires when the natural slide content overflows the canvas vertically. * The renderer no longer shrinks slides for vertical overflow — instead the * editor surfaces this so the agent can rewrite the slide to fit. */ onOverflowChange?: (info: SlideOverflowInfo) => void; } export const layoutClasses: Record = { title: "flex flex-col items-center justify-center text-center px-16", content: "flex flex-col justify-center text-left px-16 py-12", "two-column": "grid grid-cols-2 gap-8 items-center text-left px-16 py-12", image: "flex flex-col items-center justify-center px-12 py-8", section: "flex flex-col", statement: "flex flex-col", "full-image": "flex flex-col", blank: "flex flex-col", }; /** Custom image component that shows skeleton while loading */ function LazyImage({ src, alt, ...props }: React.ImgHTMLAttributes) { const [loaded, setLoaded] = useState(false); const [error, setError] = useState(false); const safeSrc = sanitizeSlideUrl(src, "image"); if (src === "PLACEHOLDER_IMAGE" || !safeSrc) { return (
); } return ( {!loaded && !error && ( )} {alt setLoaded(true)} onError={() => setError(true)} className={`max-w-full max-h-[60vh] mx-auto rounded-lg transition-opacity duration-300 ${ loaded ? "opacity-100" : "opacity-0" }`} {...props} /> ); } const markdownComponents = { img: (props: any) => , a: ({ href, children, ...props }: any) => { const safeHref = sanitizeSlideUrl(href, "link"); if (!safeHref) return <>{children}; return ( {children} ); }, code: ({ className, children, ...props }: any) => { const match = /language-mermaid/.exec(className || ""); if (match) { return ( ); } return ( {children} ); }, pre: ({ children, ...props }: any) => { // If the child is a mermaid code block, don't wrap in
    const child = Array.isArray(children) ? children[0] : children;
    if (child?.props?.className === "language-mermaid") {
      return <>{children};
    }
    return 
{children}
; }, }; const MIN_AUTOFIT_SCALE = 0.65; const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; export interface SlideFitTransform { scale: number; x: number; y: number; fitted: boolean; /** Vertical overflow in CSS px (0 if content fits). Reported to the agent so it can * rewrite the slide HTML to fit, instead of being papered over with a uniform * shrink that leaves ugly right/bottom margins. */ verticalOverflow: number; } export function computeSlideFitTransform({ contentWidth, contentHeight, viewportWidth, viewportHeight, minX = 0, minY = 0, minScale = MIN_AUTOFIT_SCALE, }: { contentWidth: number; contentHeight: number; viewportWidth: number; viewportHeight: number; minX?: number; minY?: number; minScale?: number; }): SlideFitTransform { // Only scale for horizontal overflow. For vertical overflow we surface a // `verticalOverflow` measurement so the agent can rewrite the slide HTML — // uniform scale-to-fit for vertical overflow shrinks both axes and leaves // unbalanced right/bottom margins (with origin top-left), which looks worse // than asking the LLM to redo the layout to fit the canvas properly. const safeContentWidth = Math.max(1, contentWidth); const rawScale = Math.min(1, Math.max(1, viewportWidth) / safeContentWidth); const scale = Math.max(minScale, rawScale); const verticalOverflow = Math.max( 0, Math.round(contentHeight - viewportHeight), ); return { scale, x: minX < 0 ? -minX * scale : 0, y: minY < 0 ? -minY * scale : 0, fitted: rawScale < 0.999, verticalOverflow, }; } function ensureRawHtmlFitLayers(root: HTMLElement): HTMLElement[] { const fmdSlides = Array.from( root.querySelectorAll(".fmd-slide"), ); return fmdSlides.map((slide) => { const existing = Array.from(slide.children).find( (child): child is HTMLElement => child instanceof HTMLElement && child.hasAttribute("data-fmd-autofit-content"), ); if (existing) return existing; const layer = document.createElement("div"); layer.setAttribute("data-fmd-autofit-content", "true"); layer.className = "fmd-autofit-scale"; const nonStyleChildren = Array.from(slide.childNodes).filter( (child) => !( child instanceof HTMLElement && child.tagName.toLowerCase() === "style" ), ); for (const child of nonStyleChildren) { layer.appendChild(child); } slide.appendChild(layer); return layer; }); } function measureContentBounds(target: HTMLElement): { contentWidth: number; contentHeight: number; minX: number; minY: number; } { const targetRect = target.getBoundingClientRect(); // `scrollWidth` / `clientWidth` return CSS pixels; `getBoundingClientRect` // returns layout pixels after every ancestor transform. In presentation // mode the outer canvas is scaled UP (--slide-scale > 1, e.g. 1.74), so // child rects come back inflated relative to scrollWidth. Without // normalization, `Math.max(scrollWidth, maxX - minX)` reads the inflated // value as content overflow, computeSlideFitTransform clamps to // MIN_AUTOFIT_SCALE (0.65), and every slide visibly shrinks. The editor // didn't hit this because thumbnail mode scales DOWN, so scrollWidth // always wins. Normalize child rects back to CSS-px space. const cssWidth = target.clientWidth || target.scrollWidth || 0; const cssHeight = target.clientHeight || target.scrollHeight || 0; const invScaleX = targetRect.width > 0 && cssWidth > 0 ? cssWidth / targetRect.width : 1; const invScaleY = targetRect.height > 0 && cssHeight > 0 ? cssHeight / targetRect.height : 1; let minX = 0; let minY = 0; let maxX = target.scrollWidth; let maxY = target.scrollHeight; for (const el of Array.from(target.querySelectorAll("*"))) { if (el.tagName.toLowerCase() === "style") continue; const rect = el.getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) continue; const left = (rect.left - targetRect.left) * invScaleX; const top = (rect.top - targetRect.top) * invScaleY; const right = (rect.right - targetRect.left) * invScaleX; const bottom = (rect.bottom - targetRect.top) * invScaleY; minX = Math.min(minX, left); minY = Math.min(minY, top); maxX = Math.max(maxX, right); maxY = Math.max(maxY, bottom); } return { contentWidth: Math.max(target.scrollWidth, maxX - minX), contentHeight: Math.max(target.scrollHeight, maxY - minY), minX, minY, }; } /** Reported by useSlideAutofit when content overflows the slide canvas vertically. * Surfaced so the editor can prompt the agent to rewrite the slide instead of * the renderer trying to paper over it with a uniform shrink. */ export interface SlideOverflowInfo { /** Vertical overflow in CSS px at native resolution (0 = fits). */ verticalOverflow: number; /** Total natural content height in CSS px. */ contentHeight: number; /** Available canvas height inside the slide padding. */ viewportHeight: number; } function useSlideAutofit( ref: React.RefObject, canvasWidth: number, canvasHeight: number, fitKey: string, onOverflowChange?: (info: SlideOverflowInfo) => void, ) { const overflowCallbackRef = useRef(onOverflowChange); overflowCallbackRef.current = onOverflowChange; useIsomorphicLayoutEffect(() => { const root = ref.current; if (!root || typeof ResizeObserver === "undefined") return; let raf = 0; let disposed = false; const resetTarget = (target: HTMLElement) => { target.style.setProperty("--fmd-fit-scale", "1"); target.style.setProperty("--fmd-fit-x", "0px"); target.style.setProperty("--fmd-fit-y", "0px"); target.removeAttribute("data-fmd-autofit-active"); }; const measureNow = () => { if (disposed) return; const isEditing = !!root.querySelector('[contenteditable="true"]'); const rawTargets = ensureRawHtmlFitLayers(root); const targets = rawTargets.length > 0 ? rawTargets : [root].filter((target) => target.scrollHeight > 0); let worstOverflow = 0; let worstInfo: SlideOverflowInfo | null = null; for (const target of targets) { if (isEditing) { resetTarget(target); continue; } resetTarget(target); const bounds = measureContentBounds(target); const viewportWidth = target.clientWidth || canvasWidth; const viewportHeight = target.clientHeight || canvasHeight; const transform = computeSlideFitTransform({ ...bounds, viewportWidth, viewportHeight, }); target.style.setProperty("--fmd-fit-scale", String(transform.scale)); target.style.setProperty("--fmd-fit-x", `${transform.x}px`); target.style.setProperty("--fmd-fit-y", `${transform.y}px`); if (transform.fitted) { target.setAttribute("data-fmd-autofit-active", "true"); } if (transform.verticalOverflow > worstOverflow) { worstOverflow = transform.verticalOverflow; worstInfo = { verticalOverflow: transform.verticalOverflow, contentHeight: Math.round(bounds.contentHeight), viewportHeight: Math.round(viewportHeight), }; } } // Fire the callback on EVERY measurement (not just when the overflow // value changes). The editor uses this to refresh its // `application_state.slide-fit-check` record with a new `measuredAt` // timestamp so the add-slide / update-slide actions can confirm the // slide has been re-measured AFTER their write — even when an agent // patch keeps the overflow at the same value (e.g. dropped one bullet // and added another). The editor dedups React state changes on its // own end if needed. if (!isEditing) { overflowCallbackRef.current?.( worstInfo ?? { verticalOverflow: 0, contentHeight: 0, viewportHeight: 0, }, ); } }; const scheduleMeasure = () => { if (disposed) return; cancelAnimationFrame(raf); raf = requestAnimationFrame(measureNow); }; scheduleMeasure(); const resizeObserver = new ResizeObserver(scheduleMeasure); resizeObserver.observe(root); const mutationObserver = new MutationObserver(scheduleMeasure); mutationObserver.observe(root, { attributes: true, childList: true, subtree: true, attributeFilter: ["contenteditable", "class", "src"], }); root.addEventListener("load", scheduleMeasure, true); document.fonts?.ready.then(scheduleMeasure).catch(() => {}); return () => { disposed = true; cancelAnimationFrame(raf); resizeObserver.disconnect(); mutationObserver.disconnect(); root.removeEventListener("load", scheduleMeasure, true); }; }, [canvasWidth, canvasHeight, fitKey, ref]); } function AutoFitContent({ canvasWidth, canvasHeight, fitKey, className = "", children, onOverflowChange, }: { canvasWidth: number; canvasHeight: number; fitKey: string; className?: string; children: ReactNode; onOverflowChange?: (info: SlideOverflowInfo) => void; }) { const ref = useRef(null); useSlideAutofit(ref, canvasWidth, canvasHeight, fitKey, onOverflowChange); return (
{children}
); } /** Renders blank slide HTML content and applies white filter to logo images */ function BlankSlideContent({ content }: { content: string }) { // Memoize derived strings + the dangerouslySetInnerHTML object on `content` so // the prop value has a stable reference across re-renders. React 19 only checks // reference equality on `dangerouslySetInnerHTML` and unconditionally re-assigns // `domElement.innerHTML` when the object reference differs — a fresh `{ __html }` // literal each render therefore wipes any DOM mutations made on children. That // includes the per-block `contentEditable="true"` set by SlideEditor's // double-click-to-edit flow, which made inline text editing appear to do nothing. const { mermaidBlocks, htmlWithPlaceholders, dangerousHtml } = useMemo(() => { // Extract mermaid blocks BEFORE sanitization. The sanitizer round-trips // HTML through DOMParser + innerHTML, which HTML-escapes `>` in text // nodes to `>` — that mangles diagram arrows like `A --> B` into // `A --> B` and breaks the mermaid parser. const blocks: string[] = []; const contentWithPlaceholders = content.replace( /]*>([\s\S]*?)<\/div>/gi, (_, definition) => { blocks.push(String(definition).trim()); return `
`; }, ); // Apply white filter to all logo images (brandfetch, logo.dev, etc.) for dark backgrounds const processed = sanitizeSlideHtml( contentWithPlaceholders.replace( /(]*src="[^"]*(?:brandfetch|logo\.dev)[^"]*")[^>]*)(\/?>)/gi, (_match, before, close) => { if (before.includes('style="')) { return ( before.replace( 'style="', 'style="filter:brightness(0) invert(1);', ) + close ); } return before + ' style="filter:brightness(0) invert(1);"' + close; }, ), ); return { mermaidBlocks: blocks, htmlWithPlaceholders: processed, dangerousHtml: { __html: processed }, }; }, [content]); if (mermaidBlocks.length > 0) { return (
); } return (
); } /** Renders HTML content with mermaid placeholders replaced by React MermaidRenderer */ function MermaidHtmlContent({ html, mermaidBlocks, }: { html: string; mermaidBlocks: string[]; }) { // Split on mermaid placeholders and interleave HTML + MermaidRenderer const parts = html.split(/(
<\/div>)/); return ( <> {parts.map((part, i) => { const match = part.match(/data-mermaid-index="(\d+)"/); if (match) { const idx = parseInt(match[1], 10); return ( ); } if (!part.trim()) return null; return
; })} ); } /** Core slide rendering at the deck's aspect-ratio resolution - used by both thumbnails and presentation */ export function SlideInner({ slide, designSystem, aspectRatio, onOverflowChange, }: { slide: Slide; designSystem?: DesignSystemData; aspectRatio?: AspectRatio; onOverflowChange?: (info: SlideOverflowInfo) => void; }) { const t = useT(); const dims = getAspectRatioDims(aspectRatio); const sizeStyle: React.CSSProperties = { width: dims.width, height: dims.height, }; const bg = slide.background || "bg-[#000000]"; const isGradientClass = bg.startsWith("bg-"); const safeBackground = !isGradientClass ? sanitizeCssValue(bg) : null; const bgStyle = safeBackground ? { background: safeBackground } : undefined; const bgClass = isGradientClass ? bg : ""; const isCentered = slide.layout === "title"; const dsStyle = designSystem ? ({ "--ds-accent": designSystem.colors.accent, "--ds-bg": designSystem.colors.background, "--ds-text": designSystem.colors.text, "--ds-text-muted": designSystem.colors.textMuted, "--ds-heading-font": designSystem.typography.headingFont, "--ds-body-font": designSystem.typography.bodyFont, "--ds-primary": designSystem.colors.primary, "--ds-radius": designSystem.borders.radius, } as React.CSSProperties) : {}; // If slide has excalidraw data, render it as a static SVG thumbnail if ( slide.excalidrawData && parseExcalidrawData(slide.excalidrawData)?.elements?.length ) { return (
); } const imageLoadingOverlay = slide.imageLoading && (
{t("raw.generatingImage")}
); // Slides with fmd-slide class use inline styles — render as raw HTML to avoid layout conflicts const content = typeof slide.content === "string" ? slide.content : ""; const isRawHtml = content.includes('class="fmd-slide"') || content.trimStart().startsWith("<") || ["blank", "section", "statement", "full-image"].includes(slide.layout); if (!isRawHtml && slide.layout === "two-column") { const parts = content.split("---"); const left = parts[0] || ""; const right = parts[1] || ""; return (
{imageLoadingOverlay} {left.trim()} {right.trim()}
); } if (isRawHtml) { return (
); } return (
{imageLoadingOverlay} {content}
); } export default function SlideRenderer({ slide, className = "", thumbnail = true, designSystem, aspectRatio, onOverflowChange, }: SlideRendererProps) { const dims = getAspectRatioDims(aspectRatio); if (!thumbnail) { // Full-size rendering (for presentation mode) — same intrinsic canvas scaled to fill return (
); } // Thumbnail mode: render at intrinsic resolution and scale down to fit return (
); } /** Sets --slide-scale CSS variable on the parent based on container size */ function ScaleHelper({ targetWidth = 960, targetHeight, mode, }: { targetWidth?: number; targetHeight?: number; mode?: "contain"; }) { // Stable ref callback so React doesn't churn the ResizeObserver on every // render. Returns a cleanup so React 19 disconnects on unmount / identity // change — the previous inline-arrow version stored cleanup on // `el.__cleanup` and never invoked it, leaking an observer per render. const refCallback = useCallback( (el: HTMLDivElement | null) => { if (!el) return; const parent = el.parentElement; if (!parent) return; const updateScale = () => { // Prefer offset*, fall back to getBoundingClientRect, then to // viewport. If everything still reads 0, bail rather than write // `--slide-scale: 0` — that would scale the slide to nothing and // the bad value would stick on the parent until the next // observer tick. const rect = parent.getBoundingClientRect(); const w = parent.offsetWidth || rect.width || window.innerWidth; const h = parent.offsetHeight || rect.height || window.innerHeight; if (!w || !h) return; if (mode === "contain" && targetHeight) { const scale = Math.min(w / targetWidth, h / targetHeight); parent.style.setProperty("--slide-scale", String(scale)); } else { parent.style.setProperty("--slide-scale", String(w / targetWidth)); } }; // Try sync (layout may already be settled) and defer one frame // (in case it isn't — first paint of /present can lag the swap // out of the loading fallback). updateScale(); const raf = requestAnimationFrame(updateScale); const observer = new ResizeObserver(updateScale); observer.observe(parent); return () => { cancelAnimationFrame(raf); observer.disconnect(); }; }, [targetWidth, targetHeight, mode], ); return (
); }