{"version":3,"file":"Iframe.mjs","names":[],"sources":["../../src/HtmlPreview/Iframe.tsx"],"sourcesContent":["'use client';\n\nimport { createStyles, cx } from 'antd-style';\nimport { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';\n\nimport { buildShellSrcDoc, SHELL_UPDATE_MESSAGE_TYPE } from './buildShellSrcDoc';\nimport { buildStaticSrcDoc } from './buildStaticSrcDoc';\nimport { DEFAULT_HEIGHT, DEFAULT_SANDBOX, SRCDOC_MAX_LENGTH } from './const';\nimport { AUTO_HEIGHT_MESSAGE_TYPE } from './injectAutoHeightScript';\nimport type { HtmlPreviewIframeProps } from './type';\n\nconst useStyles = createStyles(({ css, cssVar }) => ({\n  fallback: css`\n    padding: 16px;\n    font-size: 13px;\n    color: ${cssVar.colorTextDescription};\n  `,\n  iframe: css`\n    display: block;\n    width: 100%;\n    border: none;\n    background: transparent;\n  `,\n}));\n\ninterface Payload {\n  bodyHtml: string;\n  /**\n   * Non-inline-style children of `<head>` serialised in document order:\n   * `<script src=…>`, `<script>…</script>`, `<link>`, `<meta>`, `<base>`,\n   * `<title>` etc. The shell appends/dedupes these into its own head so\n   * head-loaded resources (Tailwind CDN, p5.js, fonts, …) work for full\n   * documents. Inline `<style>` is intentionally excluded — those flow\n   * through `styleContent` so streaming partial CSS grows in place rather\n   * than stacking duplicate `<style>` blocks.\n   *\n   * Empty until the user's `<head>` is *sealed* (a `</head>` close tag has\n   * arrived, or `<body>` has opened — browsers auto-close head at that\n   * point). Holding off prevents partial `src=\"https://cd\"` URLs from\n   * being mounted and 404-ing while the model is still streaming.\n   */\n  headExtrasHtml: string;\n  styleContent: string;\n}\n\n// Head is \"sealed\" as soon as we see a close tag or the body has begun;\n// after that point, additional chunks land in body and head extras won't\n// change.\nconst headSealedPattern = /<\\/head\\s*>|<body[\\s>]/i;\nconst isHeadSealed = (raw: string): boolean => headSealedPattern.test(raw);\n\nconst parseContent = (() => {\n  // Lazy-init: only need one parser instance, and only in the browser.\n  let parser: DOMParser | null = null;\n  return (content: string): Payload | null => {\n    if (typeof window === 'undefined') return null;\n    if (!content) return { bodyHtml: '', headExtrasHtml: '', styleContent: '' };\n    if (!parser) parser = new DOMParser();\n    const doc = parser.parseFromString(content, 'text/html');\n\n    const styleParts: string[] = [];\n    const headExtras: string[] = [];\n\n    if (doc.head) {\n      for (const child of Array.from(doc.head.children)) {\n        if (child.tagName === 'STYLE') {\n          styleParts.push(child.textContent || '');\n        } else {\n          headExtras.push(child.outerHTML);\n        }\n      }\n    }\n\n    return {\n      bodyHtml: doc.body ? doc.body.innerHTML : '',\n      headExtrasHtml: isHeadSealed(content) ? headExtras.join('') : '',\n      styleContent: styleParts.join('\\n'),\n    };\n  };\n})();\n\nexport const HtmlPreviewIframe = memo<HtmlPreviewIframeProps>(\n  ({\n    animated,\n    background,\n    content,\n    className,\n    defaultHeight = DEFAULT_HEIGHT,\n    ref,\n    sandbox = DEFAULT_SANDBOX,\n    style,\n    title = 'HTML preview',\n  }) => {\n    const { styles } = useStyles();\n    const innerRef = useRef<HTMLIFrameElement | null>(null);\n    const frameId = useId();\n    const [height, setHeight] = useState<number>(defaultHeight);\n    // Track caller-supplied `defaultHeight` in a ref so the (frameId-keyed)\n    // message handler can floor auto-height updates without re-subscribing\n    // every render.\n    const defaultHeightRef = useRef(defaultHeight);\n    useEffect(() => {\n      defaultHeightRef.current = defaultHeight;\n    }, [defaultHeight]);\n\n    const tooLarge = content.length > SRCDOC_MAX_LENGTH;\n\n    // ── Static mode ─────────────────────────────────────────────────────\n    // When the content isn't being streamed we can hand the iframe the\n    // user's HTML directly. The browser's normal HTML parser runs:\n    //   <script src=…> tags fetch and execute as if on a regular page,\n    //   inline <script> blocks run in DOM order, MutationObservers (like\n    //   Tailwind Play CDN's) get the document at its expected lifecycle\n    //   stage. Anything that a model can produce as a standalone web\n    //   page works without special handling on our side.\n    const staticSrcDoc = useMemo(() => {\n      if (animated || tooLarge) return null;\n      return buildStaticSrcDoc({ background, content, frameId });\n    }, [animated, background, content, frameId, tooLarge]);\n\n    // ── Shell mode ─────────────────────────────────────────────────────\n    // For streaming we keep one shell iframe loaded for the lifetime of\n    // the session and pump content updates through postMessage. The\n    // shell's morph script handles in-place DOM diffing + fade-in for\n    // new nodes (see buildShellSrcDoc.ts). Tradeoff: external <script\n    // src> tags appended this way don't always integrate cleanly with\n    // class-engine CDNs, so static content is preferred when possible.\n    const shellSrcDoc = useMemo(() => {\n      if (!animated || tooLarge) return null;\n      return buildShellSrcDoc({ background, frameId });\n    }, [animated, background, frameId, tooLarge]);\n\n    const [shellReady, setShellReady] = useState(false);\n    useEffect(() => {\n      // Each time we swap between shell and static modes (or rebuild the\n      // shell because the theme changed) we need to wait for a fresh\n      // ready ping before posting content.\n      setShellReady(false);\n    }, [shellSrcDoc]);\n\n    const payload = useMemo<Payload | null>(() => {\n      if (!animated || tooLarge) return null;\n      return parseContent(content);\n    }, [animated, content, tooLarge]);\n\n    // Push content into the shell iframe whenever it changes — but only\n    // after the shell has signalled ready, so its listener exists.\n    useEffect(() => {\n      if (!animated) return;\n      if (!shellReady || !payload) return;\n      const win = innerRef.current?.contentWindow;\n      if (!win) return;\n      win.postMessage(\n        {\n          frameId,\n          payload,\n          type: SHELL_UPDATE_MESSAGE_TYPE,\n        },\n        '*',\n      );\n    }, [animated, payload, shellReady, frameId]);\n\n    useEffect(() => {\n      const handler = (event: MessageEvent) => {\n        const data = event.data;\n        if (!data || typeof data !== 'object') return;\n        if (data.frameId !== frameId) return;\n        if (event.source !== innerRef.current?.contentWindow) return;\n\n        if (data.type === `${SHELL_UPDATE_MESSAGE_TYPE}:ready`) {\n          setShellReady(true);\n          return;\n        }\n\n        if (data.type === AUTO_HEIGHT_MESSAGE_TYPE) {\n          const next = Number(data.height);\n          if (!Number.isFinite(next) || next <= 0) return;\n          // Floor at `defaultHeight`. During streaming the shell body\n          // briefly reports a small height between morph commits (empty\n          // body just after head closes, then partial body, etc.) and on\n          // every iframe remount the auto-height starts at body padding\n          // before climbing back to content height. Letting the iframe\n          // shrink to that interim height causes a visible up/down jitter\n          // that reads as flicker — especially under a Markdown wrapper\n          // that re-renders every chunk. Anchoring to the caller's stated\n          // minimum height eliminates that without affecting the final\n          // size: real content taller than `defaultHeight` grows the\n          // iframe; content shorter than it stays at the floor.\n          const floored = Math.max(next, defaultHeightRef.current);\n          setHeight((prev) => (Math.abs(prev - floored) < 1 ? prev : floored));\n        }\n      };\n\n      window.addEventListener('message', handler);\n      return () => window.removeEventListener('message', handler);\n    }, [frameId]);\n\n    const setRef = useCallback(\n      (node: HTMLIFrameElement | null) => {\n        innerRef.current = node;\n        if (typeof ref === 'function') ref(node);\n        else if (ref) (ref as { current: HTMLIFrameElement | null }).current = node;\n      },\n      [ref],\n    );\n\n    if (tooLarge) {\n      return (\n        <div className={cx(styles.fallback, className)} style={style}>\n          Content too large to preview inline.\n        </div>\n      );\n    }\n\n    const srcDoc = staticSrcDoc ?? shellSrcDoc ?? '';\n\n    // Key the iframe by mode so React fully unmounts the previous DOM\n    // element when we switch from shell (streaming) to static (finalised).\n    // Setting iframe.srcdoc on an already-loaded element doesn't reliably\n    // re-navigate in Chromium when the previous document was also srcdoc-\n    // based — the new srcdoc attribute lands, but the document doesn't\n    // reload, so the user sees stale (often empty) shell content. A fresh\n    // element forces the browser to parse and load the new srcdoc.\n    const iframeKey = animated ? 'shell' : 'static';\n\n    return (\n      <iframe\n        className={cx(styles.iframe, className)}\n        key={iframeKey}\n        ref={setRef}\n        sandbox={sandbox}\n        srcDoc={srcDoc}\n        style={{ height, ...style }}\n        title={title}\n      />\n    );\n  },\n);\n\nHtmlPreviewIframe.displayName = 'HtmlPreviewIframe';\n\nexport default HtmlPreviewIframe;\n"],"mappings":"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"}