gh-14703: Allow top section of sites be draggable (gh-14816)

This commit is contained in:
mr. m
2026-08-02 20:28:06 +02:00
committed by GitHub
parent 9714c15c7a
commit bc540320f1
14 changed files with 697 additions and 0 deletions

View File

@@ -46,6 +46,12 @@
- name: zen.view.context-menu.refresh
value: "@IS_TWILIGHT@"
- name: zen.view.drag-window-from-content
value: true
- name: zen.view.drag-window-from-content.height-percentage
value: 30
- name: zen.view.borderless-fullscreen
value: true

View File

@@ -57,6 +57,22 @@ let JSWINDOWACTORS = {
remoteTypes: ["web", "file"],
enablePreference: "zen.glance.enabled",
},
ZenWindowDrag: {
parent: {
esModuleURI: "resource:///actors/ZenWindowDragParent.sys.mjs",
},
child: {
esModuleURI: "resource:///actors/ZenWindowDragChild.sys.mjs",
events: {
mousedown: {
mozSystemGroup: true,
},
},
},
messageManagerGroups: ["browsers"],
remoteTypes: ["web", "file"],
enablePreference: "zen.view.drag-window-from-content",
},
};
if (!Services.appinfo.inSafeMode) {

View File

@@ -15,6 +15,11 @@ SOURCES += [
"nsZenDragAndDrop.cpp",
]
if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
SOURCES += ["nsZenNativeWindowMove.mm"]
elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk":
CXXFLAGS += CONFIG["MOZ_GTK3_CFLAGS"]
XPCOM_MANIFESTS += [
"components.conf",
]

View File

@@ -4,6 +4,8 @@
#include "nsISupports.idl"
interface mozIDOMWindowProxy;
/**
* @brief Interface for Zen's drag and drop functionality.
*/
@@ -21,4 +23,14 @@ interface nsIZenDragAndDrop : nsISupports {
* @brief Indicate that a drag operation has ended.
*/
void onDragEnd();
/**
* @brief Start a native, OS-driven interactive move of the window,
* as if the user had grabbed its titlebar. Must be called while the
* primary mouse button is held down. The OS takes over the drag,
* providing native behaviors like macOS window tiling or Windows
* snap layouts, and ends it when the button is released.
* @param window The chrome window to move.
*/
void beginNativeWindowMove(in mozIDOMWindowProxy window);
};

View File

@@ -5,7 +5,58 @@
#include "nsZenDragAndDrop.h"
#include "nsBaseDragService.h"
#include "mozilla/WidgetUtils.h"
#include "nsIWidget.h"
#include "nsPIDOMWindow.h"
#if defined(XP_WIN)
# include <windows.h>
#elif defined(MOZ_WIDGET_GTK)
# include <gdk/gdk.h>
#endif
namespace zen {
#if defined(XP_MACOSX)
nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget);
#endif
/**
* @brief Start a native, OS-driven move of the window backing aWidget.
* @see nsIZenDragAndDrop::beginNativeWindowMove for more details.
*/
static nsresult StartNativeWindowMove(nsIWidget* aWidget) {
#if defined(XP_MACOSX)
return StartNativeWindowMoveCocoa(aWidget);
#elif defined(XP_WIN)
HWND hwnd = static_cast<HWND>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(hwnd, NS_ERROR_FAILURE);
// Hand the drag over to the OS as if the titlebar was grabbed; this
// enters the native move loop.
::ReleaseCapture();
::PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0);
return NS_OK;
#elif defined(MOZ_WIDGET_GTK)
auto* gdkWindow =
static_cast<GdkWindow*>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(gdkWindow, NS_ERROR_FAILURE);
GdkWindow* toplevel = gdk_window_get_toplevel(gdkWindow);
GdkSeat* seat =
gdk_display_get_default_seat(gdk_window_get_display(toplevel));
GdkDevice* pointer = seat ? gdk_seat_get_pointer(seat) : nullptr;
NS_ENSURE_TRUE(pointer, NS_ERROR_FAILURE);
// A native move works on both X11 and Wayland, unlike moving the
// window programmatically.
gint rootX = 0;
gint rootY = 0;
gdk_device_get_position(pointer, nullptr, &rootX, &rootY);
gdk_window_begin_move_drag_for_device(toplevel, pointer, 1, rootX, rootY,
GDK_CURRENT_TIME);
return NS_OK;
#else
return NS_ERROR_NOT_IMPLEMENTED;
#endif
}
namespace {
static constexpr auto kZenDefaultDragImageOpacity =
@@ -44,4 +95,15 @@ nsZenDragAndDrop::OnDragEnd() {
return NS_OK;
}
NS_IMETHODIMP
nsZenDragAndDrop::BeginNativeWindowMove(mozIDOMWindowProxy* aWindow) {
NS_ENSURE_ARG_POINTER(aWindow);
nsCOMPtr<nsPIDOMWindowOuter> outer = nsPIDOMWindowOuter::From(aWindow);
NS_ENSURE_TRUE(outer, NS_ERROR_INVALID_ARG);
RefPtr<nsIWidget> widget =
mozilla::widget::WidgetUtils::DOMWindowToWidget(outer);
NS_ENSURE_TRUE(widget, NS_ERROR_FAILURE);
return StartNativeWindowMove(widget);
}
} // namespace zen

