diff --git a/src/components/contextmenu/index.js b/src/components/contextmenu/index.js index 025977f2fe..332254f6cf 100644 --- a/src/components/contextmenu/index.js +++ b/src/components/contextmenu/index.js @@ -124,7 +124,108 @@ export default function Contextmenu(content, options) { for (let $el of children) $el.tabIndex = "0"; } + /** + * Returns the currently focused menu item, or null if focus is + * elsewhere (e.g. nothing focused yet, or focus left the menu). + * @returns {HTMLElement|null} + */ + function getFocusedItem() { + const items = [...$el.children]; + const index = items.indexOf(document.activeElement); + return index === -1 ? null : document.activeElement; + } + + /** + * Moves focus to the next/previous menu item, wrapping around at the + * ends. `direction` is +1 for next, -1 for previous. + * @param {number} direction + */ + function moveFocus(direction) { + const items = [...$el.children]; + if (!items.length) return; + + const currentIndex = items.indexOf(document.activeElement); + let nextIndex; + if (currentIndex === -1) { + nextIndex = direction > 0 ? 0 : items.length - 1; + } else { + nextIndex = (currentIndex + direction + items.length) % items.length; + } + items[nextIndex]?.focus(); + } + + /** + * Turns a keyboard activation (Enter/Space on a focused menu item) into + * a real click event, so both selection patterns this component + * supports handle it identically to a pointer click: + * - the `items`/`onselect` array form, whose routing lives in the + * `onclick` handler above + * - a consumer's own click listener attached directly to $el, as used + * by menus built with the `innerHTML` option + * + * The dispatched event is marked with `keyboardActivated` so consumers + * that filter out synthetic `detail === 0` ghost clicks (e.g. to ignore + * the click that follows a touch-based long press) can still recognize + * and allow this one through. + * @param {HTMLElement} $item + */ + function activateItem($item) { + if (!$item || !$el.contains($item)) return; + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + view: window, + }); + Object.defineProperty(clickEvent, "keyboardActivated", { + value: true, + }); + $item.dispatchEvent(clickEvent); + } + + /** + * Keyboard support for the menu: Enter/Space activates the focused + * item, Up/Down arrows move focus between items (wrapping at the + * ends), Home/End jump to the first/last item, and Escape closes the + * menu and returns focus to the toggler. + * @param {KeyboardEvent} e + */ + function onMenuKeydown(e) { + switch (e.key) { + case "Enter": + case " ": + case "Spacebar": + e.preventDefault(); + activateItem(getFocusedItem()); + break; + case "ArrowDown": + case "Down": + e.preventDefault(); + moveFocus(1); + break; + case "ArrowUp": + case "Up": + e.preventDefault(); + moveFocus(-1); + break; + case "Home": + e.preventDefault(); + $el.firstElementChild?.focus(); + break; + case "End": + e.preventDefault(); + $el.lastElementChild?.focus(); + break; + case "Escape": + case "Esc": + e.preventDefault(); + hide(); + options.toggler?.focus?.(); + break; + } + } + function destroy() { + $el.removeEventListener("keydown", onMenuKeydown); $el.remove(); $mask.remove(); options.toggler?.removeEventListener("click", toggle); @@ -134,6 +235,8 @@ export default function Contextmenu(content, options) { options.toggler.addEventListener("click", toggle); } + $el.addEventListener("keydown", onMenuKeydown); + $el.hide = hide; $el.show = show; $el.destroy = destroy; diff --git a/src/handlers/editorFileTab.js b/src/handlers/editorFileTab.js index 77ea649ea4..8f082af8d9 100644 --- a/src/handlers/editorFileTab.js +++ b/src/handlers/editorFileTab.js @@ -1,10 +1,17 @@ import { focusEditorIfEditable } from "cm/editorReadOnly"; +import openTabContextMenu from "handlers/tabContextMenu"; import config from "lib/config"; import settings from "lib/settings"; import { animate } from "motion"; const opts = { passive: false }; +/** + * How far the pointer must travel before a long press counts as a drag + * instead of a tab context menu request. + */ +const DRAG_MENU_SLOP = 8; + /** * Clone of tab being dragged * @type {HTMLDivElement} @@ -78,6 +85,23 @@ let prevScrollLeft = 0; let initialNextSibling = null; let didReorder = false; let dragSessionId = 0; +/** + * Whether the pointer moved far enough during this drag session to count as a + * real drag. When the drag session ends without any drag, the tab context + * menu is shown instead. + * @type {boolean} + */ +let didDrag = false; +/** + * Pointer position where the current drag session started. + * @type {number} + */ +let dragOriginX = 0; +/** + * Pointer position where the current drag session started. + * @type {number} + */ +let dragOriginY = 0; const MIN_SCROLL_SPEED = 2; const MAX_SCROLL_SPEED = 14; @@ -96,6 +120,10 @@ export default function startDrag(e) { const { clientX, clientY } = getClientPos(e); const { editor, activeFile } = editorManager; + dragOriginX = clientX; + dragOriginY = clientY; + didDrag = false; + if (activeFile.focusedBefore) { focusEditorIfEditable(editor); } @@ -177,6 +205,14 @@ function onDrag(e) { const { clientX, clientY } = getClientPos(e); + if ( + !didDrag && + (Math.abs(clientX - dragOriginX) > DRAG_MENU_SLOP || + Math.abs(clientY - dragOriginY) > DRAG_MENU_SLOP) + ) { + didDrag = true; + } + tabLeft = clientX - offsetX; tabTop = clientY - offsetY; @@ -206,6 +242,20 @@ function onDrag(e) { function releaseDrag(e) { const { clientX, clientY } = getClientPos(e); + // A long press (or right click) that ends without moving the pointer is a + // request for the tab context menu, not a drag. + const openContextMenu = + !didDrag && + !!draggedFile && + (e.type === "mouseup" || e.type === "touchend"); + + if (openContextMenu) { + const file = draggedFile; + finishDrag(false); + openTabContextMenu(file); + return; + } + /**@type {HTMLDivElement} target tab */ const $target = document.elementFromPoint(clientX, clientY); const isPathDropTarget = isFilePathDropTarget($target); @@ -311,6 +361,9 @@ function cleanupDrag(state = getCurrentDragState()) { allowPaneTransfer = true; initialNextSibling = null; didReorder = false; + didDrag = false; + dragOriginX = 0; + dragOriginY = 0; } function preventDefaultScroll() { diff --git a/src/handlers/tabContextMenu.js b/src/handlers/tabContextMenu.js new file mode 100644 index 0000000000..906b765514 --- /dev/null +++ b/src/handlers/tabContextMenu.js @@ -0,0 +1,204 @@ +import Contextmenu from "components/contextmenu"; + +const EDGE_MARGIN = 8; +const MENU_WIDTH_ESTIMATE = 240; +const MENU_ITEM_HEIGHT = 50; +const MENU_ITEM_COUNT = 4; +const MENU_HEIGHT_ESTIMATE = MENU_ITEM_COUNT * MENU_ITEM_HEIGHT; +const GAP = 4; +const SYNTHETIC_CLICK_WINDOW = 700; + +/** + * How far the pointer must travel before a long press counts as a drag + * instead of a tab context menu request. Matches the slop used in + * handlers/editorFileTab.js so the sidebar long-press gesture behaves + * consistently with the tab-bar drag gesture. + */ +const DRAG_MENU_SLOP = 8; + +/** + * Open the context menu for a file tab. + * + * Actions are executed through `acode.exec`, so they use the same commands + * and prompts as the rest of the app: + * - close-tab close the pressed tab + * - close-tabs-in-group close all tabs in the same tab group/pane + * - close-tabs-to-left close tabs left of the pressed tab + * - close-tabs-to-right close tabs right of the pressed tab + * + * @param {object} file The `EditorFile` whose tab was long pressed / right clicked + * @returns {HTMLElement|undefined} The context menu element, if it was shown. + */ +export default function openTabContextMenu(file) { + if (!file || !file.tab || !file.tab.isConnected) return; + + const menu = Contextmenu({ + ...positionMenu(file.tab), + innerHTML: getMenuItemsHtml, + }); + + const guardUntil = Date.now() + SYNTHETIC_CLICK_WINDOW; + + /** + * Touch gestures that open a context menu can be followed by a synthetic + * click (detail === 0). Ignore those so releasing the finger does not + * accidentally activate a menu item underneath it. + * + * Keyboard-activated clicks dispatched by the Contextmenu component are + * also `detail === 0` (since they're synthetic MouseEvents), but they're + * marked with `keyboardActivated` so they're never mistaken for a + * touch-release ghost click and suppressed here. + * @param {MouseEvent} event + */ + const suppressSyntheticClick = (event) => { + if (Date.now() > guardUntil) return; + if (event.keyboardActivated) return; + if (event.detail !== 0) return; + if (!menu.contains(event.target)) return; + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation?.(); + }; + + const removeSuppressor = () => { + document.removeEventListener("click", suppressSyntheticClick, true); + }; + + document.addEventListener("click", suppressSyntheticClick, true); + menu.onhide = removeSuppressor; + + menu.addEventListener("click", (event) => { + // A synthetic click with detail === 0 that isn't marked as a real + // keyboard activation is a touch-release ghost click; ignore it. + if (event.detail === 0 && !event.keyboardActivated) return; + const $target = event.target; + const action = $target?.getAttribute?.("action"); + if (!action) return; + menu.hide(); + removeSuppressor(); + acode.exec(action, file.id); + }); + + menu.show(); + return menu; +} + +/** + * Show the tab context menu after the ongoing long press / right click ends. + * + * Used in layouts where a long press does not start a tab drag (sidebar open + * file list). Opening while the finger is still down would let the release + * hit the menu, so the menu is opened when the pointer is lifted instead. + * + * A small movement threshold (DRAG_MENU_SLOP) is allowed before the gesture + * is treated as a drag/scroll rather than a menu request, matching the + * tab-bar drag gesture in handlers/editorFileTab.js. Without this, ordinary + * finger drift during a long press would cancel the menu before touchend. + * + * @param {object} file The `EditorFile` whose tab was long pressed / right clicked + * @param {MouseEvent} event The contextmenu event + */ +export function openTabContextMenuOnRelease(file, event) { + if (!file || !file.tab || !file.tab.isConnected) return; + event.preventDefault?.(); + event.stopPropagation?.(); + + const { clientX: originX, clientY: originY } = getEventClientPos(event); + + let opened = false; + let cancelled = false; + + const open = () => { + if (opened || cancelled) return; + opened = true; + cleanup(); + openTabContextMenu(file); + }; + + const onPointerMove = (moveEvent) => { + const { clientX, clientY } = getEventClientPos(moveEvent); + if ( + Math.abs(clientX - originX) <= DRAG_MENU_SLOP && + Math.abs(clientY - originY) <= DRAG_MENU_SLOP + ) { + // Still within the slop threshold; treat as a stationary long + // press rather than a drag/scroll attempt. + return; + } + // Moved far enough after the long press means the user intends to + // scroll or drag, not to open a menu. + cancelled = true; + cleanup(); + }; + + const onCancel = () => { + cancelled = true; + cleanup(); + }; + + function cleanup() { + document.removeEventListener("touchmove", onPointerMove, true); + document.removeEventListener("touchend", open, true); + document.removeEventListener("touchcancel", onCancel, true); + document.removeEventListener("mouseup", open, true); + document.removeEventListener("mouseleave", onCancel, true); + } + + document.addEventListener("touchmove", onPointerMove, true); + document.addEventListener("touchend", open, true); + document.addEventListener("touchcancel", onCancel, true); + document.addEventListener("mouseup", open, true); + document.addEventListener("mouseleave", onCancel, true); +} + +/** + * Extracts the client X/Y position from a mouse or touch event. + * @param {MouseEvent|TouchEvent} e + * @returns {{clientX: number, clientY: number}} + */ +function getEventClientPos(e) { + const touch = e.touches?.[0] || e.changedTouches?.[0]; + if (touch) { + return { clientX: touch.clientX, clientY: touch.clientY }; + } + return { clientX: e.clientX ?? 0, clientY: e.clientY ?? 0 }; +} + +function getMenuItemsHtml() { + const closeText = strings["close file"] || "Close file"; + const closeAllText = strings["close all"] || "Close all"; + const closeLeftText = strings["close tabs to left"] || "Close Left"; + const closeRightText = strings["close tabs to right"] || "Close Right"; + return ` +