|
| 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