View File

@@ -0,0 +1,44 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
#import <Cocoa/Cocoa.h>
#include "nsDebug.h"
#include "nsIWidget.h"
#include "nsObjCExceptions.h"
namespace zen {
// Cocoa side of StartNativeWindowMove()
nsresult StartNativeWindowMoveCocoa(nsIWidget* aWidget) {
NS_OBJC_BEGIN_TRY_BLOCK_RETURN;
NSWindow* window =
static_cast<NSWindow*>(aWidget->GetNativeData(NS_NATIVE_WINDOW));
NS_ENSURE_TRUE(window, NS_ERROR_FAILURE);
// performWindowDragWithEvent: needs a left-button event to start the
// WindowServer drag session. The triggering mousedown happened in a
// content process, so synthesize an equivalent event at the current
// mouse location.
NSPoint location = [window convertPointFromScreen:[NSEvent mouseLocation]];
NSEvent* event =
[NSEvent mouseEventWithType:NSEventTypeLeftMouseDragged
location:location
modifierFlags:0
timestamp:NSProcessInfo.processInfo.systemUptime
windowNumber:window.windowNumber
context:nil
eventNumber:0
clickCount:1
pressure:1.0];
NS_ENSURE_TRUE(event, NS_ERROR_FAILURE);
[window performWindowDragWithEvent:event];
return NS_OK;
NS_OBJC_END_TRY_BLOCK_RETURN(NS_ERROR_FAILURE);
}
} // namespace zen

View File

@@ -22,4 +22,5 @@ DIRS += [
"spaces",
"space-routing",
"sync",
"window-drag",
]

View File

@@ -20,6 +20,7 @@ BROWSER_CHROME_MANIFESTS += [
"ub-actions/browser.toml",
"urlbar/browser.toml",
"welcome/browser.toml",
"window_drag/browser.toml",
"window_sync/browser.toml",
]

View File

@@ -0,0 +1,10 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
[DEFAULT]
support-files = [
"head.js",
]
["browser_window_drag_basic.js"]

View File

