From 4ab322dd3b0a08ba2128b28c1cd75112fa7a418d Mon Sep 17 00:00:00 2001 From: "mr. m" Date: Sat, 1 Aug 2026 20:10:42 +0200 Subject: [PATCH] closes #14703: Allow top section of sites be draggable --- prefs/zen/view.yaml | 6 + src/zen/common/sys/ZenActorsManager.sys.mjs | 16 + src/zen/drag-and-drop/moz.build | 5 + src/zen/drag-and-drop/nsIZenDragAndDrop.idl | 12 + src/zen/drag-and-drop/nsZenDragAndDrop.cpp | 62 ++++ .../drag-and-drop/nsZenNativeWindowMove.mm | 44 +++ src/zen/moz.build | 1 + .../actors/ZenWindowDragChild.sys.mjs | 327 ++++++++++++++++++ .../actors/ZenWindowDragParent.sys.mjs | 27 ++ src/zen/window-drag/moz.build | 8 + 10 files changed, 508 insertions(+) create mode 100644 src/zen/drag-and-drop/nsZenNativeWindowMove.mm create mode 100644 src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs create mode 100644 src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs create mode 100644 src/zen/window-drag/moz.build diff --git a/prefs/zen/view.yaml b/prefs/zen/view.yaml index 69066e600..e555e631b 100644 --- a/prefs/zen/view.yaml +++ b/prefs/zen/view.yaml @@ -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 diff --git a/src/zen/common/sys/ZenActorsManager.sys.mjs b/src/zen/common/sys/ZenActorsManager.sys.mjs index 759167674..59cac7eba 100644 --- a/src/zen/common/sys/ZenActorsManager.sys.mjs +++ b/src/zen/common/sys/ZenActorsManager.sys.mjs @@ -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) { diff --git a/src/zen/drag-and-drop/moz.build b/src/zen/drag-and-drop/moz.build index 9b7aa4564..19fa1df8c 100644 --- a/src/zen/drag-and-drop/moz.build +++ b/src/zen/drag-and-drop/moz.build @@ -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", ] diff --git a/src/zen/drag-and-drop/nsIZenDragAndDrop.idl b/src/zen/drag-and-drop/nsIZenDragAndDrop.idl index af92f943b..bc379458a 100644 --- a/src/zen/drag-and-drop/nsIZenDragAndDrop.idl +++ b/src/zen/drag-and-drop/nsIZenDragAndDrop.idl @@ -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); }; diff --git a/src/zen/drag-and-drop/nsZenDragAndDrop.cpp b/src/zen/drag-and-drop/nsZenDragAndDrop.cpp index ec2dcc957..1a50d62c9 100644 --- a/src/zen/drag-and-drop/nsZenDragAndDrop.cpp +++ b/src/zen/drag-and-drop/nsZenDragAndDrop.cpp @@ -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 +#elif defined(MOZ_WIDGET_GTK) +# include +#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(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(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 outer = nsPIDOMWindowOuter::From(aWindow); + NS_ENSURE_TRUE(outer, NS_ERROR_INVALID_ARG); + RefPtr widget = + mozilla::widget::WidgetUtils::DOMWindowToWidget(outer); + NS_ENSURE_TRUE(widget, NS_ERROR_FAILURE); + return StartNativeWindowMove(widget); +} + } // namespace zen diff --git a/src/zen/drag-and-drop/nsZenNativeWindowMove.mm b/src/zen/drag-and-drop/nsZenNativeWindowMove.mm new file mode 100644 index 000000000..0ad97583f --- /dev/null +++ b/src/zen/drag-and-drop/nsZenNativeWindowMove.mm @@ -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 + +#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(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 diff --git a/src/zen/moz.build b/src/zen/moz.build index 4e3e80ecb..08ec2aca5 100644 --- a/src/zen/moz.build +++ b/src/zen/moz.build @@ -21,4 +21,5 @@ DIRS += [ "spaces", "space-routing", "sync", + "window-drag", ] diff --git a/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs new file mode 100644 index 000000000..95aeecf4b --- /dev/null +++ b/src/zen/window-drag/actors/ZenWindowDragChild.sys.mjs @@ -0,0 +1,327 @@ +// 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) { + 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; + } + const cursor = style.cursor.split(",").pop().trim(); + return kInteractiveCursors.has(cursor); + } +} diff --git a/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs b/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs new file mode 100644 index 000000000..fa6607a0a --- /dev/null +++ b/src/zen/window-drag/actors/ZenWindowDragParent.sys.mjs @@ -0,0 +1,27 @@ +// 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; + } + lazy.zenDragAndDropService.beginNativeWindowMove(win); + } +} diff --git a/src/zen/window-drag/moz.build b/src/zen/window-drag/moz.build new file mode 100644 index 000000000..2b3da954b --- /dev/null +++ b/src/zen/window-drag/moz.build @@ -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", +]