| author | |
| committer | |
| log | 98a6246d07624432a6e6d0ab4fb87bede90a49a6 |
| tree | b89a9528c3d0a46bed9c9bc0947e4cf4ec8f59ef |
| parent | 6524a8d43795dc543f7cfb572388cdea3662ad48 |
| signature | Signed by PGP key B5690EEEBB952194 |
7 files changed, 72 insertions(+), 14 deletions(-)
apps/web/src/components/chat/HtmlRenderFrame.tsx+3-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { EnvironmentId } from "@t3tools/contracts"; | 1 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 3 | HTML_RENDER_COLUMN_WIDTH, | 3 | HTML_RENDER_COLUMN_WIDTH, |
| 4 | htmlRenderFileName, | 4 | htmlRenderFileName, |
| ... | @@ -28,6 +28,7 @@ const MIN_URL_LIFE_MS = 5 * 60_000; | ... | @@ -28,6 +28,7 @@ const MIN_URL_LIFE_MS = 5 * 60_000; |
| 28 | export function HtmlRenderFrame(props: { | 28 | export function HtmlRenderFrame(props: { |
| 29 | readonly environmentId: EnvironmentId; | 29 | readonly environmentId: EnvironmentId; |
| 30 | readonly htmlRender: HtmlRenderReference; | 30 | readonly htmlRender: HtmlRenderReference; |
| 31 | readonly threadRef: ScopedThreadRef | undefined; | ||
| 31 | readonly onOpen: (attachment: ChatFileAttachment) => void; | 32 | readonly onOpen: (attachment: ChatFileAttachment) => void; |
| 32 | }) { | 33 | }) { |
| 33 | const { attachmentId, title } = props.htmlRender; | 34 | const { attachmentId, title } = props.htmlRender; |
| ... | @@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: { | ... | @@ -97,6 +98,7 @@ export function HtmlRenderFrame(props: { |
| 97 | <HtmlRenderDocument | 98 | <HtmlRenderDocument |
| 98 | src={src} | 99 | src={src} |
| 99 | title={title} | 100 | title={title} |
| 101 | threadRef={props.threadRef} | ||
| 100 | className="block size-full" | 102 | className="block size-full" |
| 101 | onContentHeight={setContentHeight} | 103 | onContentHeight={setContentHeight} |
| 102 | /> | 104 | /> |
apps/web/src/components/chat/MessagesTimeline.tsx+1| ... | @@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm | ... | @@ -3052,6 +3052,7 @@ function HtmlRenderTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "htm |
| 3052 | key={row.htmlRender.attachmentId} | 3052 | key={row.htmlRender.attachmentId} |
| 3053 | environmentId={ctx.activeThreadEnvironmentId} | 3053 | environmentId={ctx.activeThreadEnvironmentId} |
| 3054 | htmlRender={row.htmlRender} | 3054 | htmlRender={row.htmlRender} |
| 3055 | threadRef={ctx.threadRef ?? undefined} | ||
| 3055 | onOpen={ctx.onFileOpen} | 3056 | onOpen={ctx.onFileOpen} |
| 3056 | /> | 3057 | /> |
| 3057 | </div> | 3058 | </div> |
apps/web/src/components/files/AttachmentFilePreview.tsx+4-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; | 1 | import { filePreviewDelimiter } from "@t3tools/shared/delimitedPreview"; |
| 2 | import type { EnvironmentId } from "@t3tools/contracts"; | 2 | import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; |
| 3 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; | 3 | import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments"; |
| 4 | import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; | 4 | import { readFilePreviewResponse } from "@t3tools/client-runtime/file-preview"; |
| 5 | import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; | 5 | import { filePreviewKind, FILE_TEXT_PREVIEW_MAX_BYTES } from "@t3tools/shared/filePreview"; |
| ... | @@ -61,6 +61,8 @@ export function AttachmentFilePreview(props: { | ... | @@ -61,6 +61,8 @@ export function AttachmentFilePreview(props: { |
| 61 | asset?: { environmentId: EnvironmentId; attachmentId: string }; | 61 | asset?: { environmentId: EnvironmentId; attachmentId: string }; |
| 62 | /** An agent's HTML render, shown in the app theme. */ | 62 | /** An agent's HTML render, shown in the app theme. */ |
| 63 | htmlRender?: boolean; | 63 | htmlRender?: boolean; |
| 64 | /** The thread an HTML render's links open beside. */ | ||
| 65 | threadRef?: ScopedThreadRef; | ||
| 64 | /** First crumb: where the file comes from. */ | 66 | /** First crumb: where the file comes from. */ |
| 65 | origin?: string; | 67 | origin?: string; |
| 66 | onRemove?: () => void; | 68 | onRemove?: () => void; |
| ... | @@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: { | ... | @@ -250,6 +252,7 @@ export function AttachmentFilePreview(props: { |
| 250 | title={props.name} | 252 | title={props.name} |
| 251 | pdf={kind === "pdf"} | 253 | pdf={kind === "pdf"} |
| 252 | htmlRender={props.htmlRender === true} | 254 | htmlRender={props.htmlRender === true} |
| 255 | threadRef={props.threadRef} | ||
| 253 | /> | 256 | /> |
| 254 | ) : kind === "audio" ? ( | 257 | ) : kind === "audio" ? ( |
| 255 | <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} /> | 258 | <AudioPreview src={url} name={props.name} onError={() => setError("Unable to load audio.")} /> |
apps/web/src/components/files/BrowserDocumentFrame.tsx+37-2| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import type { ScopedThreadRef } from "@t3tools/contracts"; | ||
| 1 | import { | 2 | import { |
| 2 | htmlRenderThemeFragment, | 3 | htmlRenderThemeFragment, |
| 3 | htmlRenderThemeMessage, | 4 | htmlRenderThemeMessage, |
| ... | @@ -5,9 +6,12 @@ import { | ... | @@ -5,9 +6,12 @@ import { |
| 5 | readHtmlRenderContentHeight, | 6 | readHtmlRenderContentHeight, |
| 6 | readHtmlRenderLinkRequest, | 7 | readHtmlRenderLinkRequest, |
| 7 | } from "@t3tools/shared/htmlRender"; | 8 | } from "@t3tools/shared/htmlRender"; |
| 8 | import { useEffect, useLayoutEffect, useRef, useState } from "react"; | 9 | import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; |
| 9 | 10 | ||
| 11 | import { useOpenLink } from "~/browser/useOpenLink"; | ||
| 10 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; | 12 | import { useHtmlRenderTheme } from "~/hooks/useHtmlRenderTheme"; |
| 13 | import { useOpenChangeRequestLink } from "~/lib/openPullRequestLink"; | ||
| 14 | import { readLocalApi } from "~/localApi"; | ||
| 11 | import { cn } from "~/lib/utils"; | 15 | import { cn } from "~/lib/utils"; |
| 12 | 16 | ||
| 13 | /** | 17 | /** |
| ... | @@ -32,6 +36,8 @@ export function BrowserDocumentFrame(props: { | ... | @@ -32,6 +36,8 @@ export function BrowserDocumentFrame(props: { |
| 32 | readonly title: string; | 36 | readonly title: string; |
| 33 | readonly pdf: boolean; | 37 | readonly pdf: boolean; |
| 34 | readonly htmlRender?: boolean; | 38 | readonly htmlRender?: boolean; |
| 39 | /** The thread an HTML render's links open beside. */ | ||
| 40 | readonly threadRef?: ScopedThreadRef | undefined; | ||
| 35 | }) { | 41 | }) { |
| 36 | const className = "min-h-0 flex-1 border-0 bg-white"; | 42 | const className = "min-h-0 flex-1 border-0 bg-white"; |
| 37 | return props.pdf ? ( | 43 | return props.pdf ? ( |
| ... | @@ -47,6 +53,7 @@ export function BrowserDocumentFrame(props: { | ... | @@ -47,6 +53,7 @@ export function BrowserDocumentFrame(props: { |
| 47 | key={props.src} | 53 | key={props.src} |
| 48 | src={props.src} | 54 | src={props.src} |
| 49 | title={props.title} | 55 | title={props.title} |
| 56 | threadRef={props.threadRef} | ||
| 50 | className="min-h-0 flex-1" | 57 | className="min-h-0 flex-1" |
| 51 | /> | 58 | /> |
| 52 | ) : ( | 59 | ) : ( |
| ... | @@ -70,6 +77,8 @@ export function HtmlRenderDocument(props: { | ... | @@ -70,6 +77,8 @@ export function HtmlRenderDocument(props: { |
| 70 | readonly src: string; | 77 | readonly src: string; |
| 71 | readonly title: string; | 78 | readonly title: string; |
| 72 | readonly className?: string; | 79 | readonly className?: string; |
| 80 | /** The thread the page's links open beside, as a chat link would. */ | ||
| 81 | readonly threadRef?: ScopedThreadRef | undefined; | ||
| 73 | /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ | 82 | /** Receives the page's content height whenever it changes, so an inline frame can fit it. */ |
| 74 | readonly onContentHeight?: (height: number) => void; | 83 | readonly onContentHeight?: (height: number) => void; |
| 75 | }) { | 84 | }) { |
| ... | @@ -81,6 +90,32 @@ export function HtmlRenderDocument(props: { | ... | @@ -81,6 +90,32 @@ export function HtmlRenderDocument(props: { |
| 81 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); | 90 | frameRef.current?.contentWindow?.postMessage(htmlRenderThemeMessage(theme), "*"); |
| 82 | }; | 91 | }; |
| 83 | useEffect(postTheme, [theme]); | 92 | useEffect(postTheme, [theme]); |
| 93 | const openChangeRequestLink = useOpenChangeRequestLink(props.threadRef); | ||
| 94 | const openLink = useOpenLink(props.threadRef); | ||
| 95 | // Same rules as a chat link: a pull request opens in the right panel, any | ||
| 96 | // other link follows "Open links in", and Cmd/Ctrl-click goes to the system | ||
| 97 | // browser. Without a thread there is no panel, so it goes to the browser. | ||
| 98 | const open = useEffectEvent((url: string, modifiers: { metaKey: boolean; ctrlKey: boolean }) => { | ||
| 99 | // The modifier's destination needs no saved setting, so it never waits on one. | ||
| 100 | if (modifiers.metaKey || modifiers.ctrlKey) { | ||
| 101 | void readLocalApi() | ||
| 102 | ?.shell.openExternal(url) | ||
| 103 | .catch((cause: unknown) => console.error(cause)); | ||
| 104 | return; | ||
| 105 | } | ||
| 106 | if ( | ||
| 107 | props.threadRef !== undefined && | ||
| 108 | openChangeRequestLink( | ||
| 109 | { ...modifiers, preventDefault: () => undefined, stopPropagation: () => undefined }, | ||
| 110 | url, | ||
| 111 | undefined, | ||
| 112 | props.threadRef.environmentId, | ||
| 113 | ) | ||
| 114 | ) { | ||
| 115 | return; | ||
| 116 | } | ||
| 117 | void openLink(url, { event: modifiers }).catch((cause: unknown) => console.error(cause)); | ||
| 118 | }); | ||
| 84 | // The page cannot open windows itself (an inline page runs unopened, and | 119 | // The page cannot open windows itself (an inline page runs unopened, and |
| 85 | // desktop sends any window to the browser). It asks the client, which opens | 120 | // desktop sends any window to the browser). It asks the client, which opens |
| 86 | // the link only while this frame has focus and the reader has just used the | 121 | // the link only while this frame has focus and the reader has just used the |
| ... | @@ -99,7 +134,7 @@ export function HtmlRenderDocument(props: { | ... | @@ -99,7 +134,7 @@ export function HtmlRenderDocument(props: { |
| 99 | ) { | 134 | ) { |
| 100 | return; | 135 | return; |
| 101 | } | 136 | } |
| 102 | window.open(request.url, "_blank", "noopener,noreferrer"); | 137 | open(request.url, request); |
| 103 | frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*"); | 138 | frame.contentWindow?.postMessage(htmlRenderResult(request.id), "*"); |
| 104 | }; | 139 | }; |
| 105 | window.addEventListener("message", openLink); | 140 | window.addEventListener("message", openLink); |
apps/web/src/components/files/FilePreviewPanel.tsx+1| ... | @@ -1352,6 +1352,7 @@ export default function FilePreviewPanel({ | ... | @@ -1352,6 +1352,7 @@ export default function FilePreviewPanel({ |
| 1352 | sizeBytes={attachment.sizeBytes} | 1352 | sizeBytes={attachment.sizeBytes} |
| 1353 | asset={{ environmentId, attachmentId: attachment.id }} | 1353 | asset={{ environmentId, attachmentId: attachment.id }} |
| 1354 | htmlRender={attachment.htmlRender === true} | 1354 | htmlRender={attachment.htmlRender === true} |
| 1355 | threadRef={threadRef} | ||
| 1355 | /> | 1356 | /> |
| 1356 | ) : relativePath && isVideo && absolutePath ? ( | 1357 | ) : relativePath && isVideo && absolutePath ? ( |
| 1357 | <WorkspaceVideoPreview | 1358 | <WorkspaceVideoPreview |
packages/shared/src/htmlRender.test.ts+7-2| ... | @@ -76,16 +76,21 @@ describe("injectHtmlRenderBootstrap", () => { | ... | @@ -76,16 +76,21 @@ describe("injectHtmlRenderBootstrap", () => { |
| 76 | 76 | ||
| 77 | describe("readHtmlRenderLinkRequest", () => { | 77 | describe("readHtmlRenderLinkRequest", () => { |
| 78 | it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => { | 78 | it("accepts only http(s) URLs in an MCP Apps ui/open-link request", () => { |
| 79 | const link = (url: unknown) => ({ | 79 | const link = (url: unknown, modifiers = {}) => ({ |
| 80 | jsonrpc: "2.0", | 80 | jsonrpc: "2.0", |
| 81 | id: 1, | 81 | id: 1, |
| 82 | method: "ui/open-link", | 82 | method: "ui/open-link", |
| 83 | params: { url }, | 83 | params: { url, ...modifiers }, |
| 84 | }); | 84 | }); |
| 85 | expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({ | 85 | expect(readHtmlRenderLinkRequest(link("https://example.com/a"))).toEqual({ |
| 86 | id: 1, | 86 | id: 1, |
| 87 | url: "https://example.com/a", | 87 | url: "https://example.com/a", |
| 88 | metaKey: false, | ||
| 89 | ctrlKey: false, | ||
| 88 | }); | 90 | }); |
| 91 | expect( | ||
| 92 | readHtmlRenderLinkRequest(link("https://example.com/a", { metaKey: true, ctrlKey: "yes" })), | ||
| 93 | ).toEqual({ id: 1, url: "https://example.com/a", metaKey: true, ctrlKey: false }); | ||
| 89 | expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined(); | 94 | expect(readHtmlRenderLinkRequest(link("javascript:alert(1)"))).toBeUndefined(); |
| 90 | expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined(); | 95 | expect(readHtmlRenderLinkRequest(link("file:///etc/passwd"))).toBeUndefined(); |
| 91 | expect( | 96 | expect( |
packages/shared/src/htmlRender.ts+19-8| ... | @@ -287,17 +287,28 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined { | ... | @@ -287,17 +287,28 @@ export function readHtmlRenderContentHeight(data: unknown): number | undefined { |
| 287 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; | 287 | return typeof height === "number" && Number.isFinite(height) && height > 0 ? height : undefined; |
| 288 | } | 288 | } |
| 289 | 289 | ||
| 290 | /** A render's `ui/open-link` request, if `data` is one with an http(s) URL. */ | 290 | /** |
| 291 | export function readHtmlRenderLinkRequest( | 291 | * A render's `ui/open-link` request, if `data` is one with an http(s) URL. The |
| 292 | data: unknown, | 292 | * bootstrap adds the click's Cmd/Ctrl state, so a modifier click can leave the |
| 293 | ): { readonly id: string | number; readonly url: string } | undefined { | 293 | * app the way it does from a chat link. |
| 294 | */ | ||
| 295 | export function readHtmlRenderLinkRequest(data: unknown): | ||
| 296 | | { | ||
| 297 | readonly id: string | number; | ||
| 298 | readonly url: string; | ||
| 299 | readonly metaKey: boolean; | ||
| 300 | readonly ctrlKey: boolean; | ||
| 301 | } | ||
| 302 | | undefined { | ||
| 294 | if (typeof data !== "object" || data === null) return undefined; | 303 | if (typeof data !== "object" || data === null) return undefined; |
| 295 | const { jsonrpc, id, method, params } = data as Record<string, unknown>; | 304 | const { jsonrpc, id, method, params } = data as Record<string, unknown>; |
| 296 | if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined; | 305 | if (jsonrpc !== "2.0" || method !== OPEN_LINK_METHOD) return undefined; |
| 297 | if (typeof id !== "string" && typeof id !== "number") return undefined; | 306 | if (typeof id !== "string" && typeof id !== "number") return undefined; |
| 298 | const url = | 307 | if (typeof params !== "object" || params === null) return undefined; |
| 299 | typeof params === "object" && params !== null ? (params as { url?: unknown }).url : undefined; | 308 | const { url, metaKey, ctrlKey } = params as Record<string, unknown>; |
| 300 | return typeof url === "string" && /^https?:\/\//i.test(url) ? { id, url } : undefined; | 309 | return typeof url === "string" && /^https?:\/\//i.test(url) |
| 310 | ? { id, url, metaKey: metaKey === true, ctrlKey: ctrlKey === true } | ||
| 311 | : undefined; | ||
| 301 | } | 312 | } |
| 302 | 313 | ||
| 303 | /** The empty result a client sends back for a render's request. */ | 314 | /** The empty result a client sends back for a render's request. */ |
| ... | @@ -345,7 +356,7 @@ function rootRule(theme: HtmlRenderTheme): string { | ... | @@ -345,7 +356,7 @@ function rootRule(theme: HtmlRenderTheme): string { |
| 345 | // opens it as a new window, which the client sends to the browser. A framed | 356 | // opens it as a new window, which the client sends to the browser. A framed |
| 346 | // page also reports its content height, measured as the server measures it, | 357 | // page also reports its content height, measured as the server measures it, |
| 347 | // so its client can fit the frame to the page. | 358 | // so its client can fit the frame to the page. |
| 348 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);}})();`; | 359 | const BOOTSTRAP_SCRIPT = `(function(){var s=document.getElementById("t3-theme"),n=0;if(!s)return;var b=${JSON.stringify(BASE_CSS)};function a(t){if(!t||typeof t!=="object"||!t.variables||typeof t.variables!=="object")return;var c=":root{color-scheme:"+(t.appearance==="light"?"light":"dark")+";";for(var k in t.variables){if(/^--[a-z0-9-]+$/.test(k))c+=k+":"+String(t.variables[k]).replace(/[;{}<>]/g,"")+";";}s.textContent=c+"}"+b;}try{var m=/[#&]${THEME_FRAGMENT_KEY}=([^&]*)/.exec(location.hash);if(m){a(JSON.parse(decodeURIComponent(m[1])));history.replaceState(history.state,"",location.pathname+location.search);}}catch(e){}window.addEventListener("message",function(e){var d=e.data,p=d&&d.params;if(d&&d.jsonrpc==="2.0"&&d.method===${JSON.stringify(HOST_CONTEXT_CHANGED_METHOD)}&&p&&p.styles)a({appearance:p.theme,variables:p.styles.variables});});document.addEventListener("click",function(e){var l=e.isTrusted?e.composedPath().find(function(t){return t&&t.matches&&t.matches("a[href]");}):null,u;if(!l)return;try{u=new URL(l.getAttribute("href"),document.baseURI);}catch(x){return;}if(!/^https?:$/.test(u.protocol)||u.href.split("#")[0]===location.href.split("#")[0])return;if(window.parent!==window){e.preventDefault();window.parent.postMessage({jsonrpc:"2.0",id:"t3-link-"+(++n),method:${JSON.stringify(OPEN_LINK_METHOD)},params:{url:u.href,metaKey:e.metaKey,ctrlKey:e.ctrlKey}},"*");}else{l.setAttribute("target","_blank");l.setAttribute("rel","noopener");}},true);if(window.parent!==window){var h,o,z=function(){var r=document.documentElement,v=Math.ceil(r.scrollHeight>r.clientHeight?r.scrollHeight:r.getBoundingClientRect().height);if(v===h)return;h=v;window.parent.postMessage({jsonrpc:"2.0",method:${JSON.stringify(SIZE_CHANGED_METHOD)},params:{height:v}},"*");};if(window.ResizeObserver){o=new ResizeObserver(z);o.observe(document.documentElement);}document.addEventListener("DOMContentLoaded",function(){if(o&&document.body)o.observe(document.body);z();});window.addEventListener("load",z);}})();`; |
| 349 | 360 | ||
| 350 | function bootstrapMarkup(markup: string): string { | 361 | function bootstrapMarkup(markup: string): string { |
| 351 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); | 362 | const dark = htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark"); |