fix: route dashboard file menus by input modality, not touch capability

A mouse right-click on a file row in the dashboard Files tab opened the
mobile context-menu sheet (centered over the row, nowhere near the
cursor) on any touch-capable laptop or desktop, because phone/tablet
detection used navigator.maxTouchPoints > 0. The same check applied the
.touch-device class, degrading mouse selection and drag on those
machines.

Route by how the menu was invoked (the row's tracked pointer type) plus
isTouchPrimaryDevice() — coarse pointer and no hover — which is false on
touch-capable laptops but true on iPads whose UA claims macOS. Also let
sidebar-folder and background menus accept taphold on such iPads, where
they were previously unreachable (taphold dismissed, and iOS never fires
native contextmenu).
This commit is contained in:
Nariman Jelveh
2026-08-13 17:46:58 -07:00
parent d980a9f590
commit 7576e0b03a
2 changed files with 43 additions and 23 deletions
@@ -109,9 +109,11 @@ export default class ContextMenuModal {
const width = isMobile ? viewportWidth * 0.9 : modalWidth;
// Center over the target horizontally, clamped to the viewport. (The
// old code hardcoded left:300px on non-touch devices — which route here
// whenever maxTouchPoints > 0, e.g. touchscreen laptops — so the menu
// opened nowhere near the item on a wide screen.)
// old code hardcoded left:300px on non-touch devices — which used to
// route here whenever maxTouchPoints > 0, e.g. touchscreen laptops —
// so the menu opened nowhere near the item on a wide screen. Mouse
// interactions now get the desktop UIContextMenu instead; this modal
// is only for touch invocations and touch-first devices.)
const itemCenter = targetRect.left + (targetRect.width / 2);
let left = itemCenter - width / 2;
left = Math.max(margin, Math.min(left, viewportWidth - width - margin));
+38 -20
View File
@@ -30,7 +30,7 @@ import update_title_based_on_uploads from '../../helpers/update_title_based_on_u
import item_icon from '../../helpers/item_icon.js';
import new_context_menu_item from '../../helpers/new_context_menu_item.js';
import publish_as_website from '../../helpers/publish_as_website.js';
import ContextMenuModal from './ContextMenu/ContextMenu.js';
import ContextMenuModal, { isTouchPrimaryDevice } from './ContextMenu/ContextMenu.js';
import UIItemPropertiesModal from './UIItemPropertiesModal.js';
import { dedupedName } from './dedupedName.js';
import { isEntryVisible, isHiddenName, showHiddenFiles } from './hiddenFiles.js';
@@ -306,9 +306,12 @@ const TabFiles = {
modified: 120,
};
// Add touch-device class for touch devices to show .item-more button
// Use multiple detection methods since user-agent sniffing can miss devices
if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) {
// Add touch-device class on touch-FIRST devices only (coarse pointer,
// no hover — which also catches iPads whose UA claims macOS).
// Deliberately not maxTouchPoints: a touch-capable laptop is still
// mouse-first, and this class strips pointer-events from the
// name/icon drag handles, degrading mouse selection and drag.
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() ) {
$el_window.find('.files-tab').addClass('touch-device');
}
@@ -322,9 +325,11 @@ const TabFiles = {
_this.renderDirectory(folderPath);
};
// Context menu for sidebar folders
// Context menu for sidebar folders. isTouchPrimaryDevice() covers
// touch-first devices the UA misses (iPadOS claims macOS) — both
// for accepting the taphold and for picking the touch sheet.
$(folderElement).on('contextmenu taphold', async (e) => {
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) {
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet && !isTouchPrimaryDevice() ) {
return;
}
e.preventDefault();
@@ -333,7 +338,7 @@ const TabFiles = {
const folderPath = folderElement.getAttribute('data-path');
const items = _this.generateFolderContextMenu(folderPath);
if ( window.isMobile.phone || window.isMobile.tablet ) {
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() ) {
const modal = new ContextMenuModal({
onClose: () => $(folderElement).removeClass('context-menu-active'),
});
@@ -532,8 +537,9 @@ const TabFiles = {
// Right-click on background shows folder context menu
$el_window.find('.files').on('contextmenu taphold', async (e) => {
// Dismiss taphold on non-touch devices
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet ) {
// Dismiss taphold on non-touch devices (isTouchPrimaryDevice
// catches iPads whose UA claims macOS)
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet && !isTouchPrimaryDevice() ) {
return;
}
// Only trigger if clicking directly on .files container (not on a row)
@@ -548,7 +554,7 @@ const TabFiles = {
});
_this.updateFooterStats();
const items = await _this.generateFolderContextMenu();
if ( window.isMobile.phone || window.isMobile.tablet ) {
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() ) {
const modal = new ContextMenuModal();
modal.show(items, e.target.getBoundingClientRect());
} else {
@@ -2522,12 +2528,14 @@ const TabFiles = {
const isPending = () => el_item.getAttribute('data-pending') === '1';
el_item.onpointerdown = (e) => {
// Track pointer type so onclick and the menu handlers can
// distinguish touch from mouse — recorded before the early
// returns because the '⋯' menu routing needs it too.
lastPointerType = e.pointerType;
if ( e.target.classList.contains('item-more') ) return;
if ( el_item.classList.contains('header') ) return;
// Track pointer type so onclick can distinguish touch from mouse.
lastPointerType = e.pointerType;
// On touch devices, skip all selection logic here.
// Taps are handled by onclick (opens item) and taphold (context menu),
// so pointerdown never accidentally selects while the user is scrolling.
@@ -2666,7 +2674,7 @@ const TabFiles = {
el_item.onclick = (e) => {
if ( e.target.classList.contains('item-more') ) {
if ( isPending() ) return;
this.handleMoreClick(el_item, file, e.target);
this.handleMoreClick(el_item, file, e.target, lastPointerType === 'touch');
return;
}
@@ -2840,8 +2848,10 @@ const TabFiles = {
// Right-click context menu handler (desktop) and taphold (touch devices)
$(el_item).on('contextmenu taphold', async (e) => {
// Dismiss taphold on non-touch devices
if ( e.type === 'taphold' && !window.isMobile.phone && !window.isMobile.tablet && !(navigator.maxTouchPoints > 0) ) {
// A taphold only counts when it came from an actual touch — the
// plugin (helpers.js) also fires it for a 1s mouse-button hold,
// which must stay inert, touchscreen or not.
if ( e.type === 'taphold' && lastPointerType !== 'touch' && !window.isMobile.phone && !window.isMobile.tablet ) {
return;
}
// On iOS, both contextmenu and taphold can fire for the same long-press.
@@ -2866,7 +2876,13 @@ const TabFiles = {
items = await _this.generateContextMenuItems(el_item, file);
}
if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) {
// The touch sheet is for touch interactions and touch-first
// devices. A mouse right-click gets the desktop menu at the
// cursor — including on touch-capable laptops, whose right-clicks
// used to land in the sheet (centered over the row, nowhere near
// the pointer) via a maxTouchPoints check.
const touchInvoked = e.type === 'taphold' || lastPointerType === 'touch';
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() || touchInvoked ) {
const modal = new ContextMenuModal();
modal.show(items, el_item.getBoundingClientRect(), { title: file.name });
} else {
@@ -3763,7 +3779,7 @@ const TabFiles = {
};
},
async handleMoreClick (rowElement, file, targetElement) {
async handleMoreClick (rowElement, file, targetElement, fromTouch) {
const selectedRows = document.querySelectorAll('.files-tab .row.selected');
let items;
@@ -3774,8 +3790,10 @@ const TabFiles = {
items = await this.generateContextMenuItems(rowElement, file);
}
// Use mobile-friendly context menu on touch devices
if ( window.isMobile.phone || window.isMobile.tablet || navigator.maxTouchPoints > 0 ) {
// The touch sheet for touch taps and touch-first devices; a mouse
// click gets the desktop menu anchored to the button, also on
// touch-capable laptops.
if ( window.isMobile.phone || window.isMobile.tablet || isTouchPrimaryDevice() || fromTouch ) {
const targetRect = targetElement.getBoundingClientRect();
const modal = new ContextMenuModal();
modal.show(items, targetRect, { title: file.name });