Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/components/contextmenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
53 changes: 53 additions & 0 deletions src/handlers/editorFileTab.js
Original file line number Diff line number Diff line change
@@ -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}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -311,6 +361,9 @@ function cleanupDrag(state = getCurrentDragState()) {
allowPaneTransfer = true;
initialNextSibling = null;
didReorder = false;
didDrag = false;
dragOriginX = 0;
dragOriginY = 0;
}

function preventDefaultScroll() {
Expand Down
Loading