@@ -0,0 +1,109 @@
/* Any copyright is dedicated to the Public Domain.
https://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
let gDragStartCount = 0;
add_setup(async function () {
const observer = () => gDragStartCount++;
Services.obs.addObserver(observer, WINDOW_DRAG_TOPIC);
registerCleanupFunction(() => {
Services.obs.removeObserver(observer, WINDOW_DRAG_TOPIC);
});
});
add_task(async function test_drag_from_empty_top_area() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount === 1,
"Dragging an empty area in the top region should start a window drag"
);
is(gDragStartCount, 1, "Exactly one window drag started");
});
});
add_task(async function test_no_drag_on_interactive_target() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
// Start the gesture on top of the link. Messages from the same actor
// pair are ordered, so the control drag afterwards would arrive later.
await BrowserTestUtils.synthesizeMouse(
"#link",
5,
5,
{ type: "mousedown" },
browser
);
await BrowserTestUtils.synthesizeMouse(
"#link",
35,
15,
{ type: "mousemove", buttons: 1 },
browser
);
await BrowserTestUtils.synthesizeMouse(
"#link",
45,
20,
{ type: "mouseup" },
browser
);
// Control drag from an eligible area.
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 2,
"The control drag should start a window drag"
);
is(
gDragStartCount,
2,
"Dragging a link must not start a window drag (only the control did)"
);
});
});
add_task(async function test_no_drag_below_top_region() {
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
const innerHeight = await getContentInnerHeight(browser);
await synthesizeContentDrag(browser, 50, Math.floor(innerHeight * 0.6));
// Control drag from inside the top region.
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 3,
"The control drag should start a window drag"
);
is(
gDragStartCount,
3,
"Dragging below the top region must not start a window drag"
);
});
});
add_task(async function test_pref_disables_window_drag() {
await SpecialPowers.pushPrefEnv({
set: [["zen.view.drag-window-from-content", false]],
});
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
});
await SpecialPowers.popPrefEnv();
// Control drag with the pref back on, in a fresh tab so the actor is
// created again.
await BrowserTestUtils.withNewTab(WINDOW_DRAG_TEST_PAGE, async browser => {
await synthesizeContentDrag(browser, 50, 50);
await TestUtils.waitForCondition(
() => gDragStartCount >= 4,
"The control drag should start a window drag"
);
is(
gDragStartCount,
4,
"Dragging with the pref disabled must not start a window drag"
);
});
});

View File

@@ -0,0 +1,57 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
const WINDOW_DRAG_TOPIC = "zen-window-drag-started";
const WINDOW_DRAG_TEST_PAGE = `https://example.com/document-builder.sjs?html=${encodeURIComponent(`
<!doctype html>
<style>
body { margin: 0; }
#link {
position: fixed;
top: 20px;
left: 300px;
width: 100px;
height: 30px;
display: block;
}
</style>
<a id="link" href="https://example.com/">a link</a>
`)}`;
/**
* Synthesizes a primary-button drag gesture inside the content area,
* starting at (x, y) and moving well past the drag threshold.
*/
async function synthesizeContentDrag(browser, x, y) {
await BrowserTestUtils.synthesizeMouse(
null,
x,
y,
{ type: "mousedown" },
browser
);
for (let i = 1; i <= 3; i++) {
await BrowserTestUtils.synthesizeMouse(
null,
x + i * 10,
y + i * 5,
{ type: "mousemove", buttons: 1 },
browser
);
}
await BrowserTestUtils.synthesizeMouse(
null,
x + 40,
y + 20,
{ type: "mouseup" },
browser
);
}
function getContentInnerHeight(browser) {
return SpecialPowers.spawn(browser, [], () => content.innerHeight);
}

View File

