From 40e7e1068dbd9233415687117e6febcf9cbfc3f6 Mon Sep 17 00:00:00 2001 From: Mel Ludowise Date: Mon, 16 Mar 2026 22:59:05 -0700 Subject: [PATCH 1/3] Fix touch gesture conflicts in WebGL example apps on mobile --- examples/map-server/mcp-app.html | 1 + examples/map-server/src/mcp-app.ts | 11 +++++++++++ examples/shadertoy-server/src/mcp-app.css | 3 +++ examples/threejs-server/src/threejs-app.tsx | 16 +++++++++++++++- examples/wiki-explorer-server/src/mcp-app.css | 1 + examples/wiki-explorer-server/src/mcp-app.ts | 14 ++++++++++++++ 6 files changed, 45 insertions(+), 1 deletion(-) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 473eb50f6..c7919bc88 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -19,6 +19,7 @@ #cesiumContainer { width: 100%; height: 100%; + touch-action: none; } #fullscreen-btn { position: absolute; diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 1475d81e7..f70cdb5fe 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -397,6 +397,17 @@ async function initCesium(): Promise { // CesiumJS sets image-rendering: pixelated by default which looks bad on scaled displays // Setting to "auto" allows the browser to apply smooth interpolation cesiumViewer.canvas.style.imageRendering = "auto"; + // Prevent touch events from propagating to the parent scroll view. + // CesiumJS uses pointer events internally, which don't suppress native + // scroll gesture recognition on touch devices. Explicit non-passive touch + // listeners with preventDefault() are needed. + for (const eventName of ["touchstart", "touchmove"] as const) { + cesiumViewer.canvas.addEventListener( + eventName, + (e: TouchEvent) => e.preventDefault(), + { passive: false }, + ); + } // Note: DO NOT set resolutionScale = devicePixelRatio here! // When useBrowserRecommendedResolution: false, Cesium already uses devicePixelRatio. // Setting resolutionScale = devicePixelRatio would double the scaling (e.g., 2x2=4x on Retina) diff --git a/examples/shadertoy-server/src/mcp-app.css b/examples/shadertoy-server/src/mcp-app.css index 0449ea5ea..33fb308c3 100644 --- a/examples/shadertoy-server/src/mcp-app.css +++ b/examples/shadertoy-server/src/mcp-app.css @@ -19,12 +19,15 @@ html, body { border-radius: 0; } + #canvas { width: 100%; height: 100%; display: block; + touch-action: none; } + #canvas.hidden { display: none; } diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx index 235931641..bf122166c 100644 --- a/examples/threejs-server/src/threejs-app.tsx +++ b/examples/threejs-server/src/threejs-app.tsx @@ -85,6 +85,7 @@ function LoadingShimmer({ height, code }: { height: number; code?: string }) { display: "flex", flexDirection: "column", overflow: "hidden", + touchAction: "none", background: "linear-gradient(135deg, var(--color-background-secondary, light-dark(#f0f0f5, #2a2a3c)) 0%, var(--color-background-tertiary, light-dark(#e5e5ed, #1e1e2e)) 100%)", }} @@ -275,6 +276,14 @@ export default function ThreeJSApp({ useEffect(() => { if (!code || !canvasRef.current || !containerRef.current) return; + // Prevent touch events from propagating to the parent scroll view. + // Three.js OrbitControls uses pointer events, which don't suppress native + // scroll gesture recognition on touch devices. + const canvas = canvasRef.current; + const preventDefault = (e: TouchEvent) => e.preventDefault(); + canvas.addEventListener("touchstart", preventDefault, { passive: false }); + canvas.addEventListener("touchmove", preventDefault, { passive: false }); + // Cleanup previous animation animControllerRef.current?.cleanup(); animControllerRef.current = createAnimationController(); @@ -289,7 +298,11 @@ export default function ThreeJSApp({ animControllerRef.current.visibilityAwareRAF, ).catch((e) => setError(e instanceof Error ? e.message : "Unknown error")); - return () => animControllerRef.current?.cleanup(); + return () => { + canvas.removeEventListener("touchstart", preventDefault); + canvas.removeEventListener("touchmove", preventDefault); + animControllerRef.current?.cleanup(); + }; }, [code, height]); if (isStreaming || !code) { @@ -314,6 +327,7 @@ export default function ThreeJSApp({ height, borderRadius: "var(--border-radius-lg, 8px)", display: "block", + touchAction: "none", }} /> {error &&
Error: {error}
} diff --git a/examples/wiki-explorer-server/src/mcp-app.css b/examples/wiki-explorer-server/src/mcp-app.css index 8f6f0246e..ea13ac2ac 100644 --- a/examples/wiki-explorer-server/src/mcp-app.css +++ b/examples/wiki-explorer-server/src/mcp-app.css @@ -1,6 +1,7 @@ #graph { width: 100%; height: 100vh; + touch-action: none; } #popup { diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index 7d95cb8d6..d8382ba6e 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -111,6 +111,20 @@ const graph = new ForceGraph(container) }) .graphData(graphData); +// Prevent touch events from propagating to the parent scroll view. +// force-graph uses pointer events, which don't suppress native scroll gesture +// recognition on touch devices. +const graphCanvas = container.querySelector("canvas"); +if (graphCanvas) { + for (const eventName of ["touchstart", "touchmove"] as const) { + graphCanvas.addEventListener( + eventName, + (e) => e.preventDefault(), + { passive: false }, + ); + } +} + // Handle window resize function handleResize() { const { width, height } = container.getBoundingClientRect(); From 37a1c3e80ed41f5b69cd8fc40c089a208c3329a7 Mon Sep 17 00:00:00 2001 From: Mel Ludowise Date: Mon, 16 Mar 2026 23:05:34 -0700 Subject: [PATCH 2/3] Format with prettier --- examples/map-server/mcp-app.html | 132 ++++++++++--------- examples/shadertoy-server/src/mcp-app.css | 9 +- examples/wiki-explorer-server/src/mcp-app.ts | 8 +- 3 files changed, 79 insertions(+), 70 deletions(-) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index c7919bc88..acb9f384f 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -1,73 +1,83 @@ - + - - - + + + CesiumJS Globe - - + +
Loading globe...
- + diff --git a/examples/shadertoy-server/src/mcp-app.css b/examples/shadertoy-server/src/mcp-app.css index 33fb308c3..0d5b6f114 100644 --- a/examples/shadertoy-server/src/mcp-app.css +++ b/examples/shadertoy-server/src/mcp-app.css @@ -1,4 +1,5 @@ -html, body { +html, +body { margin: 0; width: 100%; height: 100%; @@ -19,7 +20,6 @@ html, body { border-radius: 0; } - #canvas { width: 100%; height: 100%; @@ -27,7 +27,6 @@ html, body { touch-action: none; } - #canvas.hidden { display: none; } @@ -71,7 +70,9 @@ html, body { display: none; /* Hidden by default, shown when fullscreen available */ align-items: center; justify-content: center; - transition: background 0.2s, opacity 0.2s; + transition: + background 0.2s, + opacity 0.2s; opacity: 0; /* Initially invisible, shown on View hover */ z-index: 100; } diff --git a/examples/wiki-explorer-server/src/mcp-app.ts b/examples/wiki-explorer-server/src/mcp-app.ts index d8382ba6e..69bec05d4 100644 --- a/examples/wiki-explorer-server/src/mcp-app.ts +++ b/examples/wiki-explorer-server/src/mcp-app.ts @@ -117,11 +117,9 @@ const graph = new ForceGraph(container) const graphCanvas = container.querySelector("canvas"); if (graphCanvas) { for (const eventName of ["touchstart", "touchmove"] as const) { - graphCanvas.addEventListener( - eventName, - (e) => e.preventDefault(), - { passive: false }, - ); + graphCanvas.addEventListener(eventName, (e) => e.preventDefault(), { + passive: false, + }); } } From e2a7b2d19fdec0a795cfa84f94c6cf102d103a60 Mon Sep 17 00:00:00 2001 From: Mel Ludowise Date: Tue, 17 Mar 2026 17:45:17 -0700 Subject: [PATCH 3/3] fix(examples): improve mobile support for map, pdf, and threejs examples - map-server: fullscreen button respects safe area insets, hover/opacity pattern with always-visible on touch devices - pdf-server: fit-to-width scale for narrow viewports, responsive toolbar with compact controls on mobile, safe area in fullscreen toolbar via CSS custom properties - threejs-server: fullscreen button respects safe area and always visible on touch, ResizeObserver for responsive width, host containerDimensions for fullscreen height, no container padding in fullscreen, replace useHostStyles with inline style application to avoid overwriting onhostcontextchanged handler --- examples/map-server/mcp-app.html | 137 ++++++++++-------- examples/map-server/src/mcp-app.ts | 14 +- examples/pdf-server/src/mcp-app.css | 48 +++++- examples/pdf-server/src/mcp-app.ts | 70 ++++++++- examples/threejs-server/src/global.css | 10 ++ .../threejs-server/src/mcp-app-wrapper.tsx | 31 +++- examples/threejs-server/src/threejs-app.tsx | 34 ++++- 7 files changed, 259 insertions(+), 85 deletions(-) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 473eb50f6..ba4e6920f 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -1,72 +1,89 @@ - + - - - + + + CesiumJS Globe - - + +
Loading globe...
- + diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 1475d81e7..60a0811c8 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -623,6 +623,16 @@ function updateFullscreenButton(): void { // Show button only if fullscreen is available btn.style.display = canFullscreen ? "flex" : "none"; + // Position button respecting safe area insets + const insets = context?.safeAreaInsets; + btn.style.top = `${10 + (insets?.top ?? 0)}px`; + btn.style.right = `${10 + (insets?.right ?? 0)}px`; + + // Always show button on touch devices (hover doesn't work on mobile) + if (context?.deviceCapabilities?.touch) { + btn.style.opacity = canFullscreen ? "0.7" : "0"; + } + // Toggle icons based on current mode const isFullscreen = currentDisplayMode === "fullscreen"; expandIcon.style.display = isFullscreen ? "none" : "block"; @@ -718,8 +728,8 @@ app.onhostcontextchanged = (params) => { ); } - // Update button if available modes changed - if (params.availableDisplayModes) { + // Update button if available modes or safe area changed + if (params.availableDisplayModes || params.safeAreaInsets) { updateFullscreenButton(); } }; diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index 008b22a61..77e3757eb 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -103,13 +103,14 @@ body { display: flex; align-items: center; justify-content: space-between; - padding: 0.5rem 1rem; + padding: 0.5rem 0.5rem; background: var(--bg000); border-bottom: 1px solid var(--bg200); flex-shrink: 0; - gap: 0.5rem; - height: 48px; + gap: 0.25rem 0.5rem; + min-height: 48px; box-sizing: border-box; + flex-wrap: wrap; } .toolbar-left { @@ -261,7 +262,6 @@ body { --min-font-size-inv: calc(1 / var(--min-font-size, 1)); } - .text-layer :is(span, br) { color: transparent; position: absolute; @@ -306,7 +306,13 @@ body { overflow: hidden; /* No scrolling on main - only canvas-container scrolls */ border-radius: 0; border: none; - padding: 0 !important; /* Ignore safe area insets in fullscreen */ + padding: 0 !important; /* Background extends edge-to-edge */ +} + +.main.fullscreen .toolbar { + padding-top: calc(0.5rem + var(--safe-top, 0px)); + padding-left: calc(0.5rem + var(--safe-left, 0px)); + padding-right: calc(0.5rem + var(--safe-right, 0px)); } .main.fullscreen .viewer { @@ -468,3 +474,35 @@ body { .loading-indicator.error .loading-indicator-arc { stroke: #e74c3c; } + +/* Compact toolbar on narrow screens */ +@media (max-width: 480px) { + .toolbar-left { + display: none; + } + .toolbar { + justify-content: center; + } + .nav-btn, + .zoom-btn, + .search-btn, + .fullscreen-btn { + width: 28px; + height: 28px; + font-size: 0.85rem; + } + .page-input { + width: 40px; + padding: 0.2rem 0.3rem; + font-size: 0.8rem; + } + .total-pages, + .zoom-level { + font-size: 0.75rem; + min-width: 36px; + } + .toolbar-center, + .toolbar-right { + gap: 0.2rem; + } +} diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 4278d9285..8b06a36c8 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -109,6 +109,36 @@ const loadingIndicatorArc = loadingIndicatorEl.querySelector( // Track current display mode let currentDisplayMode: "inline" | "fullscreen" = "inline"; +// Whether the user has manually zoomed (disables auto fit-to-width) +let userHasZoomed = false; + +/** + * Compute a scale that fits the PDF page width to the available container width. + * Returns null if the container isn't visible or the page width is unavailable. + */ +async function computeFitToWidthScale(): Promise { + if (!pdfDocument) return null; + + try { + const page = await pdfDocument.getPage(currentPage); + const naturalViewport = page.getViewport({ scale: 1.0 }); + const pageWidth = naturalViewport.width; + + const container = canvasContainerEl as HTMLElement; + const containerStyle = getComputedStyle(container); + const paddingLeft = parseFloat(containerStyle.paddingLeft); + const paddingRight = parseFloat(containerStyle.paddingRight); + const availableWidth = container.clientWidth - paddingLeft - paddingRight; + + if (availableWidth <= 0 || pageWidth <= 0) return null; + if (availableWidth >= pageWidth) return null; // Already fits + + return availableWidth / pageWidth; + } catch { + return null; + } +} + /** * Request the host to resize the app to fit the current PDF page. * Only applies in inline mode - fullscreen mode uses scrolling. @@ -783,16 +813,19 @@ function nextPage() { } function zoomIn() { + userHasZoomed = true; scale = Math.min(scale + 0.25, 3.0); renderPage(); } function zoomOut() { + userHasZoomed = true; scale = Math.max(scale - 0.25, 0.5); renderPage(); } function resetZoom() { + userHasZoomed = false; scale = 1.0; renderPage(); } @@ -1281,6 +1314,14 @@ app.ontoolresult = async (result: CallToolResult) => { loadingIndicatorEl.style.display = "none"; showViewer(); + + // Compute fit-to-width scale for narrow containers (e.g. mobile) + const fitScale = await computeFitToWidthScale(); + if (fitScale !== null) { + scale = fitScale; + log.info("Fit-to-width scale:", scale); + } + renderPage(); // Start background preloading of all pages for text extraction startPreloading(); @@ -1308,17 +1349,30 @@ function handleHostContextChanged(ctx: McpUiHostContext) { applyHostStyleVariables(ctx.styles.variables); } - // Apply safe area insets + // Apply safe area insets — set CSS custom properties for use in both + // inline mode (padding on .main) and fullscreen mode (padding on .toolbar) if (ctx.safeAreaInsets) { - mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; - mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; - mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; - mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; + const { top, right, bottom, left } = ctx.safeAreaInsets; + mainEl.style.setProperty("--safe-top", `${top}px`); + mainEl.style.setProperty("--safe-right", `${right}px`); + mainEl.style.setProperty("--safe-bottom", `${bottom}px`); + mainEl.style.setProperty("--safe-left", `${left}px`); + mainEl.style.paddingTop = `${top}px`; + mainEl.style.paddingRight = `${right}px`; + mainEl.style.paddingBottom = `${bottom}px`; + mainEl.style.paddingLeft = `${left}px`; } - // Log containerDimensions for debugging - if (ctx.containerDimensions) { - log.info("Container dimensions:", ctx.containerDimensions); + // Recompute fit-to-width when container dimensions change + if (ctx.containerDimensions && pdfDocument && !userHasZoomed) { + log.info("Container dimensions changed:", ctx.containerDimensions); + computeFitToWidthScale().then((fitScale) => { + if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) { + scale = fitScale; + log.info("Recomputed fit-to-width scale:", scale); + renderPage(); + } + }); } // Handle display mode changes diff --git a/examples/threejs-server/src/global.css b/examples/threejs-server/src/global.css index c969ccc41..1542cecac 100644 --- a/examples/threejs-server/src/global.css +++ b/examples/threejs-server/src/global.css @@ -92,6 +92,16 @@ body { opacity: 0.7; } +/* Always show button on touch devices */ +.threejs-container.touch-device .fullscreen-btn.available { + opacity: 0.7; +} + +/* In fullscreen, canvas goes edge-to-edge; only the button respects safe area */ +.threejs-container.fullscreen { + padding: 0 !important; +} + /* Fullscreen mode: swap icons and remove border radius */ .threejs-container.fullscreen .fullscreen-btn .expand-icon { display: none; diff --git a/examples/threejs-server/src/mcp-app-wrapper.tsx b/examples/threejs-server/src/mcp-app-wrapper.tsx index f9dd263e5..9ef24d263 100644 --- a/examples/threejs-server/src/mcp-app-wrapper.tsx +++ b/examples/threejs-server/src/mcp-app-wrapper.tsx @@ -5,7 +5,7 @@ * props to the actual view component. */ import type { App, McpUiHostContext } from "@modelcontextprotocol/ext-apps"; -import { useApp, useHostStyles } from "@modelcontextprotocol/ext-apps/react"; +import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { StrictMode, useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; @@ -66,14 +66,39 @@ function McpAppWrapper() { setToolResult(params as CallToolResult); }; // Host context changes (theme, dimensions, etc.) + // Note: we handle styles here instead of using useHostStyles, + // because useHostStyles overwrites onhostcontextchanged. app.onhostcontextchanged = (params) => { + const root = document.documentElement; + if (params.theme) { + root.setAttribute("data-theme", params.theme); + root.style.colorScheme = params.theme; + } + if (params.styles?.variables) { + for (const [k, v] of Object.entries(params.styles.variables)) { + if (v !== undefined) root.style.setProperty(k, v as string); + } + } setHostContext((prev) => ({ ...prev, ...params })); }; }, }); - // Apply host styling (theme, CSS variables, fonts) - useHostStyles(app); + // Apply initial host styles + useEffect(() => { + if (!app) return; + const ctx = app.getHostContext(); + const root = document.documentElement; + if (ctx?.theme) { + root.setAttribute("data-theme", ctx.theme); + root.style.colorScheme = ctx.theme; + } + if (ctx?.styles?.variables) { + for (const [k, v] of Object.entries(ctx.styles.variables)) { + if (v !== undefined) root.style.setProperty(k, v as string); + } + } + }, [app]); // Get initial host context after connection useEffect(() => { diff --git a/examples/threejs-server/src/threejs-app.tsx b/examples/threejs-server/src/threejs-app.tsx index 235931641..68a2a2c51 100644 --- a/examples/threejs-server/src/threejs-app.tsx +++ b/examples/threejs-server/src/threejs-app.tsx @@ -226,6 +226,8 @@ export default function ThreeJSApp({ const canFullscreen = hostContext?.availableDisplayModes?.includes("fullscreen") ?? false; const isFullscreen = currentDisplayMode === "fullscreen"; + const dims = hostContext?.containerDimensions; + const hostHeight = dims && "height" in dims ? dims.height : 0; // Sync display mode from host context useEffect(() => { @@ -272,6 +274,19 @@ export default function ThreeJSApp({ return () => observer.disconnect(); }, []); + // Track container width for resize handling + const [containerWidth, setContainerWidth] = useState(0); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + const w = Math.round(entries[0].contentRect.width); + if (w > 0) setContainerWidth(w); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + useEffect(() => { if (!code || !canvasRef.current || !containerRef.current) return; @@ -280,17 +295,18 @@ export default function ThreeJSApp({ animControllerRef.current = createAnimationController(); setError(null); - const width = containerRef.current.offsetWidth || 800; + const w = containerWidth || containerRef.current.offsetWidth || 800; + const h = isFullscreen && hostHeight > 0 ? hostHeight : height; executeThreeCode( code, canvasRef.current, - width, - height, + w, + h, animControllerRef.current.visibilityAwareRAF, ).catch((e) => setError(e instanceof Error ? e.message : "Unknown error")); return () => animControllerRef.current?.cleanup(); - }, [code, height]); + }, [code, height, containerWidth, isFullscreen, hostHeight]); if (isStreaming || !code) { return ( @@ -303,7 +319,7 @@ export default function ThreeJSApp({ return (
0 ? hostHeight : height, + borderRadius: isFullscreen ? 0 : "var(--border-radius-lg, 8px)", display: "block", }} /> @@ -321,6 +337,10 @@ export default function ThreeJSApp({ className={`fullscreen-btn${canFullscreen ? " available" : ""}`} title={isFullscreen ? "Exit fullscreen" : "Toggle fullscreen"} onClick={toggleFullscreen} + style={{ + top: 10 + (safeAreaInsets?.top ?? 0), + right: 10 + (safeAreaInsets?.right ?? 0), + }} >