Skip to content

Commit 99576ed

Browse files
committed
feat: move zoom controls from widgetRenderer to MCP widget only
Remove zoom from WidgetRenderer (regular widgets don't need it). Add McpWidgetZoom component that uses MutationObserver to detect MCPAppsActivityRenderer containers and inject zoom/expand controls specifically for Excalidraw and other MCP widget output.
1 parent c585bde commit 99576ed

3 files changed

Lines changed: 176 additions & 73 deletions

File tree

apps/app/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
44
import { ExampleLayout } from "@/components/example-layout";
55
import { useGenerativeUIExamples, useExampleSuggestions } from "@/hooks";
66
import { ExplainerCardsPortal } from "@/components/explainer-cards";
7+
import { McpWidgetZoom } from "@/components/mcp-widget-zoom";
78

89
import { CopilotChat } from "@copilotkit/react-core/v2";
910

@@ -88,6 +89,7 @@ export default function HomePage() {
8889
) : null
8990
} />
9091
<ExplainerCardsPortal />
92+
<McpWidgetZoom />
9193
</div>
9294
</div>
9395
</>

apps/app/src/components/generative-ui/widget-renderer.tsx

Lines changed: 18 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,6 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
443443
const [height, setHeight] = useState(0);
444444
const [loaded, setLoaded] = useState(false);
445445
const [isFullscreen, setIsFullscreen] = useState(false);
446-
const [zoom, setZoom] = useState(1);
447446
// Track what html has been committed to the iframe to avoid redundant reloads
448447
const committedHtmlRef = useRef("");
449448
const isFirstRenderRef = useRef(true);
@@ -511,22 +510,6 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
511510
const showLoading = !!html && !ready;
512511
const loadingPhrase = useLoadingPhrase(showLoading);
513512

