UNPKG

@lobehub/ui

Version:

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

1 lines 12.4 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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"}