diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 473eb50f6..b19eed4e5 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -1,72 +1,90 @@ - + - - - + + + CesiumJS Globe - - + +
Loading globe...
- + diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 1475d81e7..4fda13284 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) @@ -623,6 +634,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 +739,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/shadertoy-server/src/mcp-app.css b/examples/shadertoy-server/src/mcp-app.css index 0449ea5ea..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%; @@ -23,6 +24,7 @@ html, body { width: 100%; height: 100%; display: block; + touch-action: none; } #canvas.hidden { @@ -68,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/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..1de585f9e 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%)", }} @@ -226,6 +227,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,25 +275,51 @@ 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; + // 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(); 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]); + return () => { + canvas.removeEventListener("touchstart", preventDefault); + canvas.removeEventListener("touchmove", preventDefault); + animControllerRef.current?.cleanup(); + }; + }, [code, height, containerWidth, isFullscreen, hostHeight]); if (isStreaming || !code) { return ( @@ -303,7 +332,7 @@ export default function ThreeJSApp({ return (
0 ? hostHeight : height, + borderRadius: isFullscreen ? 0 : "var(--border-radius-lg, 8px)", display: "block", + touchAction: "none", }} /> {error &&
Error: {error}
} @@ -321,6 +351,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), + }} > (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();