514-
const toolbarBtnStyle: React.CSSProperties = {
515-
background: "transparent",
516-
border: "none",
517-
cursor: "pointer",
518-
padding: "2px 0",
519-
color: "var(--text-secondary, #6b7280)",
520-
fontSize: 13,
521-
fontFamily: "inherit",
522-
width: 24,
523-
height: 24,
524-
display: "inline-flex",
525-
alignItems: "center",
526-
justifyContent: "center",
527-
borderRadius: 4,
528-
};
529-
530513
return (
531514
<>
532515
{/* Fullscreen backdrop */}
@@ -564,42 +547,12 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
564547
display: "flex",
565548
justifyContent: "flex-end",
566549
alignItems: "center",
567-
gap: 4,
568550
padding: isFullscreen ? "8px 16px" : "0 0 4px 0",
569551
...(isFullscreen ? { borderBottom: "1px solid var(--border-secondary, #e5e7eb)" } : {}),
570552
}}>
571-
{ready && (
572-
<>
573-
<button
574-
onClick={() => setZoom(z => Math.max(0.25, z - 0.25))}
575-
style={toolbarBtnStyle}
576-
title="Zoom out"
577-
>
578-
579-
</button>
580-
<span style={{ fontSize: 12, color: "var(--text-secondary, #6b7280)", minWidth: 36, textAlign: "center", fontFamily: "inherit" }}>
581-
{Math.round(zoom * 100)}%
582-
</span>
583-
<button
584-
onClick={() => setZoom(z => Math.min(3, z + 0.25))}
585-
style={toolbarBtnStyle}
586-
title="Zoom in"
587-
>
588-
+
589-
</button>
590-
<button
591-
onClick={() => setZoom(1)}
592-
style={{ ...toolbarBtnStyle, fontSize: 11, width: "auto", padding: "2px 8px" }}
593-
title="Reset zoom"
594-
>
595-
Fit
596-
</button>
597-
<div style={{ width: 1, height: 16, background: "var(--border-secondary, #e5e7eb)", margin: "0 4px" }} />
598-
</>
599-
)}
600553
<button
601554
onClick={() => setIsFullscreen(v => !v)}
602-
style={{ ...toolbarBtnStyle, width: "auto", padding: "2px 8px" }}
555+
style={{ background: "transparent", border: "none", cursor: "pointer", padding: "4px 8px", color: "var(--text-secondary, #6b7280)", fontSize: 13, fontFamily: "inherit" }}
603556
>
604557
{isFullscreen ? "✕ Close" : "⤢ Expand"}
605558
</button>
@@ -643,31 +596,23 @@ export function WidgetRenderer({ title, description, html }: WidgetRendererProps
643596
</div>
644597
</div>
645598
)}
646-
{/* Zoom container — clips overflow, iframe scales inside */}
647-
<div style={{
648-
overflow: "hidden",
649-
height: isFullscreen ? undefined : (ready ? height * zoom : 0),
650-
flex: isFullscreen ? 1 : undefined,
651-
display: html ? undefined : "none",
652-
}}>
653-
<iframe
654-
ref={iframeRef}
655-
sandbox="allow-scripts allow-same-origin"
656-
className="w-full border-0"
657-
onLoad={() => setLoaded(true)}
658-
style={{
659-
height: isFullscreen ? `${100 / zoom}%` : (ready ? height : 0),
660-
width: `${100 / zoom}%`,
661-
transform: `scale(${zoom})`,
662-
transformOrigin: "top left",
663-
overflow: "hidden",
664-
background: "transparent",
665-
opacity: ready ? 1 : 0,
666-
transition: "opacity 300ms ease-in, transform 150ms ease-out",
667-
}}
668-
title={title}
669-
/>
670-
</div>
599+
{/* Single iframe — always mounted so ref is stable */}
600+
<iframe
601+
ref={iframeRef}
602+
sandbox="allow-scripts allow-same-origin"
603+
className="w-full border-0"
604+
onLoad={() => setLoaded(true)}
605+
style={{
606+
height: isFullscreen ? undefined : (ready ? height : 0),
607+
flex: isFullscreen ? 1 : undefined,
608+
overflow: "hidden",
609+
background: "transparent",
610+
opacity: ready ? 1 : 0,
611+
transition: "opacity 300ms ease-in",
612+
display: html ? undefined : "none",
613+
}}
614+
title={title}
615+
/>
671616
</div>
672617
</>
673618

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"use client";
2+
3+
import { useEffect, useRef } from "react";
4+
5+
/**
6+
* Observes the DOM for MCP Apps widget containers (rendered by CopilotKit's
7+
* MCPAppsActivityRenderer) and injects zoom / expand controls around them.
8+
*
9+
* Identification heuristic: a <div> with inline style `min-height: 100px`
10+
* that contains an <iframe> created by the renderer.
11+
*/
12+
export function McpWidgetZoom() {
13+
const processed = useRef(new WeakSet<Element>());
14+
15+
useEffect(() => {
16+
function injectControls(container: HTMLElement) {
17+
if (processed.current.has(container)) return;
18+
processed.current.add(container);
19+
20+
let zoom = 1;
21+
const iframe = container.querySelector("iframe") as HTMLIFrameElement | null;
22+
if (!iframe) return;
23+
24+
// Wrap iframe in a zoom-clip div
25+
const clipDiv = document.createElement("div");
26+
clipDiv.style.overflow = "hidden";
27+
clipDiv.style.position = "relative";
28+
clipDiv.style.width = "100%";
29+
clipDiv.style.flex = "1";
30+
iframe.parentNode?.insertBefore(clipDiv, iframe);
31+
clipDiv.appendChild(iframe);
32+
33+
// Toolbar
34+
const toolbar = document.createElement("div");
35+
toolbar.style.cssText =
36+
"display:flex;justify-content:flex-end;align-items:center;gap:4px;padding:4px 8px;";
37+
38+
const btnStyle =
39+
"background:transparent;border:none;cursor:pointer;padding:2px 6px;" +
40+
"color:#6b7280;font-size:13px;font-family:inherit;border-radius:4px;";
41+
42+
const zoomOut = document.createElement("button");
43+
zoomOut.textContent = "−";
44+
zoomOut.title = "Zoom out";
45+
zoomOut.style.cssText = btnStyle;
46+
47+
const zoomLabel = document.createElement("span");
48+
zoomLabel.textContent = "100%";
49+
zoomLabel.style.cssText = "font-size:12px;color:#6b7280;min-width:36px;text-align:center;";
50+
51+
const zoomIn = document.createElement("button");
52+
zoomIn.textContent = "+";
53+
zoomIn.title = "Zoom in";
54+
zoomIn.style.cssText = btnStyle;
55+
56+
const fitBtn = document.createElement("button");
57+
fitBtn.textContent = "Fit";
58+
fitBtn.title = "Reset zoom";
59+
fitBtn.style.cssText = btnStyle + "font-size:11px;";
60+
61+
const sep = document.createElement("div");
62+
sep.style.cssText = "width:1px;height:16px;background:#e5e7eb;margin:0 4px;";
63+
64+
const expandBtn = document.createElement("button");
65+
expandBtn.textContent = "⤢ Expand";
66+
expandBtn.style.cssText = btnStyle;
67+
68+
toolbar.append(zoomOut, zoomLabel, zoomIn, fitBtn, sep, expandBtn);
69+
container.insertBefore(toolbar, container.firstChild);
70+
71+
function applyZoom() {
72+
iframe.style.transform = `scale(${zoom})`;
73+
iframe.style.transformOrigin = "top left";
74+
iframe.style.width = `${100 / zoom}%`;
75+
iframe.style.height = `${100 / zoom}%`;
76+
clipDiv.style.height = container.style.height || "auto";
77+
zoomLabel.textContent = `${Math.round(zoom * 100)}%`;
78+
}
79+
80+
zoomOut.addEventListener("click", () => {
81+
zoom = Math.max(0.25, zoom - 0.25);
82+
applyZoom();
83+
});
84+
zoomIn.addEventListener("click", () => {
85+
zoom = Math.min(3, zoom + 0.25);
86+
applyZoom();
87+
});
88+
fitBtn.addEventListener("click", () => {
89+
zoom = 1;
90+
applyZoom();
91+
});
92+
93+
// Fullscreen modal
94+
let isFullscreen = false;
95+
expandBtn.addEventListener("click", () => {
96+
isFullscreen = !isFullscreen;
97+
if (isFullscreen) {
98+
container.style.cssText =
99+
"position:fixed;top:10%;left:12.5%;width:75%;height:80%;z-index:9999;" +
100+
"background:var(--background,#fff);border-radius:16px;" +
101+
"box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);display:flex;flex-direction:column;overflow:hidden;";
102+
clipDiv.style.flex = "1";
103+
clipDiv.style.height = "auto";
104+
expandBtn.textContent = "✕ Close";
105+
// Backdrop
106+
const backdrop = document.createElement("div");
107+
backdrop.id = "mcp-zoom-backdrop";
108+
backdrop.style.cssText =
109+
"position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,0.5);";
110+
backdrop.addEventListener("click", () => expandBtn.click());
111+
document.body.appendChild(backdrop);
112+
} else {
113+
container.style.cssText =
114+
"width:100%;min-height:100px;overflow:hidden;position:relative;";
115+
clipDiv.style.flex = "";
116+
clipDiv.style.height = container.dataset.originalHeight || "auto";
117+
expandBtn.textContent = "⤢ Expand";
118+
document.getElementById("mcp-zoom-backdrop")?.remove();
119+
}
120+
});
121+
122+
// Escape key
123+
const handleEsc = (e: KeyboardEvent) => {
124+
if (e.key === "Escape" && isFullscreen) expandBtn.click();
125+
};
126+
window.addEventListener("keydown", handleEsc);
127+
}
128+
129+
function scanForMcpWidgets() {
130+
// MCPAppsActivityRenderer containers have min-height: 100px and overflow: hidden
131+
document.querySelectorAll<HTMLElement>("div[style]").forEach((div) => {
132+
const style = div.style;
133+
if (
134+
style.minHeight === "100px" &&
135+
style.overflow === "hidden" &&
136+
style.position === "relative" &&
137+
div.querySelector("iframe") &&
138+
!processed.current.has(div)
139+
) {
140+
// Save original height for restore
141+
div.dataset.originalHeight = style.height;
142+
injectControls(div);
143+
}
144+
});
145+
}
146+
147+
// Observe for new MCP widgets appearing
148+
const observer = new MutationObserver(() => scanForMcpWidgets());
149+
observer.observe(document.body, { childList: true, subtree: true });
150+
scanForMcpWidgets();
151+
152+
return () => observer.disconnect();
153+
}, []);
154+
155+
return null;
156+
}

0 commit comments

Comments
 (0)