UNPKG

@lobehub/ui

Version:

Lobe UI is an open-source UI component library for building AIGC web apps

1 lines 12.5 kB
{"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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"}