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/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/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/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, ); 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 edf78c82cf..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)); @@ -81,6 +82,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 +117,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,38 +137,134 @@ 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; + // 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 (event.shiftKey) { + if (modifiers.shiftKey) { dropEffect = "copy"; - } else if (event.ctrlKey && moveAllowed) { + } else if (moveModifierActive && 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}`); + 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) { + message += + "; due to Wayland limitation, modifier keys cannot be changed during drag"; } return { dropEffect, dropEffectMessage: message }; } 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.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 new file mode 100644 index 0000000000..36a2ec747a --- /dev/null +++ b/src/util/platform.ts @@ -0,0 +1,25 @@ +/** + * @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; + // `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 ?? "", + ); +}