UNPKG

@lobehub/ui

Version:

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

1 lines 9.91 kB
{"version":3,"file":"buildShellSrcDoc.mjs","names":[],"sources":["../../src/HtmlPreview/buildShellSrcDoc.ts"],"sourcesContent":["import { buildAutoHeightScript } from './injectAutoHeightScript';\nimport { STORAGE_SHIM_SCRIPT } from './injectStorageShim';\n\nexport const SHELL_UPDATE_MESSAGE_TYPE = 'lobe-html-shell-update';\n\ninterface BuildShellSrcDocOptions {\n background?: string;\n frameId: string;\n}\n\n/**\n * Build the iframe's one-and-only document.\n *\n * Why a \"shell\" doc:\n * The iframe is loaded *once* and never reloads during a streaming session.\n * All subsequent body updates arrive via `postMessage` from the parent\n * (see `SHELL_UPDATE_MESSAGE_TYPE`). The script in this document morphs the\n * live DOM in place, so already-painted nodes stay untouched — only nodes\n * that are *new* to this commit get a `.lobe-html-new` class and a CSS\n * fade-in. No iframe reload means no white flash, no script reboots, and\n * no jitter from height resets.\n */\nexport const buildShellSrcDoc = ({ background, frameId }: BuildShellSrcDocOptions): string => {\n const baseRules = `html,body{margin:0;padding:0;${background ? `background:${background};` : ''}color-scheme:light dark;}`;\n const fadeRules = `@keyframes lobe-html-fade{from{opacity:0}to{opacity:1}}.lobe-html-new{animation:lobe-html-fade 240ms ease-out both;}`;\n\n const morphScript = `\n(function () {\n var FRAME_ID = ${JSON.stringify(frameId)};\n var UPDATE_TYPE = ${JSON.stringify(SHELL_UPDATE_MESSAGE_TYPE)};\n\n function cloneScript(src) {\n // <script> elements parsed via DOMParser are inert. Rebuild them as\n // proper DOM scripts so the browser executes them.\n //\n // Important: only set .text for inline scripts. Setting it on a\n // src-bearing script (even to an empty string) causes some browser /\n // extension combinations to treat the element as an inline script\n // with empty body and skip the external fetch — so the CDN never\n // loads. We just copy attributes; the browser will fetch the src on\n // append.\n var s = document.createElement('script');\n for (var i = 0; i < src.attributes.length; i++) {\n var a = src.attributes[i];\n s.setAttribute(a.name, a.value);\n }\n if (!src.hasAttribute('src')) {\n var text = src.textContent;\n if (text) s.text = text;\n }\n return s;\n }\n\n function importNode(node) {\n if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'SCRIPT') {\n return cloneScript(node);\n }\n return document.importNode(node, true);\n }\n\n function markFadeIn(node) {\n if (node.nodeType === Node.ELEMENT_NODE && node.classList) {\n node.classList.add('lobe-html-new');\n }\n }\n\n // Recursive prefix-match morph. For each parent we match leading children\n // that already exist (by outerHTML or recursive morph), then we remove\n // trailing children that no longer exist, and finally append the new\n // tail with a fade-in class.\n function morph(oldEl, newEl) {\n if (oldEl.nodeType !== newEl.nodeType) return false;\n if (oldEl.nodeType !== Node.ELEMENT_NODE) return false;\n if (oldEl.tagName !== newEl.tagName) return false;\n\n // Sync attributes\n var oldAttrs = oldEl.attributes;\n for (var i = oldAttrs.length - 1; i >= 0; i--) {\n var an = oldAttrs[i].name;\n if (!newEl.hasAttribute(an)) oldEl.removeAttribute(an);\n }\n var newAttrs = newEl.attributes;\n for (var j = 0; j < newAttrs.length; j++) {\n var na = newAttrs[j];\n if (oldEl.getAttribute(na.name) !== na.value) {\n oldEl.setAttribute(na.name, na.value);\n }\n }\n\n var oldKids = oldEl.childNodes;\n var newKids = newEl.childNodes;\n var commonLen = 0;\n\n while (commonLen < oldKids.length && commonLen < newKids.length) {\n var o = oldKids[commonLen];\n var n = newKids[commonLen];\n if (o.nodeType !== n.nodeType) break;\n if (o.nodeType === Node.TEXT_NODE) {\n if (o.textContent !== n.textContent) {\n // Update text content in place — no fade for text.\n o.textContent = n.textContent;\n }\n commonLen++;\n } else if (o.nodeType === Node.ELEMENT_NODE) {\n // Cheap identity check before recursing.\n if (o.outerHTML === n.outerHTML) {\n commonLen++;\n } else if (morph(o, n)) {\n commonLen++;\n } else {\n break;\n }\n } else {\n commonLen++;\n }\n }\n\n // Trim old trailing children that no longer exist.\n while (oldEl.childNodes.length > commonLen) {\n oldEl.removeChild(oldEl.lastChild);\n }\n\n // Append the new tail with fade-in markers, batched through a\n // DocumentFragment. A flat sequence of appendChild calls fires one\n // mutation per element — MutationObserver libraries (Tailwind Play\n // CDN, Stimulus, etc.) that batch their work can drop intermediate\n // notifications if they arrive too quickly. Going through a fragment\n // delivers exactly one childList mutation that lists all new nodes\n // at once, which observers handle reliably.\n if (commonLen < newKids.length) {\n var frag = document.createDocumentFragment();\n for (var k = commonLen; k < newKids.length; k++) {\n var imported = importNode(newKids[k]);\n markFadeIn(imported);\n frag.appendChild(imported);\n }\n oldEl.appendChild(frag);\n }\n\n return true;\n }\n\n // Track which head extras (scripts/links/meta/title/base) we've already\n // mounted so re-arriving chunks don't re-execute scripts or duplicate\n // resources. Keyed by outerHTML — for streaming partial URLs each\n // partial-and-then-complete tag is a distinct key, which means a partial\n // CDN URL may briefly 404 before the complete one succeeds. That's\n // acceptable; the alternative (waiting for the closing tag heuristic) is\n // fragile and would defeat the live-CDN use case entirely.\n var headSeen = Object.create(null);\n\n function syncHeadExtras(headExtrasHtml) {\n if (typeof headExtrasHtml !== 'string') return;\n var parser = new DOMParser();\n var doc = parser.parseFromString(\n '<!doctype html><html><head>' + headExtrasHtml + '</head></html>',\n 'text/html',\n );\n var children = doc.head ? doc.head.children : [];\n for (var i = 0; i < children.length; i++) {\n var src = children[i];\n var key = src.outerHTML;\n if (headSeen[key]) continue;\n headSeen[key] = true;\n var clone = importNode(src);\n // Tag for debugging — also keeps these distinguishable from the\n // shell's own head children if anything ever needs to inspect them.\n if (clone.setAttribute) clone.setAttribute('data-lobe-user', '');\n document.head.appendChild(clone);\n }\n }\n\n function applyUpdate(payload) {\n if (!payload) return;\n\n // 1) Inline user styles: merged into a single growing <style> element.\n // Streaming partial CSS just keeps overwriting this text until the\n // rules become complete, so we don't stack half-parsed <style>\n // blocks in the head.\n var styleEl = document.getElementById('lobe-user-style');\n if (styleEl && styleEl.textContent !== payload.styleContent) {\n styleEl.textContent = payload.styleContent || '';\n }\n\n // 2) Everything else in the user's <head> (scripts, links, meta, …):\n // append-with-dedupe so head-loaded resources actually run.\n syncHeadExtras(payload.headExtrasHtml);\n\n // 3) Body: in-place morph with fade-in on new nodes.\n var bodyParser = new DOMParser();\n var newDoc = bodyParser.parseFromString(\n '<!doctype html><html><body>' + (payload.bodyHtml || '') + '</body></html>',\n 'text/html',\n );\n\n // morph() returns false only for type mismatch on the root — body to\n // body always matches, so this is safe.\n morph(document.body, newDoc.body);\n\n // Nudge class-engine CDNs (Tailwind Play CDN, Stimulus, etc.) into\n // re-scanning the document. They watch via MutationObserver but some\n // implementations only consider the directly-mutated nodes from each\n // record and skip recursing into nested descendants, so deeply-styled\n // subtrees can end up with un-generated utility classes. Toggling a\n // throwaway class on body produces an attribute mutation that prompts\n // a fresh full-document scan.\n try {\n document.body.classList.add('_lobe-rescan');\n document.body.classList.remove('_lobe-rescan');\n } catch (_) {}\n }\n\n window.addEventListener('message', function (event) {\n var data = event.data;\n if (!data || data.type !== UPDATE_TYPE || data.frameId !== FRAME_ID) return;\n applyUpdate(data.payload);\n });\n\n // Signal the parent that the listener is wired up so it can flush any\n // pending content that was queued before this script ran.\n try {\n parent.postMessage({ type: UPDATE_TYPE + ':ready', frameId: FRAME_ID }, '*');\n } catch (_) {}\n})();\n`;\n\n return `<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<style>${baseRules}${fadeRules}</style>\n<style id=\"lobe-user-style\"></style>\n<script>${STORAGE_SHIM_SCRIPT}</script>\n<script>${buildAutoHeightScript(frameId)}</script>\n<script>${morphScript}</script>\n</head>\n<body></body>\n</html>`;\n};\n"],"mappings":";;;AAGA,MAAa,4BAA4B;;;;;;;;;;;;;AAmBzC,MAAa,oBAAoB,EAAE,YAAY,cAA+C;CAC5F,MAAM,YAAY,gCAAgC,aAAa,cAAc,WAAW,KAAK,GAAG;CAChG,MAAM,YAAY;CAElB,MAAM,cAAc;;mBAEH,KAAK,UAAU,QAAQ,CAAC;sBACrB,KAAK,UAAU,0BAA0B,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqM9D,QAAO;;;;SAIA,YAAY,UAAU;;UAErB,oBAAoB;UACpB,sBAAsB,QAAQ,CAAC;UAC/B,YAAY"}