Repository: ruffle-rs/ruffle
Pull request #20367: web: Fix context menu positioning in quirks mode
Author: login: kjarosh
Labels: name: A-web
description: Area: Web & Extensions
name: T-fix
description: Type: Bug fix (in something that's supposed to work already)
Created: 2025-05-07T22:58:37Z | Merged: 2025-05-16T23:08:14Z

When in quirks mode and the body was smaller than the content, context menu was constrained to the bounds of the body. This patch makes the context menu work properly in both quirks and no quirks modes.

* Fixes https://github.com/ruffle-rs/ruffle/issues/19697


Diff:
diff --git a/web/packages/core/src/internal/player/inner.tsx b/web/packages/core/src/internal/player/inner.tsx
index f50dda93930b..f3075f92b8b0 100644
--- a/web/packages/core/src/internal/player/inner.tsx
+++ b/web/packages/core/src/internal/player/inner.tsx
@@ -1640,19 +1640,30 @@ export class InnerPlayer {
         const playerRect = this.element.getBoundingClientRect();
         const contextMenuRect = this.contextMenuElement.getBoundingClientRect();
 
+        // We need to get the viewport element in order to properly detect the
+        // viewport size. We cannot use window.innerWidth, because it doesn't
+        // take into account the scrollbar. Unfortunately, the viewport element
+        // is different in standards mode (documentElement) compared to quirks
+        // mode (body).
+        //
+        // In standards mode, scrollingElement always returns the
+        // documentElement, in quirks mode it sometimes returns body, sometimes
+        // null. As we don't care about scrollability, we can just assume quirks
+        // mode and get the body when it's null.
+        const viewportElement = document.scrollingElement || document.body;
+
         // Keep the entire context menu inside the viewport.
-        // TODO: Allow the context menu to escape the document body while being mindful of scrollbars.
         const overflowX = Math.max(
             0,
             event.clientX +
                 contextMenuRect.width -
-                document.documentElement.clientWidth,
+                viewportElement.clientWidth,
         );
         const overflowY = Math.max(
             0,
             event.clientY +
                 contextMenuRect.height -
-                document.documentElement.clientHeight,
+                viewportElement.clientHeight,
         );
         const x = event.clientX - playerRect.x - overflowX;
         const y = event.clientY - playerRect.y - overflowY;
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/Test.as b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/Test.as
new file mode 100644
index 000000000000..9d2448b2fdc6
--- /dev/null
+++ b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/Test.as
@@ -0,0 +1,15 @@
+﻿package  {
+    import flash.display.*;
+
+    [SWF(width="400", height="400")]
+    public class Test extends MovieClip {
+        public function Test() {
+            var rect = new Sprite();
+            rect.graphics.lineStyle(2, 0xFF0000);
+            rect.graphics.drawRect(0, 0, 400, 400);
+            rect.graphics.drawRect(199, 199, 2, 2);
+            addChild(rect);
+            trace("Loaded!");
+        }
+    }
+}
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_no_quirks.html b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_no_quirks.html
new file mode 100644
index 000000000000..c14c68be229c
--- /dev/null
+++ b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_no_quirks.html
@@ -0,0 +1,18 @@
+<!doctype html>
+<html>
+
+<head>
+    <title>context_menu_escaping_body</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+</head>
+
+<body>
+    <div style="position:absolute;top:0;left:0;height:100%;width:100%;">
+        <div id="flashContent">
+            <object type="application/x-shockwave-flash" data="test.swf" width="400" height="400" id="objectElement">
+            </object>
+        </div>
+    </div>
+</body>
+
+</html>
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_quirks.html b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_quirks.html
new file mode 100644
index 000000000000..d904c3f0199c
--- /dev/null
+++ b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/index_quirks.html
@@ -0,0 +1,17 @@
+<html>
+
+<head>
+    <title>context_menu_escaping_body</title>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+</head>
+
+<body>
+    <div style="position:absolute;top:0;left:0;height:100%;width:100%;">
+        <div id="flashContent">
+            <object type="application/x-shockwave-flash" data="test.swf" width="400" height="400" id="objectElement">
+            </object>
+        </div>
+    </div>
+</body>
+
+</html>
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.swf b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.swf
new file mode 100644
index 000000000000..0fdaa58fc0e6
Binary files /dev/null and b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.swf differ
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.ts b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.ts
new file mode 100644
index 000000000000..2fb4fc84f98a
--- /dev/null
+++ b/web/packages/selfhosted/test/integration_tests/context_menu_escaping_body/test.ts
@@ -0,0 +1,67 @@
+import { injectRuffleAndWait, openTest, playAndMonitor } from "../../utils.js";
+import { expect, use } from "chai";
+import chaiHtml from "chai-html";
+
+use(chaiHtml);
+
+describe("Context Menu", () => {
+    it("(quirks mode) load the test", async () => {
+        await browser.setWindowSize(1000, 1000);
+
+        await openTest(
+            browser,
+            "integration_tests/context_menu_escaping_body",
+            "index_quirks.html",
+        );
+        await injectRuffleAndWait(browser);
+        const player = await browser.$("<ruffle-object>");
+        await playAndMonitor(browser, player, "Loaded!\n");
+
+        // Dismiss hardware acceleration modal in Chrome
+        await player.click();
+        await player.click();
+    });
+
+    it("(quirks mode) open context menu", async () => {
+        const player = await browser.$("#objectElement");
+
+        await player.click({ x: -150, y: -150, button: "right" });
+
+        const menu = await player.$("#context-menu");
+        const menuLocation = await menu.getLocation();
+        expect(menuLocation.x).to.equal(50);
+        expect(menuLocation.y).to.equal(50);
+
+        // Dismiss the menu
+        await player.click({ x: -151, y: -151 });
+    });
+
+    it("(no quirks mode) load the test", async () => {
+        await openTest(
+            browser,
+            "integration_tests/context_menu_escaping_body",
+            "index_no_quirks.html",
+        );
+        await injectRuffleAndWait(browser);
+        const player = await browser.$("<ruffle-object>");
+        await playAndMonitor(browser, player, "Loaded!\n");
+
+        // Dismiss hardware acceleration modal in Chrome
+        await player.click();
+        await player.click();
+    });
+
+    it("(no quirks mode) open context menu", async () => {
+        const player = await browser.$("#objectElement");
+
+        await player.click({ x: -150, y: -150, button: "right" });
+
+        const menu = await player.$("#context-menu");
+        const menuLocation = await menu.getLocation();
+        expect(menuLocation.x).to.equal(50);
+        expect(menuLocation.y).to.equal(50);
+
+        // Dismiss the menu
+        await player.click({ x: -151, y: -151 });
+    });
+});
diff --git a/web/packages/selfhosted/test/integration_tests/context_menu_position/test.ts b/web/packages/selfhosted/test/integration_tests/context_menu_position/test.ts
index d6b71d7af07c..67ec90562809 100644
--- a/web/packages/selfhosted/test/integration_tests/context_menu_position/test.ts
+++ b/web/packages/selfhosted/test/integration_tests/context_menu_position/test.ts
@@ -36,7 +36,7 @@ describe("Context Menu", () => {
         await player.click();
 
         // Make the window large enough to accommodate a protruding context menu
-        await browser.setWindowSize(1000, 1000);
+        await browser.setWindowSize(1500, 1500);
     });
 
     it("open context menu in the middle LTR", async () => {


Reviews:
user: login: danielhjacobs
body: Just to note, if I understand correctly the issue is we want the number 2 behavior from https://www.w3.org/TR/cssom-view/#dom-element-clientwidth and https://www.w3.org/TR/cssom-view/#dom-element-clientheight regardless of quirks mode or not, but in non-quirks mode that behavior only applies for the `documentElement` and in quirks mode it only applies for the `body`, so we have to use https://www.w3.org/TR/cssom-view/#dom-window-innerwidth and https://www.w3.org/TR/cssom-view/#dom-window-innerheight, which always has the same behavior.

It's worth noting the behavior of `window.innerWidth` differs from the behavior of https://www.w3.org/TR/cssom-view/#dom-element-clientwidth in non-quirks mode if a scrollbar exists, as the former includes the size of the scrollbar and the latter does not. ~~Though I don't think it matters for us because the context menu doesn't have a scrollbar.~~
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T13:21:29Z
user: login: danielhjacobs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:55:12Z
user: login: danielhjacobs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T14:55:42Z
user: login: danielhjacobs
body: It may be better to reference this as the viewport element (viewportElement) instead of the root element (rootElement). MDN defines documentElement as the root element regardless of quirks mode: https://developer.mozilla.org/en-US/docs/Web/API/Document/documentElement. We may also wish to say in standards mode scrollingElement returns the documentElement (to avoid confusion from someone who reads the comment and thinks it returns document itself).
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-16T21:44:17Z

Review comments:
diff_hunk: @@ -1641,18 +1641,17 @@ export class InnerPlayer {
         const contextMenuRect = this.contextMenuElement.getBoundingClientRect();
 
         // Keep the entire context menu inside the viewport.
-        // TODO: Allow the context menu to escape the document body while being mindful of scrollbars.
         const overflowX = Math.max(
             0,
             event.clientX +
                 contextMenuRect.width -
-                document.documentElement.clientWidth,
+                window.innerWidth,
path: web/packages/core/src/internal/player/inner.tsx
user: login: danielhjacobs
body: Alternative:

`(document.scrollingElement || document.body).clientWidth`
created_at: 2025-05-12T14:55:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1641,18 +1641,17 @@ export class InnerPlayer {
         const contextMenuRect = this.contextMenuElement.getBoundingClientRect();
 
         // Keep the entire context menu inside the viewport.
-        // TODO: Allow the context menu to escape the document body while being mindful of scrollbars.
         const overflowX = Math.max(
             0,
             event.clientX +
                 contextMenuRect.width -
-                document.documentElement.clientWidth,
+                window.innerWidth,
         );
         const overflowY = Math.max(
             0,
             event.clientY +
                 contextMenuRect.height -
-                document.documentElement.clientHeight,
+                window.innerHeight,
path: web/packages/core/src/internal/player/inner.tsx
user: login: danielhjacobs
body: Alternative:

`(document.scrollingElement || document.body).clientHeight`
created_at: 2025-05-12T14:55:41Z
author_association: CONTRIBUTOR

PR comments:
user: login: danielhjacobs
created_at: 2025-05-12T13:46:33Z
body: Correction: as mentioned on Discord, the behavior differs when the document itself has a scrollbar, which may come up. Not sure how big a deal that may be though.
author_association: CONTRIBUTOR
user: login: danielhjacobs
created_at: 2025-05-12T18:20:50Z
body: So for the final note:

I'm fine with the current code, but it does treat scroll-able pages differently. If we want to treat them the same, the suggested code may be better. Hopefully it passes the new tests too.
author_association: CONTRIBUTOR
user: login: kjarosh
created_at: 2025-05-16T21:35:44Z
body: Fixed, could you take a second look @danielhjacobs ?
author_association: MEMBER
user: login: kjarosh
created_at: 2025-05-16T22:31:32Z
body: > It may be better to reference this as the viewport element (viewportElement) instead of the root element (rootElement). MDN defines documentElement as the root element regardless of quirks mode: https://developer.mozilla.org/en-US/docs/Web/API/Document/documentElement.

I guess it depends on where you look. This page uses the term "root element" in a different way: https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth

author_association: MEMBER
user: login: danielhjacobs
created_at: 2025-05-16T22:47:19Z
body: The grammar is odd with the extra parentheses, but I think that page means:

> When clientWidth is used on the root element (the \<html> element), or on \<body> if the document is in quirks mode, the viewport's width (excluding any scrollbar) is returned.

Distinguishing the situations to say in quirks mode the behavior applies for the body **instead** of the root element.
author_association: CONTRIBUTOR
user: login: kjarosh
created_at: 2025-05-16T22:59:13Z
body: Oh, that actually makes sense. I was confused :sweat_smile: 
author_association: MEMBER