From 1934c9dce66f3c7af8081b36d20568684188b7a8 Mon Sep 17 00:00:00 2001 From: Jeremy Maitin-Shepard Date: Tue, 11 Aug 2026 14:06:58 -0700 Subject: [PATCH 1/5] fix(drag_and_drop): Fix drag and drop issues under Wayland Under Wayland, neither Firefox nor Chrome report changes to modifier keys during drag events. Chrome reports modifiers for the initial `dragstart` event and no modifiers for the subsequent events (https://issues.chromium.org/issues/40138974). Firefox saves the modifiers that were pressed during the initial `dragstart` and continues to report the same modifiers for all subsequent events. Previously, Neuroglancer determined the drop effect based on the modifier keys reported to the `dragover` event, and under Linux Wayland on Chrome no modifiers are ever reported. With this change, the Firefox behavior under Wayland is essentially emulated on Chrome: the modifiers reported to `dragstart` are saved and apply throughout the drag event (even if the user later releases them). This commit also updates the drag status message to indicate the Wayland-specific limitations when Wayland is detected. Additionally, Chrome under Wayland sets `effectAllowed` to `copyMove` rather than `all` by default. Previously, in cases such as dropping a layer onto a drop zone in order to create a new layer group, Neuroglancer selected a default dropEffect of `link`, which led to the drop being rejected by Chrome. This commit fixes that problem by reporting a fake dropEffect, if necessary, to ensure it is one of the values allowed by `effectAllowed`. Regardless of the reported drop effect, the correct drop operation is still performed. --- src/util/drag_and_drop.ts | 125 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/src/util/drag_and_drop.ts b/src/util/drag_and_drop.ts index edf78c82cf..b2e93f19f6 100644 --- a/src/util/drag_and_drop.ts +++ b/src/util/drag_and_drop.ts @@ -81,6 +81,29 @@ export function decodeParametersFromDragTypeList( let savedDropEffect: DataTransfer["dropEffect"] | undefined; +// Chrome on Wayland seems to report an `effectAllowed` of `copyMove` regardless +// of what the dragstart handler sets. Convert the actual drop effect to an +// allowed value to avoid Chrome rejecting the drop. The actual drop effect is +// still stored separately by `setDropEffect` to ensure the correct drop action +// is performed. +function getAllowedDropEffect( + effectAllowed: string, + dropEffect: string, +): string { + if (effectAllowed == dropEffect) return dropEffect; + switch (effectAllowed) { + case "all": + return dropEffect; + case "copyMove": + return dropEffect == "copy" || dropEffect == "move" ? dropEffect : "copy"; + case "copyLink": + return dropEffect == "copy" || dropEffect == "link" ? dropEffect : "copy"; + case "linkMove": + return dropEffect == "link" || dropEffect == "move" ? dropEffect : "link"; + } + return effectAllowed; +} + /** * On Chrome 62, the dataTransfer.dropEffect property is reset to 'none' when the 'drop' event is * dispatched. As a workaround, we store it in a global variable. @@ -93,7 +116,10 @@ export function setDropEffect( event: DragEvent, dropEffect: T, ) { - event.dataTransfer!.dropEffect = dropEffect; + event.dataTransfer!.dropEffect = getAllowedDropEffect( + event.dataTransfer!.effectAllowed, + dropEffect, + ) as any; savedDropEffect = dropEffect; return dropEffect; } @@ -110,28 +136,112 @@ export function preventDrag(element: HTMLElement) { }); } +// False except on Wayland. +let mustRestartDragToChangeModifiers = false; + +// When `modifiersReportedDuringDrag == false`, this stores the initial +// modifiers reported to the `dragstart` handler. +let savedModifiers: + | { + shiftKey: boolean; + ctrlKey: boolean; + altKey: boolean; + metaKey: boolean; + } + | undefined = undefined; + +// Apply Linux Wayland-specific workarounds. +if ( + navigator.platform.startsWith("Linux ") && + !navigator.userAgent.includes("CrOs") && + !navigator.userAgent.includes("Android") && + // On Wayland, screenX and screenY are always reported as 0. However, this + // does not definitively rule out X11. + window.screenX === 0 && + window.screenY === 0 +) { + // On Linux under Wayland, Chrome does not report any modifier keys after the + // initial `dragstart` event, while Firefox saves the modifier keys that were + // held during `dragstart` and continues to report them on all drag-related + // events, even if the user releases the modifiers. + // + // We will emulate the and Firefox do not report modifier keys + // during drag operations due to Wayland limitations, only on the initial + // dragstart event. There is no way to directly detect Wayland vs X11 but we + // on Chrome can check if any modifiers have been observed in drag events other than + // `dragstart`. + mustRestartDragToChangeModifiers = true; + + if (navigator.userAgent.includes("Chrome")) { + // Under Chrome, effectively emulate the Firefox behavior by storing the + // modifiers that are reported to `dragstart` and make them available. + // + // Additionally, if any modifier is reported to a drag event other than + // `dragstart`, the platform must not be Wayland and the workaround and + // warning can be disabled. + const eventTypes = [ + "dragstart", + "dragend", + "drag", + "dragenter", + "dragover", + "dragleave", + ]; + function dragHandler(event: DragEvent) { + if (event.type == "dragstart") { + savedModifiers = { + shiftKey: event.shiftKey, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }; + } else if (event.type == "dragend") { + savedModifiers = undefined; + } else if ( + event.ctrlKey || + event.altKey || + event.shiftKey || + event.metaKey + ) { + // Non-Wayland platform detected, disable workaround. + savedModifiers = undefined; + mustRestartDragToChangeModifiers = false; + for (const eventType of eventTypes) { + window.removeEventListener(eventType, dragHandler, { capture: true }); + } + } + } + for (const eventType of eventTypes) { + window.addEventListener(eventType, dragHandler, { capture: true }); + } + } +} + export function getDropEffectFromModifiers( event: DragEvent, defaultDropEffect: DropEffect, moveAllowed: boolean, ): { dropEffect: DropEffect | "move" | "copy"; dropEffectMessage: string } { + const modifiers = savedModifiers ?? event; let dropEffect: DropEffect | "move" | "copy"; - if (event.shiftKey) { + if (modifiers.shiftKey) { dropEffect = "copy"; - } else if (event.ctrlKey && moveAllowed) { + } else if (modifiers.ctrlKey && moveAllowed) { dropEffect = "move"; } else { dropEffect = defaultDropEffect; } let message = ""; const addMessage = (msg: string) => { - if (message !== "") { + if (message === "" && mustRestartDragToChangeModifiers) { + message = "restart drag and "; + } else if (message !== "") { message += ", "; } message += msg; }; if (defaultDropEffect !== "none" && dropEffect !== defaultDropEffect) { - if (event.shiftKey) { + if (modifiers.shiftKey) { addMessage(`release SHIFT to ${defaultDropEffect}`); } else { addMessage(`release CONTROL to ${defaultDropEffect}`); @@ -143,5 +253,10 @@ export function getDropEffectFromModifiers( if (dropEffect !== "move" && moveAllowed && defaultDropEffect !== "move") { addMessage("hold CONTROL to move"); } + + if (message !== "" && mustRestartDragToChangeModifiers) { + message += + "; due to Wayland limitation, modifier keys cannot be changed during drag"; + } return { dropEffect, dropEffectMessage: message }; } From be9aada82c6d1969bb83003432faecf56ebdd1c1 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 12 Aug 2026 17:18:39 +0200 Subject: [PATCH 2/5] feat: add cmd for control binds --- src/help/input_event_bindings.ts | 13 +++++++++++-- src/util/event_action_map.ts | 13 ++++++++++++- src/util/platform.ts | 22 ++++++++++++++++++++++ 3 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 src/util/platform.ts diff --git a/src/help/input_event_bindings.ts b/src/help/input_event_bindings.ts index ff5ec6b1b2..97c1c0581b 100644 --- a/src/help/input_event_bindings.ts +++ b/src/help/input_event_bindings.ts @@ -32,6 +32,7 @@ import { type EventActionMap, } from "#src/util/event_action_map.js"; import { emptyToUndefined } from "#src/util/json.js"; +import { isMacPlatform } from "#src/util/platform.js"; declare let NEUROGLANCER_BUILD_INFO: | { tag: string; url?: string; timestamp?: string } @@ -51,8 +52,16 @@ export function formatKeyName(name: string) { } export function formatKeyStroke(stroke: string) { - const parts = stroke.split("+"); - return parts.map(formatKeyName).join("+"); + const mac = isMacPlatform(); + return stroke + .split("+") + .map((part) => { + if (mac && part === "control") return "⌘"; + if (mac && part === "alt") return "⌥"; + if (mac && part === "shift") return "⇧"; + return formatKeyName(part); + }) + .join("+"); } const DEFAULT_HELP_PANEL_LOCATION: SidePanelLocation = { diff --git a/src/util/event_action_map.ts b/src/util/event_action_map.ts index bddf12a6f0..3ff1c9b319 100644 --- a/src/util/event_action_map.ts +++ b/src/util/event_action_map.ts @@ -17,6 +17,7 @@ import { registerEventListener } from "#src/util/disposable.js"; import type { HierarchicalMapInterface } from "#src/util/hierarchical_map.js"; import { HierarchicalMap } from "#src/util/hierarchical_map.js"; +import { isMacPlatform } from "#src/util/platform.js"; /** * @file Facilities for dispatching user-defined actions in response to input events. @@ -463,8 +464,18 @@ export function dispatchEventWithModifiers( detail: any, eventMap: EventActionMapInterface, ) { + let modifiers = getEventModifierMask(originalEvent); + // On Mac, treat Cmd (meta) as Ctrl for shortcut matching so that + // "control+key" bindings fire when the user presses Cmd+key. + if ( + isMacPlatform() && + modifiers & Modifiers.META && + !(modifiers & Modifiers.CONTROL) + ) { + modifiers = (modifiers & ~Modifiers.META) | Modifiers.CONTROL; + } dispatchEvent( - getStrokeIdentifier(baseIdentifier, getEventModifierMask(originalEvent)), + getStrokeIdentifier(baseIdentifier, modifiers), originalEvent, originalEvent.eventPhase, detail, diff --git a/src/util/platform.ts b/src/util/platform.ts new file mode 100644 index 0000000000..b2c60fa930 --- /dev/null +++ b/src/util/platform.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isMacPlatform(): boolean { + if (typeof navigator === "undefined") return false; + return /Mac|iPhone|iPad/.test( + (navigator as any).userAgentData?.platform ?? navigator.platform ?? "", + ); +} From 5aa74f3ac5e41960fd0d68faa98bd64bd3239398 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Fri, 3 Jul 2026 10:38:07 +0200 Subject: [PATCH 3/5] fix: more flexible platform detection mac (cherry picked from commit d96fa6a35d48090717a494b43bdd14522b56679c) --- src/util/platform.spec.ts | 44 +++++++++++++++++++++++++++++++++++++++ src/util/platform.ts | 5 ++++- 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 src/util/platform.spec.ts diff --git a/src/util/platform.spec.ts b/src/util/platform.spec.ts new file mode 100644 index 0000000000..804605d2b9 --- /dev/null +++ b/src/util/platform.spec.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2026 Google Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { isMacPlatform } from "#src/util/platform.js"; + +describe("isMacPlatform", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns true for userAgentData.platform 'macOS'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "macOS" } }); + expect(isMacPlatform()).toBe(true); + }); + + it("returns true for legacy navigator.platform 'MacIntel'", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect(isMacPlatform()).toBe(true); + }); + + it("returns false for userAgentData.platform 'Windows'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "Windows" } }); + expect(isMacPlatform()).toBe(false); + }); + + it("returns false for userAgentData.platform 'Linux'", () => { + vi.stubGlobal("navigator", { userAgentData: { platform: "Linux" } }); + expect(isMacPlatform()).toBe(false); + }); +}); diff --git a/src/util/platform.ts b/src/util/platform.ts index b2c60fa930..36a2ec747a 100644 --- a/src/util/platform.ts +++ b/src/util/platform.ts @@ -16,7 +16,10 @@ export function isMacPlatform(): boolean { if (typeof navigator === "undefined") return false; - return /Mac|iPhone|iPad/.test( + // `userAgentData` (Client Hints) is preferred where available; `navigator.platform` is + // deprecated but remains the only option in Firefox and Safari, which do not implement + // `userAgentData`. + return /mac/i.test( (navigator as any).userAgentData?.platform ?? navigator.platform ?? "", ); } From 233bab224b2cc2098b8cb284486addc92c816399 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 12 Aug 2026 17:27:55 +0200 Subject: [PATCH 4/5] fix: correct mac bind labels Show Cmd rather than Ctrl in tooltips describing modifier+click bindings, matching what the Cmd-as-Ctrl remap in dispatchEventWithModifiers makes the user actually press. Left untouched: layer_bar.ts, segmentation_display_state/frontend.ts, and drag_and_drop.ts have Ctrl/Alt-only click handlers with no Cmd/Option equivalent implemented at all; fixing those requires adding new functional behavior. (cherry picked from commit 0305e2100ec0bac48672b787f255df17a53af0f5) --- src/rendered_data_panel.ts | 5 +++-- src/ui/selection_details.ts | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/rendered_data_panel.ts b/src/rendered_data_panel.ts index d0436dd1cf..f2747df762 100644 --- a/src/rendered_data_panel.ts +++ b/src/rendered_data_panel.ts @@ -45,6 +45,7 @@ import { KeyboardEventBinder } from "#src/util/keyboard_bindings.js"; import * as matrix from "#src/util/matrix.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; import { startRelativeMouseDrag } from "#src/util/mouse_drag.js"; +import { isMacPlatform } from "#src/util/platform.js"; import type { TouchPinchInfo, TouchTranslateInfo, @@ -431,8 +432,8 @@ export abstract class RenderedDataPanel extends RenderedPanel { typeof NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP !== "undefined" && NEUROGLANCER_SHOW_OBJECT_SELECTION_TOOLTIP === true ) { - element.title = - "Double click to toggle display of object under mouse pointer. Control+rightclick to pin/unpin selection."; + const modifierKeyLabel = isMacPlatform() ? "Cmd" : "Control"; + element.title = `Double click to toggle display of object under mouse pointer. ${modifierKeyLabel}+rightclick to pin/unpin selection.`; } this.registerDisposer(new AutomaticallyFocusedElement(element)); diff --git a/src/ui/selection_details.ts b/src/ui/selection_details.ts index 0c6f5bb7a9..b282065a14 100644 --- a/src/ui/selection_details.ts +++ b/src/ui/selection_details.ts @@ -29,6 +29,7 @@ import { SidePanel } from "#src/ui/side_panel.js"; import { setClipboard } from "#src/util/clipboard.js"; import type { Borrowed } from "#src/util/disposable.js"; import { MouseEventBinder } from "#src/util/mouse_bindings.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { CheckboxIcon } from "#src/widget/checkbox_icon.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; import { DependentViewWidget } from "#src/widget/dependent_view_widget.js"; @@ -72,15 +73,15 @@ export class SelectionDetailsPanel extends SidePanel { }); titleBar.appendChild(backButton); titleBar.appendChild(forwardButton); + const modifierKeyLabel = isMacPlatform() ? "cmd" : "ctrl"; titleBar.appendChild( this.registerDisposer( new CheckboxIcon(state.pin, { // Note: \ufe0e forces text display, as otherwise the pin icon may as an emoji with // color. text: "📌\ufe0e", - enableTitle: "Pin selection\nctrl+rightclick to select and pin", - disableTitle: - "Unpin selection\nctrl+shift+rightclick to select on hover", + enableTitle: `Pin selection\n${modifierKeyLabel}+rightclick to select and pin`, + disableTitle: `Unpin selection\n${modifierKeyLabel}+shift+rightclick to select on hover`, }), ).element, ); From fc02a6648d3c9df789b4a0e666e521ee754969c3 Mon Sep 17 00:00:00 2001 From: Sean Martin Date: Wed, 12 Aug 2026 18:25:48 +0200 Subject: [PATCH 5/5] fix: use Cmd as the drag move modifier and correct remaining bind labels getDropEffectFromModifiers accepted only Ctrl to force a "move" drop, which is unusable on Mac where Ctrl+click is the system secondary-click gesture. Accept Cmd there instead, and name the modifier accordingly in the drag status message. savedModifiers already captured metaKey, so the Wayland workaround needs no change. The segment list and add-layer tooltips were label-only: Option and Cmd already worked, the text just named the wrong keys. The tool palette dropdown button advertised "control+click to create new", but its CheckboxIcon ignores modifiers and no create-new path exists, so the hint was inaccurate on every platform. Drop it. --- src/segmentation_display_state/frontend.ts | 5 +- src/ui/layer_bar.ts | 6 +- src/ui/tool_palette.ts | 2 +- src/util/drag_and_drop.spec.ts | 78 +++++++++++++++++++++- src/util/drag_and_drop.ts | 14 +++- 5 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/segmentation_display_state/frontend.ts b/src/segmentation_display_state/frontend.ts index 8ffdebbad5..ce047d58ac 100644 --- a/src/segmentation_display_state/frontend.ts +++ b/src/segmentation_display_state/frontend.ts @@ -59,6 +59,7 @@ import { measureElementClone } from "#src/util/dom.js"; import type { vec3 } from "#src/util/geom.js"; import { kOneVec, vec4 } from "#src/util/geom.js"; import { parseUint64 } from "#src/util/json.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { NullarySignal } from "#src/util/signal.js"; import { withSharedVisibility } from "#src/visibility_priority/frontend.js"; import { makeCopyButton } from "#src/widget/copy_button.js"; @@ -303,8 +304,10 @@ export function bindSegmentListWidth( const segmentWidgetTemplate = (() => { const template = document.createElement("div"); template.classList.add("neuroglancer-segment-list-entry"); + const colorModifierLabel = isMacPlatform() ? "option" : "alt"; template.title = - "Right click to move to segment, alt+click to set color, alt+shift+click to unset color"; + `Right click to move to segment, ${colorModifierLabel}+click to set color, ` + + `${colorModifierLabel}+shift+click to unset color`; const stickyContainer = document.createElement("div"); stickyContainer.classList.add("neuroglancer-segment-list-entry-sticky"); template.appendChild(stickyContainer); diff --git a/src/ui/layer_bar.ts b/src/ui/layer_bar.ts index b4339954e8..6b9f857572 100644 --- a/src/ui/layer_bar.ts +++ b/src/ui/layer_bar.ts @@ -37,6 +37,7 @@ import { import { RefCounted } from "#src/util/disposable.js"; import { removeFromParent } from "#src/util/dom.js"; import { preventDrag } from "#src/util/drag_and_drop.js"; +import { isMacPlatform } from "#src/util/platform.js"; import { makeCloseButton } from "#src/widget/close_button.js"; import { makeDeleteButton } from "#src/widget/delete_button.js"; import { makeIcon } from "#src/widget/icon.js"; @@ -331,8 +332,9 @@ export class LayerBar extends RefCounted { const addButton = makeIcon({ svg: svg_plus, - title: - "Click to add layer, control+click/right click/⌘+click to add local annotation layer.", + title: `Click to add layer, ${ + isMacPlatform() ? "⌘+click" : "control+click" + }/right click to add local annotation layer.`, }); addButton.classList.add("neuroglancer-layer-add-button"); diff --git a/src/ui/tool_palette.ts b/src/ui/tool_palette.ts index c2610a4adf..efc1044bdb 100644 --- a/src/ui/tool_palette.ts +++ b/src/ui/tool_palette.ts @@ -1274,7 +1274,7 @@ export class MultiToolPaletteDropdownButton extends RefCounted { const checkbox = this.registerDisposer( new CheckboxIcon(this.dropdownVisible, { svg: svg_tool, - enableTitle: "Show tool palette list (control+click to create new)", + enableTitle: "Show tool palette list", disableTitle: "Hide tool palette list", backgroundScheme: "dark", }), diff --git a/src/util/drag_and_drop.spec.ts b/src/util/drag_and_drop.spec.ts index 96c5b4ec80..460f260062 100644 --- a/src/util/drag_and_drop.spec.ts +++ b/src/util/drag_and_drop.spec.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import { describe, it, expect } from "vitest"; +import { afterEach, describe, it, expect, vi } from "vitest"; import { encodeParametersAsDragType, decodeParametersFromDragType, + getDropEffectFromModifiers, } from "#src/util/drag_and_drop.js"; describe("drag_and_drop", () => { @@ -31,3 +32,78 @@ describe("drag_and_drop", () => { expect(result).toEqual(json); }); }); + +describe("getDropEffectFromModifiers", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function makeDragEvent( + modifiers: Partial< + Pick + >, + ) { + return { + shiftKey: false, + ctrlKey: false, + metaKey: false, + altKey: false, + ...modifiers, + } as DragEvent; + } + + it("uses Ctrl as the move modifier off Mac", () => { + vi.stubGlobal("navigator", { platform: "Win32" }); + const { dropEffect } = getDropEffectFromModifiers( + makeDragEvent({ ctrlKey: true }), + "link", + true, + ); + expect(dropEffect).toBe("move"); + }); + + it("uses Cmd as the move modifier on Mac", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + const { dropEffect } = getDropEffectFromModifiers( + makeDragEvent({ metaKey: true }), + "link", + true, + ); + expect(dropEffect).toBe("move"); + }); + + it("ignores Ctrl on Mac, where it is the secondary-click gesture", () => { + vi.stubGlobal("navigator", { platform: "MacIntel" }); + const { dropEffect } = getDropEffectFromModifiers( + makeDragEvent({ ctrlKey: true }), + "link", + true, + ); + expect(dropEffect).toBe("link"); + }); + + it("names the move modifier per platform in the message", () => { + vi.stubGlobal("navigator", { platform: "Win32" }); + expect( + getDropEffectFromModifiers(makeDragEvent({}), "link", true) + .dropEffectMessage, + ).toContain("hold CONTROL to move"); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + expect( + getDropEffectFromModifiers(makeDragEvent({}), "link", true) + .dropEffectMessage, + ).toContain("hold COMMAND to move"); + }); + + it("uses Shift to copy on both platforms", () => { + for (const platform of ["Win32", "MacIntel"]) { + vi.stubGlobal("navigator", { platform }); + const { dropEffect } = getDropEffectFromModifiers( + makeDragEvent({ shiftKey: true }), + "link", + true, + ); + expect(dropEffect).toBe("copy"); + } + }); +}); diff --git a/src/util/drag_and_drop.ts b/src/util/drag_and_drop.ts index b2e93f19f6..1ff7aa5c92 100644 --- a/src/util/drag_and_drop.ts +++ b/src/util/drag_and_drop.ts @@ -30,6 +30,7 @@ import { registerEventListener } from "#src/util/disposable.js"; import { hexEncode, hexDecode } from "#src/util/hex.js"; +import { isMacPlatform } from "#src/util/platform.js"; export function encodeStringAsDragType(s: string) { return hexEncode(new TextEncoder().encode(s)); @@ -223,10 +224,17 @@ export function getDropEffectFromModifiers( moveAllowed: boolean, ): { dropEffect: DropEffect | "move" | "copy"; dropEffectMessage: string } { const modifiers = savedModifiers ?? event; + // Ctrl+drag is unavailable on Mac, where Ctrl+click is the system secondary-click + // gesture; Cmd is the conventional modifier there. + const macPlatform = isMacPlatform(); + const moveModifierActive = macPlatform + ? modifiers.metaKey + : modifiers.ctrlKey; + const moveModifierLabel = macPlatform ? "COMMAND" : "CONTROL"; let dropEffect: DropEffect | "move" | "copy"; if (modifiers.shiftKey) { dropEffect = "copy"; - } else if (modifiers.ctrlKey && moveAllowed) { + } else if (moveModifierActive && moveAllowed) { dropEffect = "move"; } else { dropEffect = defaultDropEffect; @@ -244,14 +252,14 @@ export function getDropEffectFromModifiers( if (modifiers.shiftKey) { addMessage(`release SHIFT to ${defaultDropEffect}`); } else { - addMessage(`release CONTROL to ${defaultDropEffect}`); + addMessage(`release ${moveModifierLabel} to ${defaultDropEffect}`); } } if (dropEffect !== "copy") { addMessage("hold SHIFT to copy"); } if (dropEffect !== "move" && moveAllowed && defaultDropEffect !== "move") { - addMessage("hold CONTROL to move"); + addMessage(`hold ${moveModifierLabel} to move`); } if (message !== "" && mustRestartDragToChangeModifiers) {