From 5df22831f5e7c768974b1bbf6f063d321f6181e3 Mon Sep 17 00:00:00 2001 From: Justin Sanders Date: Sun, 29 Mar 2026 20:14:34 -0500 Subject: [PATCH] fix: prevent iOS composer focus zoom --- .../src/components/ComposerPromptEditor.tsx | 20 ++- .../src/hooks/usePreventIosInputZoom.test.ts | 54 ++++++++ apps/web/src/hooks/usePreventIosInputZoom.ts | 126 ++++++++++++++++++ 3 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/hooks/usePreventIosInputZoom.test.ts create mode 100644 apps/web/src/hooks/usePreventIosInputZoom.ts diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..9eb2d0a5d7 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -59,6 +59,8 @@ import { isCollapsedCursorAdjacentToInlineToken, } from "~/composer-logic"; import { splitPromptIntoComposerSegments } from "~/composer-editor-mentions"; +import { useMediaQuery } from "~/hooks/useMediaQuery"; +import { usePreventIosInputZoom } from "~/hooks/usePreventIosInputZoom"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER, type TerminalContextDraft, @@ -893,6 +895,13 @@ function ComposerPromptEditorInner({ }: ComposerPromptEditorInnerProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); + const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); + const composerFontSizePx = isCoarsePointer ? 16 : 14; + const { + onBlur: handleEditorBlur, + onFocus: handleEditorFocus, + onTouchStartCapture: handleEditorTouchStartCapture, + } = usePreventIosInputZoom(); const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); @@ -1093,18 +1102,25 @@ function ComposerPromptEditorInner({ contentEditable={ } + style={{ fontSize: composerFontSizePx }} + onBlur={handleEditorBlur} + onFocus={handleEditorFocus} onPaste={onPaste} + onTouchStartCapture={handleEditorTouchStartCapture} /> } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/hooks/usePreventIosInputZoom.test.ts b/apps/web/src/hooks/usePreventIosInputZoom.test.ts new file mode 100644 index 0000000000..dbcbadc706 --- /dev/null +++ b/apps/web/src/hooks/usePreventIosInputZoom.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; + +import { + buildInputZoomLockedViewportContent, + isIosInputZoomPlatform, +} from "./usePreventIosInputZoom"; + +describe("isIosInputZoomPlatform", () => { + it("detects iPhone user agents", () => { + expect( + isIosInputZoomPlatform({ + maxTouchPoints: 5, + platform: "iPhone", + userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3 like Mac OS X) AppleWebKit/605.1.15", + }), + ).toBe(true); + }); + + it("detects iPadOS devices that report as MacIntel", () => { + expect( + isIosInputZoomPlatform({ + maxTouchPoints: 5, + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + }), + ).toBe(true); + }); + + it("ignores desktop macOS", () => { + expect( + isIosInputZoomPlatform({ + maxTouchPoints: 0, + platform: "MacIntel", + userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15", + }), + ).toBe(false); + }); +}); + +describe("buildInputZoomLockedViewportContent", () => { + it("adds the focus zoom lock directives", () => { + expect(buildInputZoomLockedViewportContent("width=device-width, initial-scale=1.0")).toBe( + "width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no", + ); + }); + + it("replaces existing maximum scale directives", () => { + expect( + buildInputZoomLockedViewportContent( + "width=device-width, initial-scale=1.0, maximum-scale=5, user-scalable=yes", + ), + ).toBe("width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no"); + }); +}); diff --git a/apps/web/src/hooks/usePreventIosInputZoom.ts b/apps/web/src/hooks/usePreventIosInputZoom.ts new file mode 100644 index 0000000000..a84a16fe45 --- /dev/null +++ b/apps/web/src/hooks/usePreventIosInputZoom.ts @@ -0,0 +1,126 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; + +const DEFAULT_VIEWPORT_CONTENT = "width=device-width, initial-scale=1.0"; +const IOS_USER_AGENT_PATTERN = /\b(iPad|iPhone|iPod)\b/i; +const VIEWPORT_META_SELECTOR = 'meta[name="viewport"]'; + +export function isIosInputZoomPlatform(input: { + maxTouchPoints: number; + platform: string; + userAgent: string; +}): boolean { + return ( + IOS_USER_AGENT_PATTERN.test(input.userAgent) || + (input.platform === "MacIntel" && input.maxTouchPoints > 1) + ); +} + +export function buildInputZoomLockedViewportContent(content: string): string { + const baseContent = content.trim() || DEFAULT_VIEWPORT_CONTENT; + const segments = baseContent + .split(",") + .map((segment) => segment.trim()) + .filter((segment) => segment.length > 0); + const filteredSegments = segments.filter((segment) => { + const [key] = segment.split("=", 1); + const normalizedKey = key?.trim().toLowerCase(); + return normalizedKey !== "maximum-scale" && normalizedKey !== "user-scalable"; + }); + + filteredSegments.push("maximum-scale=1", "user-scalable=no"); + return filteredSegments.join(", "); +} + +export function usePreventIosInputZoom(): { + onBlur: () => void; + onFocus: () => void; + onTouchStartCapture: () => void; +} { + const isEnabled = useMemo(() => { + if (typeof navigator === "undefined") { + return false; + } + + return isIosInputZoomPlatform({ + maxTouchPoints: navigator.maxTouchPoints, + platform: navigator.platform, + userAgent: navigator.userAgent, + }); + }, []); + const focusedRef = useRef(false); + const lockStateRef = useRef<{ + isLocked: boolean; + originalContent: string | null; + viewportMeta: HTMLMetaElement | null; + }>({ + isLocked: false, + originalContent: null, + viewportMeta: null, + }); + + const restoreViewport = useCallback(() => { + const lockState = lockStateRef.current; + if (!lockState.isLocked) { + return; + } + + if (lockState.viewportMeta && lockState.originalContent != null) { + lockState.viewportMeta.setAttribute("content", lockState.originalContent); + } + + lockState.isLocked = false; + lockState.originalContent = null; + lockState.viewportMeta = null; + }, []); + + const lockViewport = useCallback(() => { + if (!isEnabled || typeof document === "undefined") { + return; + } + + const lockState = lockStateRef.current; + if (lockState.isLocked) { + return; + } + + const viewportMeta = document.querySelector(VIEWPORT_META_SELECTOR); + if (!viewportMeta) { + return; + } + + lockState.isLocked = true; + lockState.originalContent = viewportMeta.getAttribute("content") ?? DEFAULT_VIEWPORT_CONTENT; + lockState.viewportMeta = viewportMeta; + viewportMeta.setAttribute( + "content", + buildInputZoomLockedViewportContent(lockState.originalContent), + ); + }, [isEnabled]); + + const handleTouchStartCapture = useCallback(() => { + lockViewport(); + requestAnimationFrame(() => { + if (!focusedRef.current) { + restoreViewport(); + } + }); + }, [lockViewport, restoreViewport]); + + const handleFocus = useCallback(() => { + focusedRef.current = true; + lockViewport(); + }, [lockViewport]); + + const handleBlur = useCallback(() => { + focusedRef.current = false; + restoreViewport(); + }, [restoreViewport]); + + useEffect(() => restoreViewport, [restoreViewport]); + + return { + onBlur: handleBlur, + onFocus: handleFocus, + onTouchStartCapture: handleTouchStartCapture, + }; +}