From 9e39a7f5a187f9b54c35b3c4c0b1fb0965fffeb3 Mon Sep 17 00:00:00 2001 From: Vansh Date: Wed, 13 May 2026 05:01:17 +0530 Subject: [PATCH] fix(client): block Safari page-level pinch-zoom (#3901) iOS Safari has ignored the `user-scalable=no` viewport hint since iOS 10, so two-finger pinch still zooms the whole page and can softlock the in-game HUD. Intercept WebKit's non-standard `gesturestart`, `gesturechange` and `gestureend` events at `document` and call `preventDefault()` so the page stays put. The game's own pinch-to-zoom on the map canvas is driven by pointer events (InputHandler) and is unaffected; browsers that do not fire GestureEvent treat the listeners as a no-op. Resolves #2330 If this PR fixes an issue, link it below. If not, delete these two lines. Resolves #(issue number) ## Description: Describe the PR. ## Please complete the following: - [ ] I have added screenshots for all UI updates - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME --- src/client/Main.ts | 5 ++ .../utilities/DisableSafariPinchZoom.ts | 40 +++++++++ tests/client/DisableSafariPinchZoom.test.ts | 83 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 src/client/utilities/DisableSafariPinchZoom.ts create mode 100644 tests/client/DisableSafariPinchZoom.test.ts diff --git a/src/client/Main.ts b/src/client/Main.ts index 8a4ce179f..2c53eaf83 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -65,6 +65,7 @@ import { isInIframe, translateText, } from "./Utils"; +import { installSafariPinchZoomBlocker } from "./utilities/DisableSafariPinchZoom"; import "./components/DesktopNavBar"; import "./components/Footer"; @@ -1008,6 +1009,10 @@ const hideCrazyGamesElements = () => { // Initialize the client when the DOM is loaded const bootstrap = () => { + // Prevent Safari's page-level pinch-zoom, which ignores `user-scalable=no` + // on iOS and can softlock the HUD. See issue #2330. + installSafariPinchZoomBlocker(); + initLayout(); new Client().initialize(); initNavigation(); diff --git a/src/client/utilities/DisableSafariPinchZoom.ts b/src/client/utilities/DisableSafariPinchZoom.ts new file mode 100644 index 000000000..ca3c96139 --- /dev/null +++ b/src/client/utilities/DisableSafariPinchZoom.ts @@ -0,0 +1,40 @@ +/** + * Blocks the page-level pinch-to-zoom gesture on Safari / WebKit. + * + * iOS Safari has ignored the `user-scalable=no` viewport hint since iOS 10, + * so setting it on the viewport meta tag is not enough to stop two-finger + * pinch zoom. The only reliable way to prevent the page from zooming is to + * listen for WebKit's non-standard `gesturestart`, `gesturechange` and + * `gestureend` events and call `preventDefault()` on them. + * + * The game's own pinch-to-zoom on the map canvas is driven by pointer + * events (see {@link ../InputHandler}), which are unaffected by blocking + * these WebKit-only events. Browsers that do not fire `GestureEvent` + * (Chrome, Firefox, every Android browser) treat the listeners as a no-op, + * so it is safe to install them unconditionally. + * + * @param target - The EventTarget to attach the listeners to. Defaults to + * `document`, which is the scope Safari uses to decide whether to zoom + * the page. + * @returns A function that removes the installed listeners. + * + * @see https://github.com/openfrontio/OpenFrontIO/issues/2330 + */ +export function installSafariPinchZoomBlocker( + target: EventTarget = document, +): () => void { + const block = (e: Event) => { + e.preventDefault(); + }; + + const events = ["gesturestart", "gesturechange", "gestureend"] as const; + for (const type of events) { + target.addEventListener(type, block); + } + + return () => { + for (const type of events) { + target.removeEventListener(type, block); + } + }; +} diff --git a/tests/client/DisableSafariPinchZoom.test.ts b/tests/client/DisableSafariPinchZoom.test.ts new file mode 100644 index 000000000..1f588703a --- /dev/null +++ b/tests/client/DisableSafariPinchZoom.test.ts @@ -0,0 +1,83 @@ +import { installSafariPinchZoomBlocker } from "../../src/client/utilities/DisableSafariPinchZoom"; + +const GESTURE_EVENTS = ["gesturestart", "gesturechange", "gestureend"] as const; + +function dispatchCancelableGestureEvent( + target: EventTarget, + type: string, +): Event { + // Safari's GestureEvent is not available in jsdom. Dispatch a plain + // cancelable Event of the same name so preventDefault() is observable via + // defaultPrevented. + const event = new Event(type, { bubbles: true, cancelable: true }); + target.dispatchEvent(event); + return event; +} + +describe("installSafariPinchZoomBlocker", () => { + it("prevents the default action of each Safari gesture event on document", () => { + const uninstall = installSafariPinchZoomBlocker(); + + try { + for (const type of GESTURE_EVENTS) { + const event = dispatchCancelableGestureEvent(document, type); + expect(event.defaultPrevented).toBe(true); + } + } finally { + uninstall(); + } + }); + + it("attaches listeners to the provided target", () => { + const target = document.createElement("div"); + const uninstall = installSafariPinchZoomBlocker(target); + + try { + for (const type of GESTURE_EVENTS) { + const event = dispatchCancelableGestureEvent(target, type); + expect(event.defaultPrevented).toBe(true); + } + } finally { + uninstall(); + } + }); + + it("removes the listeners when the returned disposer is called", () => { + const target = document.createElement("div"); + const uninstall = installSafariPinchZoomBlocker(target); + uninstall(); + + for (const type of GESTURE_EVENTS) { + const event = dispatchCancelableGestureEvent(target, type); + expect(event.defaultPrevented).toBe(false); + } + }); + + it("does not affect events on unrelated targets", () => { + const scope = document.createElement("div"); + const other = document.createElement("div"); + const uninstall = installSafariPinchZoomBlocker(scope); + + try { + const event = dispatchCancelableGestureEvent(other, "gesturestart"); + expect(event.defaultPrevented).toBe(false); + } finally { + uninstall(); + } + }); + + it("leaves unrelated event types alone", () => { + const uninstall = installSafariPinchZoomBlocker(); + + try { + const event = new Event("touchstart", { + bubbles: true, + cancelable: true, + }); + document.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } finally { + uninstall(); + } + }); +});