mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 00:50:06 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22bdc9ee6 | ||
|
|
125654896c |
No files matched your search
@@ -315,6 +315,41 @@ test("draggable top-tab close buttons cancel the drag gesture on mousedown", ()
|
||||
assert.equal(workspaceSource.includes("<button"), false);
|
||||
});
|
||||
|
||||
test("draggable top tab roots activate sub-threshold presses and still reorder past the threshold", () => {
|
||||
const tabTypes = ["plugin-view", "editor", "session", "workspace", "logView"];
|
||||
|
||||
for (const tabType of tabTypes) {
|
||||
const marker = `data-tab-type="${tabType}"`;
|
||||
const start = topTabItemsSource.indexOf(marker);
|
||||
assert.notEqual(start, -1, marker);
|
||||
const classNameAt = topTabItemsSource.indexOf("className={cn(", start);
|
||||
assert.notEqual(classNameAt, -1, tabType);
|
||||
const openingHandlers = topTabItemsSource.slice(start, classNameAt);
|
||||
assert.match(openingHandlers, /onMouseDown=\{handleTabDragThresholdMouseDown\}/, tabType);
|
||||
assert.match(openingHandlers, /onMouseUp=\{\(event\) => handleTabDragThresholdRelease\(event,/, tabType);
|
||||
assert.match(
|
||||
openingHandlers,
|
||||
/onDragStart=\{\(event\) => \{\s*handleTabDragThresholdDragStart\(event\);\s*onTabDragStart\(event,/,
|
||||
tabType,
|
||||
);
|
||||
assert.match(
|
||||
openingHandlers,
|
||||
/onDrag=\{\(event\) => \{\s*if \(!handleTabDragThresholdDrag\(event\)\) return;\s*onTabDragStart\(event,/,
|
||||
tabType,
|
||||
);
|
||||
assert.match(
|
||||
openingHandlers,
|
||||
/onDragEnd=\{\(event\) => \{\s*handleTabDragThresholdRelease\(event,[\s\S]*?onTabDragEnd\(\);\s*\}\}/,
|
||||
tabType,
|
||||
);
|
||||
assert.equal(openingHandlers.includes("handleTabCloseButtonMouseDown"), false, tabType);
|
||||
assert.equal(openingHandlers.includes("preventDefault"), false, tabType);
|
||||
}
|
||||
|
||||
assert.equal(topTabItemsSource.match(/onMouseDown=\{handleTabMiddleMouseDown\}/g)?.length ?? 0, 0);
|
||||
assert.equal(topTabItemsSource.match(/handleTabDragThresholdRelease\(/g)?.length, 10);
|
||||
});
|
||||
|
||||
test("session close button double click stays on the close button", () => {
|
||||
let stopPropagationCount = 0;
|
||||
|
||||
|
||||
+14
-7
@@ -24,6 +24,7 @@ import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useSettingsChromeStore } from '../application/state/settingsChromeStore';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { Host, TerminalSession, Workspace } from '../types';
|
||||
import { isTabDragReorderStarted } from '../lib/tabInteractions';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
|
||||
@@ -520,14 +521,20 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
]);
|
||||
|
||||
const handleTabDragStart = useCallback((e: React.DragEvent, tabId: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('tab-reorder-id', tabId);
|
||||
// Also set session-id for backward compatibility with workspace split functionality
|
||||
// Only orphan sessions can be dragged to create workspaces
|
||||
const isOrphanSession = orphanSessionMap.has(tabId);
|
||||
if (isOrphanSession) {
|
||||
e.dataTransfer.setData('session-id', tabId);
|
||||
// Payload must be written on the original dragstart. A slow reorder calls
|
||||
// this again from `drag`, when DataTransfer is already read-only.
|
||||
try {
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('tab-reorder-id', tabId);
|
||||
// Also set session-id for backward compatibility with workspace split functionality
|
||||
// Only orphan sessions can be dragged to create workspaces
|
||||
if (orphanSessionMap.has(tabId)) {
|
||||
e.dataTransfer.setData('session-id', tabId);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException)) throw error;
|
||||
}
|
||||
if (!isTabDragReorderStarted()) return;
|
||||
draggedTabIdRef.current = tabId;
|
||||
// Use setTimeout to allow the drag image to be captured before we change styles
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -26,7 +26,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Host, TerminalSession, Workspace } from '../../types';
|
||||
import { DISTRO_LOGOS, DISTRO_COLORS } from '../DistroAvatar';
|
||||
import { getShellIconPath, isMonochromeShellIcon } from '../../lib/useDiscoveredShells';
|
||||
import { handleTabCloseButtonMouseDown, handleTabMiddleClickClose, handleTabMiddleMouseDown } from '../../lib/tabInteractions';
|
||||
import { handleTabCloseButtonMouseDown, handleTabDragThresholdDrag, handleTabDragThresholdDragStart, handleTabDragThresholdMouseDown, handleTabDragThresholdRelease, handleTabMiddleClickClose } from '../../lib/tabInteractions';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from '../ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { SessionTabContextMenuContent } from './SessionTabContextMenuContent';
|
||||
@@ -516,6 +516,9 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
shortcutNumber,
|
||||
}) => {
|
||||
const isActive = useIsTabActive(tab.id);
|
||||
const activate = useCallback(() => {
|
||||
activeTabStore.setActiveTabId(tab.id);
|
||||
}, [tab.id]);
|
||||
const close = useCallback((event?: React.MouseEvent) => {
|
||||
event?.stopPropagation();
|
||||
onClose(tab.id);
|
||||
@@ -526,12 +529,23 @@ export const PluginViewTopTab: React.FC<PluginViewTopTabProps> = memo(({
|
||||
data-tab-type="plugin-view"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={() => activeTabStore.setActiveTabId(tab.id)}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onClick={activate}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, activate)}
|
||||
onAuxClick={(event) => handleTabMiddleClickClose(event, close)}
|
||||
draggable
|
||||
onDragStart={(event) => onTabDragStart(event, tab.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, tab.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, tab.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, activate);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragOver={(event) => onTabDragOver(event, tab.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(event) => onTabDrop(event, tab.id)}
|
||||
@@ -648,11 +662,22 @@ export const EditorTopTab: React.FC<EditorTopTabProps> = memo(({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onRequestCloseEditorTab(editorTab.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, tabId)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, tabId);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, tabId);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragOver={(e) => onTabDragOver(e, tabId)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, tabId)}
|
||||
@@ -825,11 +850,22 @@ export const SessionTopTab: React.FC<SessionTopTabProps> = memo(({
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseSession(session.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, session.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, session.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, session.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragOver={(e) => onTabDragOver(e, session.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, session.id)}
|
||||
@@ -1030,11 +1066,22 @@ export const WorkspaceTopTab: React.FC<WorkspaceTopTabProps> = memo(({
|
||||
data-host-drop-active={isHostDropTarget ? 'true' : 'false'}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseWorkspace(workspace.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, workspace.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, workspace.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, workspace.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragOver={(e) => onTabDragOver(e, workspace.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, workspace.id)}
|
||||
@@ -1177,11 +1224,22 @@ export const LogViewTopTab: React.FC<LogViewTopTabProps> = memo(({
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
data-active={topTabActiveValue(isActive)}
|
||||
onClick={handleClick}
|
||||
onMouseDown={handleTabMiddleMouseDown}
|
||||
onMouseDown={handleTabDragThresholdMouseDown}
|
||||
onMouseUp={(event) => handleTabDragThresholdRelease(event, handleClick)}
|
||||
onAuxClick={(e) => handleTabMiddleClickClose(e, () => onCloseLogView(logView.id))}
|
||||
draggable
|
||||
onDragStart={(e) => onTabDragStart(e, logView.id)}
|
||||
onDragEnd={onTabDragEnd}
|
||||
onDragStart={(event) => {
|
||||
handleTabDragThresholdDragStart(event);
|
||||
onTabDragStart(event, logView.id);
|
||||
}}
|
||||
onDrag={(event) => {
|
||||
if (!handleTabDragThresholdDrag(event)) return;
|
||||
onTabDragStart(event, logView.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
handleTabDragThresholdRelease(event, handleClick);
|
||||
onTabDragEnd();
|
||||
}}
|
||||
onDragOver={(e) => onTabDragOver(e, logView.id)}
|
||||
onDragLeave={onTabDragLeave}
|
||||
onDrop={(e) => onTabDrop(e, logView.id)}
|
||||
|
||||
+152
-1
@@ -4,21 +4,32 @@ import type React from "react";
|
||||
|
||||
import {
|
||||
MIDDLE_MOUSE_BUTTON,
|
||||
TAB_DRAG_THRESHOLD_PX,
|
||||
handleTabCloseButtonMouseDown,
|
||||
handleTabDragThresholdDrag,
|
||||
handleTabDragThresholdDragStart,
|
||||
handleTabDragThresholdMouseDown,
|
||||
handleTabDragThresholdRelease,
|
||||
handleTabDragThresholdReset,
|
||||
isTabDragReorderStarted,
|
||||
handleTabMiddleClickClose,
|
||||
handleTabMiddleMouseDown,
|
||||
} from "./tabInteractions.ts";
|
||||
|
||||
interface FakeMouseEvent {
|
||||
button: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
preventDefault: () => void;
|
||||
stopPropagation: () => void;
|
||||
}
|
||||
|
||||
const makeEvent = (button: number) => {
|
||||
const makeEvent = (button: number, clientX = 0, clientY = 0) => {
|
||||
const calls = { preventDefault: 0, stopPropagation: 0 };
|
||||
const event = {
|
||||
button,
|
||||
clientX,
|
||||
clientY,
|
||||
preventDefault: () => {
|
||||
calls.preventDefault++;
|
||||
},
|
||||
@@ -29,6 +40,12 @@ const makeEvent = (button: number) => {
|
||||
return { event: event as unknown as React.MouseEvent, calls };
|
||||
};
|
||||
|
||||
const beginPrimaryPress = (clientX: number, clientY: number) => {
|
||||
const press = makeEvent(0, clientX, clientY);
|
||||
handleTabDragThresholdMouseDown(press.event);
|
||||
return press;
|
||||
};
|
||||
|
||||
test("handleTabMiddleClickClose closes the tab on a middle click", () => {
|
||||
let closed = 0;
|
||||
const { event, calls } = makeEvent(MIDDLE_MOUSE_BUTTON);
|
||||
@@ -66,6 +83,140 @@ test("handleTabMiddleMouseDown suppresses autoscroll only for the middle button"
|
||||
assert.equal(left.calls.preventDefault, 0);
|
||||
});
|
||||
|
||||
test("sub-threshold dragstart does not cancel the gesture, and mouseup activates", () => {
|
||||
handleTabDragThresholdReset();
|
||||
for (const distance of [0, TAB_DRAG_THRESHOLD_PX - 1]) {
|
||||
const press = beginPrimaryPress(10, 20);
|
||||
assert.equal(press.calls.preventDefault, 0, "primary mousedown must still allow a later drag");
|
||||
|
||||
const drag = makeEvent(0, 10 + distance, 20);
|
||||
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
|
||||
assert.equal(skipReorder, true, `distance ${distance} must not start a reorder`);
|
||||
assert.equal(isTabDragReorderStarted(), false, `distance ${distance} must not arm reorder chrome`);
|
||||
assert.equal(drag.calls.preventDefault, 0, `distance ${distance} must not preventDefault`);
|
||||
|
||||
let activated = 0;
|
||||
const release = makeEvent(0, 10 + distance, 20);
|
||||
handleTabDragThresholdRelease(release.event, () => {
|
||||
activated++;
|
||||
});
|
||||
assert.equal(activated, 1, `distance ${distance} must activate on mouseup`);
|
||||
assert.equal(release.calls.preventDefault, 0);
|
||||
}
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("a sub-threshold release activates only once when mouseup and dragend both fire", () => {
|
||||
handleTabDragThresholdReset();
|
||||
beginPrimaryPress(4, 4);
|
||||
const drag = makeEvent(0, 6, 4);
|
||||
assert.equal(handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent), true);
|
||||
|
||||
let activated = 0;
|
||||
const activate = () => {
|
||||
activated++;
|
||||
};
|
||||
handleTabDragThresholdRelease(makeEvent(0, 6, 4).event, activate);
|
||||
handleTabDragThresholdRelease(makeEvent(0, 6, 4).event, activate);
|
||||
assert.equal(activated, 1);
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("mouseup without dragstart does not activate", () => {
|
||||
handleTabDragThresholdReset();
|
||||
beginPrimaryPress(1, 1);
|
||||
let activated = 0;
|
||||
handleTabDragThresholdRelease(makeEvent(0, 2, 1).event, () => {
|
||||
activated++;
|
||||
});
|
||||
assert.equal(activated, 0);
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("drags at or above the threshold still reorder and do not activate on release", () => {
|
||||
handleTabDragThresholdReset();
|
||||
for (const distance of [TAB_DRAG_THRESHOLD_PX, TAB_DRAG_THRESHOLD_PX + 12]) {
|
||||
beginPrimaryPress(5, 8);
|
||||
const drag = makeEvent(0, 5 + distance, 8);
|
||||
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
|
||||
assert.equal(skipReorder, false, `distance ${distance} must keep the reorder drag`);
|
||||
assert.equal(isTabDragReorderStarted(), true, `distance ${distance} must arm reorder chrome`);
|
||||
assert.equal(drag.calls.preventDefault, 0, `distance ${distance} must not preventDefault`);
|
||||
assert.equal(handleTabDragThresholdDrag(drag.event as unknown as React.DragEvent), false);
|
||||
|
||||
let activated = 0;
|
||||
handleTabDragThresholdRelease(makeEvent(0, 5 + distance, 8).event, () => {
|
||||
activated++;
|
||||
});
|
||||
assert.equal(activated, 0, `distance ${distance} must not fake-activate`);
|
||||
}
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("a slow drag that starts under the threshold reorders once it crosses it", () => {
|
||||
handleTabDragThresholdReset();
|
||||
beginPrimaryPress(0, 0);
|
||||
const dragStart = makeEvent(0, TAB_DRAG_THRESHOLD_PX - 1, 0);
|
||||
assert.equal(
|
||||
handleTabDragThresholdDragStart(dragStart.event as unknown as React.DragEvent),
|
||||
true,
|
||||
);
|
||||
assert.equal(dragStart.calls.preventDefault, 0);
|
||||
|
||||
const stillShort = makeEvent(0, TAB_DRAG_THRESHOLD_PX - 1, 0);
|
||||
assert.equal(handleTabDragThresholdDrag(stillShort.event as unknown as React.DragEvent), false);
|
||||
|
||||
const crossed = makeEvent(0, TAB_DRAG_THRESHOLD_PX, 0);
|
||||
assert.equal(isTabDragReorderStarted(), false);
|
||||
assert.equal(handleTabDragThresholdDrag(crossed.event as unknown as React.DragEvent), true);
|
||||
assert.equal(isTabDragReorderStarted(), true);
|
||||
assert.equal(handleTabDragThresholdDrag(crossed.event as unknown as React.DragEvent), false);
|
||||
|
||||
let activated = 0;
|
||||
handleTabDragThresholdRelease(makeEvent(0, 1, 0).event, () => {
|
||||
activated++;
|
||||
});
|
||||
assert.equal(activated, 0, "returning near the origin after a real drag must not activate");
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("handleTabDragThreshold ignores non-primary buttons", () => {
|
||||
handleTabDragThresholdReset();
|
||||
for (const button of [MIDDLE_MOUSE_BUTTON, 2]) {
|
||||
const press = makeEvent(button, 4, 6);
|
||||
handleTabDragThresholdMouseDown(press.event);
|
||||
assert.equal(
|
||||
press.calls.preventDefault,
|
||||
button === MIDDLE_MOUSE_BUTTON ? 1 : 0,
|
||||
`button ${button} mousedown preventDefault count`,
|
||||
);
|
||||
|
||||
const drag = makeEvent(button, 4, 6);
|
||||
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
|
||||
assert.equal(skipReorder, false, `button ${button} must not skip dragstart`);
|
||||
assert.equal(isTabDragReorderStarted(), true, `button ${button} must still arm reorder chrome`);
|
||||
assert.equal(drag.calls.preventDefault, 0, `button ${button} dragstart must not preventDefault`);
|
||||
}
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("handleTabDragThresholdReset drops a press so the next drag is not judged from stale coordinates", () => {
|
||||
handleTabDragThresholdReset();
|
||||
beginPrimaryPress(0, 0);
|
||||
handleTabDragThresholdReset();
|
||||
|
||||
const drag = makeEvent(0, 1, 0);
|
||||
const skipReorder = handleTabDragThresholdDragStart(drag.event as unknown as React.DragEvent);
|
||||
assert.equal(skipReorder, false);
|
||||
assert.equal(drag.calls.preventDefault, 0);
|
||||
|
||||
beginPrimaryPress(0, 0);
|
||||
const next = makeEvent(0, 1, 0);
|
||||
assert.equal(handleTabDragThresholdDragStart(next.event as unknown as React.DragEvent), true);
|
||||
assert.equal(next.calls.preventDefault, 0);
|
||||
handleTabDragThresholdReset();
|
||||
});
|
||||
|
||||
test("handleTabCloseButtonMouseDown blocks ancestor drag only for the primary button", () => {
|
||||
const left = makeEvent(0);
|
||||
handleTabCloseButtonMouseDown(left.event);
|
||||
|
||||
@@ -32,6 +32,101 @@ export const handleTabCloseButtonMouseDown = (e: React.MouseEvent): void => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* Pointer travel below this is a click, not a tab reorder. Chromium can fire
|
||||
* `dragstart` after about 2px on a `draggable` tab and cancel the click that
|
||||
* would activate it (#3886).
|
||||
*/
|
||||
export const TAB_DRAG_THRESHOLD_PX = 4;
|
||||
|
||||
type TabPress = {
|
||||
x: number;
|
||||
y: number;
|
||||
dragStartFired: boolean;
|
||||
reorderStarted: boolean;
|
||||
};
|
||||
|
||||
let tabPress: TabPress | null = null;
|
||||
|
||||
const pointerDistanceFrom = (press: TabPress, x: number, y: number): number =>
|
||||
Math.hypot(x - press.x, y - press.y);
|
||||
|
||||
const pointerDistance = (x: number, y: number): number => {
|
||||
if (!tabPress) return Number.POSITIVE_INFINITY;
|
||||
return pointerDistanceFrom(tabPress, x, y);
|
||||
};
|
||||
|
||||
/**
|
||||
* Record a primary-button press on a draggable tab root, and keep middle-click
|
||||
* autoscroll suppression. Wire to the tab root `onMouseDown`. Non-primary
|
||||
* buttons are not tracked, so they cannot cancel a later drag.
|
||||
*/
|
||||
export const handleTabDragThresholdMouseDown = (e: React.MouseEvent): void => {
|
||||
handleTabMiddleMouseDown(e);
|
||||
if (e.button !== 0) return;
|
||||
tabPress = { x: e.clientX, y: e.clientY, dragStartFired: false, reorderStarted: false };
|
||||
};
|
||||
|
||||
/**
|
||||
* Note that `dragstart` fired. Never `preventDefault` here: cancelling the
|
||||
* only `dragstart` aborts the HTML drag, so a slow reorder cannot continue
|
||||
* (#3886). Returns true when travel is still below the reorder threshold.
|
||||
* Callers still invoke `onTabDragStart` so the drag payload can be written
|
||||
* while DataTransfer is writable; reorder chrome waits for
|
||||
* `isTabDragReorderStarted`.
|
||||
*/
|
||||
export const handleTabDragThresholdDragStart = (e: React.DragEvent): boolean => {
|
||||
if (!tabPress) return false;
|
||||
tabPress.dragStartFired = true;
|
||||
if (pointerDistance(e.clientX, e.clientY) < TAB_DRAG_THRESHOLD_PX) return true;
|
||||
tabPress.reorderStarted = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** True when this gesture should show reorder chrome, or there is no tracked press. */
|
||||
export const isTabDragReorderStarted = (): boolean =>
|
||||
!tabPress || tabPress.reorderStarted;
|
||||
|
||||
/**
|
||||
* `dragstart` only fires once. Later travel arrives as `drag`. Returns true
|
||||
* the first time that travel reaches the reorder threshold.
|
||||
*/
|
||||
export const handleTabDragThresholdDrag = (e: React.DragEvent): boolean => {
|
||||
if (!tabPress?.dragStartFired || tabPress.reorderStarted) return false;
|
||||
if (pointerDistance(e.clientX, e.clientY) < TAB_DRAG_THRESHOLD_PX) return false;
|
||||
tabPress.reorderStarted = true;
|
||||
return true;
|
||||
};
|
||||
|
||||
type TabReleasePoint = {
|
||||
button?: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Activate when `dragstart` already cancelled the click, the pointer stayed
|
||||
* below the reorder threshold, and no reorder started. Wire to `mouseup` and
|
||||
* `dragend` (Chromium delivers `dragend` after an uncancelled drag). A second
|
||||
* call in the same gesture does not activate again.
|
||||
*/
|
||||
export const handleTabDragThresholdRelease = (
|
||||
event: TabReleasePoint,
|
||||
activate: () => void,
|
||||
): void => {
|
||||
const press = tabPress;
|
||||
tabPress = null;
|
||||
if (!press?.dragStartFired || press.reorderStarted) return;
|
||||
if (event.button != null && event.button !== 0) return;
|
||||
if (pointerDistanceFrom(press, event.clientX, event.clientY) >= TAB_DRAG_THRESHOLD_PX) return;
|
||||
activate();
|
||||
};
|
||||
|
||||
/** Drop a tracked press so the next gesture starts clean. */
|
||||
export const handleTabDragThresholdReset = (): void => {
|
||||
tabPress = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Close a tab when it is middle-clicked. Wire to `onAuxClick`, which fires for
|
||||
* a completed non-primary click. Left clicks (tab activation) and right clicks
|
||||
|
||||
Reference in new issue
Block a user