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(); + } + }); +});