@@ -0,0 +1,333 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
"dragRegionHeightPercentage",
"zen.view.drag-window-from-content.height-percentage",
30
);
// A small threshold to allow for minor mouse jitter during a normal click.
// Anything beyond this is considered an intentional window drag.
const DRAG_START_THRESHOLD_PX = 4;
const kInteractiveTags = new Set([
"a",
"area",
"audio",
"button",
"canvas",
"details",
"dialog",
"embed",
"frame",
"iframe",
"img",
"input",
"label",
"menu",
"object",
"optgroup",
"option",
"select",
"summary",
"textarea",
"video",
]);
const kInteractiveRoles = new Set([
"button",
"checkbox",
"combobox",
"grid",
"link",
"listbox",
"menu",
"menubar",
"menuitem",
"option",
"radio",
"scrollbar",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"textbox",
"toolbar",
"tree",
"treegrid",
]);
// Cursors that signal the page considers the area interactive or draggable.
const kInteractiveCursors = new Set([
"pointer",
"grab",
"grabbing",
"move",
"all-scroll",
"text",
"vertical-text",
"cell",
"crosshair",
"col-resize",
"row-resize",
"n-resize",
"e-resize",
"s-resize",
"w-resize",
"ne-resize",
"nw-resize",
"se-resize",
"sw-resize",
"ew-resize",
"ns-resize",
"nesw-resize",
"nwse-resize",
]);
const kGestureListenerOptions = { mozSystemGroup: true, capture: true };
const kGestureEvents = ["mousemove", "mouseup", "dragstart", "unload"];
export class ZenWindowDragChild extends JSWindowActorChild {
#tracking = false;
#dragging = false;
#startScreenX = 0;
#startScreenY = 0;
handleEvent(event) {
// Never let pages spoof the gesture with synthetic events.
if (!event.isTrusted) {
return;
}
switch (event.type) {
case "mousedown":
this.#onMouseDown(event);
break;
case "mousemove":
this.#onMouseMove(event);
break;
case "mouseup":
this.#onMouseUp(event);
break;
case "dragstart":
// The gesture turned out to be a real content drag. Let it win.
this.#reset();
break;
case "unload":
this.#reset();
break;
}
}
get #dragRegionHeight() {
const percentage =
Math.max(0, Math.min(100, lazy.dragRegionHeightPercentage)) / 100;
return this.contentWindow.innerHeight * percentage;
}
#screenPoint(event) {
const dpr = this.contentWindow.devicePixelRatio;
return {
screenX: Math.round(event.screenX * dpr),
screenY: Math.round(event.screenY * dpr),
};
}
#onMouseDown(event) {
if (this.#tracking || this.#dragging) {
// The native OS move swallows the mouseup, so a stale gesture may
// still be tracked. Recover and evaluate this mousedown normally.
this.#reset();
}
if (
event.button !== 0 ||
event.buttons !== 1 ||
event.detail !== 1 ||
event.defaultPrevented ||
event.ctrlKey ||
event.altKey ||
event.shiftKey ||
event.metaKey
) {
return;
}
const doc = this.document;
if (doc.fullscreenElement || doc.pointerLockElement) {
return;
}
// Only handle events from the top document itself; anything inside an
// (i)frame belongs to the page.
if (event.composedTarget?.ownerDocument !== doc) {
return;
}
if (event.clientY > this.#dragRegionHeight) {
return;
}
let overContent = true;
try {
overContent = this.#isEventOverDraggableContent(event);
} catch (e) {
console.error("ZenWindowDrag: eligibility check failed", e);
}
if (overContent) {
return;
}
const { screenX, screenY } = this.#screenPoint(event);
this.#startScreenX = screenX;
this.#startScreenY = screenY;
this.#tracking = true;
this.#addGestureListeners();
}
#onMouseMove(event) {
if (!this.#tracking) {
return;
}
// We missed the mouseup (e.g. the OS consumed it during the native
// move, or it happened outside the window).
if (!(event.buttons & 1)) {
this.#reset();
return;
}
if (this.#dragging) {
// Keep the page from extending a text selection under the gesture.
event.preventDefault();
return;
}
const point = this.#screenPoint(event);
const threshold =
DRAG_START_THRESHOLD_PX * this.contentWindow.devicePixelRatio;
if (
Math.hypot(
point.screenX - this.#startScreenX,
point.screenY - this.#startScreenY
) < threshold
) {
return;
}
this.#dragging = true;
// The mousedown may have moved the caret or started a stray selection.
this.contentWindow.getSelection()?.removeAllRanges();
// The OS takes over the drag from here.
this.sendAsyncMessage("ZenWindowDrag:StartDrag");
event.preventDefault();
}
#onMouseUp(event) {
if (!this.#tracking || event.button !== 0) {
return;
}
if (this.#dragging) {
event.preventDefault();
event.preventClickEvent();
}
this.#reset();
}
#reset() {
this.#tracking = false;
this.#dragging = false;
this.#removeGestureListeners();
}
#addGestureListeners() {
const win = this.contentWindow;
for (const type of kGestureEvents) {
win.addEventListener(type, this, kGestureListenerOptions);
}
}
#removeGestureListeners() {
const win = this.contentWindow;
if (!win) {
return;
}
for (const type of kGestureEvents) {
win.removeEventListener(type, this, kGestureListenerOptions);
}
}
/**
* Returns true if starting a window drag here would fight with content
* that the page wants to be clickable, draggable or selectable.
*
* @param {MouseEvent} event
*/
#isEventOverDraggableContent(event) {
// Scrollbars and other native anonymous parts only dispatch to the
// system group, which is us. Never treat those as a window drag.
if (event.originalTarget?.isNativeAnonymous) {
return true;
}
let target = event.composedTarget;
if (target?.nodeType === Node.TEXT_NODE) {
target = target.parentElement;
}
if (!target || target.nodeType !== Node.ELEMENT_NODE) {
return true;
}
for (let node = target; node; node = node.flattenedTreeParentNode) {
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
if (this.#isInteractiveElement(node)) {
return true;
}
}
return this.#hasInteractiveCursor(target);
}
#isInteractiveElement(element) {
if (kInteractiveTags.has(element.localName)) {
return true;
}
// Covers [draggable="true"] and elements draggable by default,
// like links and images.
if (element.draggable) {
return true;
}
if (element.isContentEditable) {
return true;
}
if (
element.tabIndex >= 0 &&
element !== this.document.body &&
element !== this.document.documentElement
) {
return true;
}
const role = element.getAttribute?.("role");
if (role && kInteractiveRoles.has(role.toLowerCase())) {
return true;
}
// Inline event handlers are a strong hint of a custom widget.
if (
element.onclick ||
element.onmousedown ||
element.onpointerdown ||
element.ondragstart
) {
return true;
}
return false;
}
#hasInteractiveCursor(element) {
const style = element.ownerGlobal?.getComputedStyle(element);
if (!style) {
return false;
}
// The keyword is always the last component of the computed value.
// Don't split on "," as url() cursor images may contain commas.
const cursor = style.cursor.match(/[a-z-]+$/)?.[0];
return kInteractiveCursors.has(cursor);
}
}

View File

@@ -0,0 +1,33 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
const lazy = {};
XPCOMUtils.defineLazyServiceGetter(
lazy,
"zenDragAndDropService",
"@mozilla.org/zen/drag-and-drop;1",
Ci.nsIZenDragAndDrop
);
export class ZenWindowDragParent extends JSWindowActorParent {
receiveMessage(message) {
if (message.name !== "ZenWindowDrag:StartDrag") {
return;
}
const win = this.browsingContext.topChromeWindow;
if (!win || win.closed || win.windowState === win.STATE_FULLSCREEN) {
return;
}
if (Cu.isInAutomation) {
// Tests can't exercise a real OS drag session; let them observe the
// decision instead.
Services.obs.notifyObservers(win, "zen-window-drag-started");
return;
}
lazy.zenDragAndDropService.beginNativeWindowMove(win);
}
}

View File

@@ -0,0 +1,8 @@
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.
FINAL_TARGET_FILES.actors += [
"actors/ZenWindowDragChild.sys.mjs",
"actors/ZenWindowDragParent.sys.mjs",
]