feat(saved-connections): enhance drag-and-drop functionality with pointer events

- Introduced pointer drag support in ConnectionItem and GroupNodeItem components, allowing for improved user interactions during drag-and-drop operations.
- Added new pointer event handlers for starting, moving, ending, and canceling pointer drags, enhancing the overall drag experience.
- Updated SavedConnections component to manage pointer drag state and resolve drop targets based on pointer position, improving accuracy in item placement.
- Refactored TabBar to support pointer-based dragging, ensuring a consistent experience across components.
This commit is contained in:
Kang committed 2026-06-25 15:10:24 +08:00
1 parent e62561feb9
commit d068313e5f
5 files changed
+349 -28

No files matched your search

@@ -162,6 +162,7 @@ function ConnectionDetailsTooltip({ conn, t }: ConnectionDetailsTooltipProps) {
export default function ConnectionItem({ conn, indented, depth = 0 }: ConnectionItemProps) {
const {
isDragEnabled,
isPointerDragEnabled,
dragTarget,
selectedConnectionIds,
handleConnect,
@@ -180,6 +181,10 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
handleDragOverItem,
handleDragLeaveItem,
handleDropItem,
handlePointerDragStart,
handlePointerDragMove,
handlePointerDragEnd,
handlePointerDragCancel,
t,
} = useSavedConnectionsContext();
@@ -242,9 +247,22 @@ export default function ConnectionItem({ conn, indented, depth = 0 }: Connection
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-saved-drop-type="connection"
data-saved-drop-id={conn.id}
className="relative min-w-full w-max"
draggable={isDragEnabled}
draggable={isDragEnabled && !isPointerDragEnabled}
onWheel={closeAndSuppressDetails}
onPointerDown={
isPointerDragEnabled
? (e) => {
closeAndSuppressDetails();
handlePointerDragStart(e, "connection", conn.id);
}
: undefined
}
onPointerMove={isPointerDragEnabled ? handlePointerDragMove : undefined}
onPointerUp={isPointerDragEnabled ? handlePointerDragEnd : undefined}
onPointerCancel={isPointerDragEnabled ? handlePointerDragCancel : undefined}
onDragStart={
isDragEnabled
? (e) => {
@@ -27,6 +27,7 @@ interface GroupNodeItemProps {
export default function GroupNodeItem({ node, depth }: GroupNodeItemProps) {
const {
isDragEnabled,
isPointerDragEnabled,
dragTarget,
expandedGroups,
toggleGroup,
@@ -41,6 +42,10 @@ export default function GroupNodeItem({ node, depth }: GroupNodeItemProps) {
handleDragOverItem,
handleDragLeaveItem,
handleDropItem,
handlePointerDragStart,
handlePointerDragMove,
handlePointerDragEnd,
handlePointerDragCancel,
t,
} = useSavedConnectionsContext();
@@ -56,7 +61,15 @@ export default function GroupNodeItem({ node, depth }: GroupNodeItemProps) {
<ContextMenuTrigger asChild>
<div
className="relative"
draggable={isDragEnabled}
draggable={isDragEnabled && !isPointerDragEnabled}
onPointerDown={
isPointerDragEnabled
? (e) => handlePointerDragStart(e, "group", node.group.id)
: undefined
}
onPointerMove={isPointerDragEnabled ? handlePointerDragMove : undefined}
onPointerUp={isPointerDragEnabled ? handlePointerDragEnd : undefined}
onPointerCancel={isPointerDragEnabled ? handlePointerDragCancel : undefined}
onDragStart={
isDragEnabled ? (e) => handleDragStart(e, "group", node.group.id) : undefined
}
@@ -70,6 +83,8 @@ export default function GroupNodeItem({ node, depth }: GroupNodeItemProps) {
)}
<div
data-group-header
data-saved-drop-type="group"
data-saved-drop-id={node.group.id}
className={`flex items-center gap-1.5 py-1.5 px-2 rounded cursor-pointer transition-colors select-none df-hover ${isInside ? "ring-1 ring-primary/60 bg-primary/10" : ""}`}
style={{ paddingLeft: indentPx }}
onClick={() => toggleGroup(node.group.id)}
@@ -1,5 +1,5 @@
import type { TFunction } from "i18next";
import type { MouseEvent as ReactMouseEvent } from "react";
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { createContext, useContext } from "react";
import type { NewSessionTarget } from "@/lib/windowManager";
import type { Group, SavedConnection } from "@/types/global";
@@ -29,6 +29,7 @@ export const naturalCompare = (a: string, b: string) =>
export interface SavedConnectionsContextValue {
// UI state
isDragEnabled: boolean;
isPointerDragEnabled: boolean;
dragTarget: DragTarget | null;
expandedGroups: Set<string>;
selectedConnectionIds: Set<string>;
@@ -68,6 +69,10 @@ export interface SavedConnectionsContextValue {
id: string,
tgtType: "connection" | "group",
) => Promise<void>;
handlePointerDragStart: (e: ReactPointerEvent, type: "connection" | "group", id: string) => void;
handlePointerDragMove: (e: ReactPointerEvent) => void;
handlePointerDragEnd: (e: ReactPointerEvent) => void;
handlePointerDragCancel: (e: ReactPointerEvent) => void;
t: TFunction;
}
+193 -15
View File
@@ -71,6 +71,21 @@ type HeaderActionButtonProps = ComponentProps<typeof Button> & {
};
const SAVED_CONNECTIONS_DRAG_MIME = "application/x-nyaterm-saved-connections";
const POINTER_SAVED_DRAG_THRESHOLD_PX = 4;
interface PointerSavedDragState {
type: "connection" | "group";
id: string;
pointerId: number;
startX: number;
startY: number;
dragging: boolean;
}
function shouldUsePointerSavedConnectionsDrag() {
if (typeof navigator === "undefined") return false;
return /Mac/.test(navigator.platform) && /AppleWebKit/.test(navigator.userAgent);
}
function HeaderActionButton({ tooltip, children, ...props }: HeaderActionButtonProps) {
return (
@@ -138,6 +153,7 @@ export default function SavedConnections({
const [dragTarget, _setDragTarget] = useState<DragTarget | null>(null);
const dragTargetRef = useRef<DragTarget | null>(null);
const dragSourceRef = useRef<{ type: "connection" | "group"; id: string } | null>(null);
const pointerDragRef = useRef<PointerSavedDragState | null>(null);
const connectionsRef = useRef(savedConnections);
const groupsRef = useRef(savedGroups);
connectionsRef.current = savedConnections;
@@ -145,6 +161,7 @@ export default function SavedConnections({
const keyword = filterText.trim().toLowerCase();
const isDragEnabled = sortMode === "default";
const isPointerDragEnabled = isDragEnabled && shouldUsePointerSavedConnectionsDrag();
const connectionById = useMemo(
() => new Map(savedConnections.map((connection) => [connection.id, connection])),
[savedConnections],
@@ -766,6 +783,64 @@ export default function SavedConnections({
return y < rect.height * 0.5 ? "before" : "after";
};
const computeDropPositionFromPoint = (
element: HTMLElement,
clientY: number,
itemType: "connection" | "group",
srcType: "connection" | "group",
): DragTarget["position"] => {
const rect = element.getBoundingClientRect();
const y = clientY - rect.top;
if (itemType === "group") {
if (srcType === "connection") return "inside";
if (y < rect.height * 0.25) return "before";
if (y > rect.height * 0.75) return "after";
return "inside";
}
return y < rect.height * 0.5 ? "before" : "after";
};
const resolvePointerItemTarget = (
clientX: number,
clientY: number,
source: { type: "connection" | "group"; id: string },
): DragTarget | null => {
const elements = document.elementsFromPoint(clientX, clientY);
for (const element of elements) {
const target = element.closest<HTMLElement>("[data-saved-drop-type][data-saved-drop-id]");
if (!target) continue;
const type = target.dataset.savedDropType;
const id = target.dataset.savedDropId;
if ((type !== "connection" && type !== "group") || !id) continue;
if (!canDropOnItem(source, id, type)) return null;
return {
id,
type,
position: computeDropPositionFromPoint(target, clientY, type, source.type),
};
}
const background = panelRootRef.current?.querySelector<HTMLElement>("[data-saved-drop-bg]");
if (!background) return null;
const rect = background.getBoundingClientRect();
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
return null;
}
const isAtRoot =
source.type === "connection"
? !connectionsRef.current.find((c) => c.id === source.id)?.group_id
: !groupsRef.current.find((g) => g.id === source.id)?.parent_id;
return isAtRoot ? null : { id: null, type: "background", position: "inside" };
};
const handleDragStart = (e: React.DragEvent, type: "connection" | "group", id: string) => {
e.stopPropagation();
// WebKit-based runtimes may ignore HTML5 drops unless the drag carries real data.
@@ -780,6 +855,89 @@ export default function SavedConnections({
dragSourceRef.current = null;
};
const handlePointerDragStart = (
e: React.PointerEvent,
type: "connection" | "group",
id: string,
) => {
if (!isPointerDragEnabled) return;
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) return;
pointerDragRef.current = {
type,
id,
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
dragging: false,
};
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerDragMove = (e: React.PointerEvent) => {
if (!isPointerDragEnabled) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== e.pointerId) return;
const moved =
Math.abs(e.clientX - state.startX) >= POINTER_SAVED_DRAG_THRESHOLD_PX ||
Math.abs(e.clientY - state.startY) >= POINTER_SAVED_DRAG_THRESHOLD_PX;
if (!state.dragging) {
if (!moved) return;
state.dragging = true;
dragSourceRef.current = { type: state.type, id: state.id };
}
const source = { type: state.type, id: state.id } as const;
const target = resolvePointerItemTarget(e.clientX, e.clientY, source);
const prev = dragTargetRef.current;
if (
prev?.id !== target?.id ||
prev?.type !== target?.type ||
prev?.position !== target?.position
) {
setDragTarget(target);
}
e.preventDefault();
};
const handlePointerDragEnd = (e: React.PointerEvent) => {
if (!isPointerDragEnabled) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== e.pointerId) return;
pointerDragRef.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
const source = { type: state.type, id: state.id } as const;
const target = state.dragging ? resolvePointerItemTarget(e.clientX, e.clientY, source) : null;
setDragTarget(null);
dragSourceRef.current = null;
if ((target?.type === "connection" || target?.type === "group") && target.id) {
void submitItemDrop(source, target.id, target.type, target.position);
} else if (target?.type === "background") {
void dropSourceToRoot(source);
}
if (state.dragging) e.preventDefault();
};
const handlePointerDragCancel = (e: React.PointerEvent) => {
if (!isPointerDragEnabled) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== e.pointerId) return;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
pointerDragRef.current = null;
dragSourceRef.current = null;
setDragTarget(null);
};
const updateItemDragTarget = (e: React.DragEvent, id: string, type: "connection" | "group") => {
e.preventDefault();
e.stopPropagation();
@@ -811,22 +969,17 @@ export default function SavedConnections({
if (cur?.id === id && cur.type === type) setDragTarget(null);
};
const handleDropItem = async (
e: React.DragEvent,
const submitItemDrop = async (
source: { type: "connection" | "group"; id: string },
id: string,
tgtType: "connection" | "group",
position: DragTarget["position"],
) => {
e.preventDefault();
e.stopPropagation();
const source = resolveDragSource(e.dataTransfer);
setDragTarget(null);
dragSourceRef.current = null;
if (!source || !canDropOnItem(source, id, tgtType)) return;
if (!canDropOnItem(source, id, tgtType)) return;
const connections = connectionsRef.current;
const groups = groupsRef.current;
const { id: srcId, type: srcType } = source;
const position = computeDropPosition(e, tgtType, srcType);
try {
if (position === "inside" && tgtType === "group") {
@@ -933,6 +1086,21 @@ export default function SavedConnections({
}
};
const handleDropItem = async (
e: React.DragEvent,
id: string,
tgtType: "connection" | "group",
) => {
e.preventDefault();
e.stopPropagation();
const source = resolveDragSource(e.dataTransfer);
setDragTarget(null);
dragSourceRef.current = null;
if (!source || !canDropOnItem(source, id, tgtType)) return;
await submitItemDrop(source, id, tgtType, computeDropPosition(e, tgtType, source.type));
};
const handleDragOverBg = (e: React.DragEvent) => {
e.preventDefault();
const source = resolveDragSource(e.dataTransfer);
@@ -951,12 +1119,7 @@ export default function SavedConnections({
setDragTarget({ id: null, type: "background", position: "inside" });
};
const handleDropBg = async (e: React.DragEvent) => {
e.preventDefault();
const source = resolveDragSource(e.dataTransfer);
setDragTarget(null);
dragSourceRef.current = null;
if (!source) return;
async function dropSourceToRoot(source: { type: "connection" | "group"; id: string }) {
try {
if (source.type === "connection") {
const conn = connectionsRef.current.find((c) => c.id === source.id);
@@ -995,6 +1158,15 @@ export default function SavedConnections({
error: err,
});
}
}
const handleDropBg = async (e: React.DragEvent) => {
e.preventDefault();
const source = resolveDragSource(e.dataTransfer);
setDragTarget(null);
dragSourceRef.current = null;
if (!source) return;
await dropSourceToRoot(source);
};
// ── Sort button helpers ───────────────────────────────────────────────────
@@ -1015,6 +1187,7 @@ export default function SavedConnections({
// ── Context value ─────────────────────────────────────────────────────────
const ctxValue: SavedConnectionsContextValue = {
isDragEnabled,
isPointerDragEnabled,
dragTarget,
expandedGroups,
selectedConnectionIds,
@@ -1040,6 +1213,10 @@ export default function SavedConnections({
handleDragOverItem,
handleDragLeaveItem,
handleDropItem,
handlePointerDragStart,
handlePointerDragMove,
handlePointerDragEnd,
handlePointerDragCancel,
t,
};
@@ -1169,6 +1346,7 @@ export default function SavedConnections({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
data-saved-drop-bg
className={`flex-1 overflow-x-auto overflow-y-auto p-1.5 text-xs space-y-0.5 terminal-scroll ${dragTarget?.type === "background" ? "ring-inset ring-2 ring-primary/20" : ""}`}
onMouseDown={(event) => {
if (event.button !== 0 || event.target !== event.currentTarget) return;
+115 -10
View File
@@ -3,6 +3,7 @@ import {
type DragEvent,
type MouseEvent,
memo,
type PointerEvent,
useCallback,
useLayoutEffect,
useMemo,
@@ -92,6 +93,20 @@ interface ConnectionGroupNode {
const TAB_STRIP_SCROLL_DURATION_MS = 180;
const TAB_STRIP_SCROLL_PADDING = 12;
const POINTER_TAB_DRAG_THRESHOLD_PX = 4;
interface PointerTabDragState {
tabId: string;
pointerId: number;
startX: number;
startY: number;
dragging: boolean;
}
function shouldUsePointerTabDrag() {
if (typeof navigator === "undefined") return false;
return /Mac/.test(navigator.platform) && /AppleWebKit/.test(navigator.userAgent);
}
function easeOutCubic(progress: number): number {
return 1 - (1 - progress) ** 3;
@@ -264,8 +279,12 @@ function TabBar({
const tabButtonRefs = useRef(new Map<string, HTMLDivElement>());
const draggedTabIdRef = useRef<string | null>(null);
const droppedTabRef = useRef(false);
const completedDropRef = useRef(false);
const pointerDragRef = useRef<PointerTabDragState | null>(null);
const suppressTabClickRef = useRef(false);
const tabStripAnimatingRef = useRef(false);
const tabStripScrollAnimationRef = useRef<(() => void) | null>(null);
const usePointerTabDrag = shouldUsePointerTabDrag();
const [tabStripScroll, setTabStripScroll] = useState({
hasOverflow: false,
});
@@ -733,6 +752,7 @@ function TabBar({
if (!effectiveTabId) return;
droppedTabRef.current = true;
completedDropRef.current = true;
const fromIndex = tabs.findIndex((tab) => tab.id === effectiveTabId);
if (fromIndex === -1) {
if (onMoveTabHere) {
@@ -746,6 +766,11 @@ function TabBar({
}
const nextIndex = insertionIndex > fromIndex ? insertionIndex - 1 : insertionIndex;
if (nextIndex === fromIndex) {
resetDragState();
return;
}
onReorderTabs(effectiveTabId, nextIndex);
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent("nyaterm:refresh-terminals"));
@@ -761,16 +786,89 @@ function TabBar({
event.dataTransfer.setData("application/nyaterm-tab", tabId);
draggedTabIdRef.current = tabId;
droppedTabRef.current = false;
completedDropRef.current = false;
setDraggedTabId(tabId);
setDropIndex(tabs.findIndex((tab) => tab.id === tabId));
};
const handleDragEnd = (event: DragEvent<HTMLDivElement>) => {
if (droppedTabRef.current) {
const handlePointerDown = (event: PointerEvent<HTMLDivElement>, tabId: string) => {
if (!usePointerTabDrag) return;
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) {
return;
}
pointerDragRef.current = {
tabId,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
dragging: false,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!usePointerTabDrag) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== event.pointerId) return;
const moved =
Math.abs(event.clientX - state.startX) >= POINTER_TAB_DRAG_THRESHOLD_PX ||
Math.abs(event.clientY - state.startY) >= POINTER_TAB_DRAG_THRESHOLD_PX;
if (!state.dragging) {
if (!moved) return;
state.dragging = true;
suppressTabClickRef.current = true;
draggedTabIdRef.current = state.tabId;
droppedTabRef.current = false;
setDraggedTabId(state.tabId);
}
setDropIndex(getInsertionIndexFromClientX(event.clientX));
event.preventDefault();
};
const handlePointerEnd = (event: PointerEvent<HTMLDivElement>) => {
if (!usePointerTabDrag) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== event.pointerId) return;
pointerDragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
if (state.dragging && isDragEndFallbackNearTabStrip(event.clientX, event.clientY)) {
handleDropAtIndex(getInsertionIndexFromClientX(event.clientX));
} else {
resetDragState();
}
event.preventDefault();
window.setTimeout(() => {
suppressTabClickRef.current = false;
}, 0);
};
const handlePointerCancel = (event: PointerEvent<HTMLDivElement>) => {
if (!usePointerTabDrag) return;
const state = pointerDragRef.current;
if (!state || state.pointerId !== event.pointerId) return;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
pointerDragRef.current = null;
resetDragState();
window.setTimeout(() => {
suppressTabClickRef.current = false;
}, 0);
};
const handleDragEnd = (event: DragEvent<HTMLDivElement>) => {
if (droppedTabRef.current || completedDropRef.current) {
completedDropRef.current = false;
resetDragState();
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent("nyaterm:refresh-terminals"));
});
return;
}
@@ -781,9 +879,6 @@ function TabBar({
}
resetDragState();
requestAnimationFrame(() => {
window.dispatchEvent(new CustomEvent("nyaterm:refresh-terminals"));
});
};
const renderTabIcon = (tab: Tab) => {
@@ -944,7 +1039,7 @@ function TabBar({
const tabButton = (
<div
draggable
draggable={!usePointerTabDrag}
className={`group relative flex items-center gap-2 border-r pl-3 pr-2 text-xs transition-[color,background-color,opacity] duration-200 ${
isActive ? "font-semibold" : "font-medium df-hover"
} ${draggedTabId === tab.id ? "opacity-60" : ""}`}
@@ -959,7 +1054,13 @@ function TabBar({
: "transparent",
color: isActive ? "var(--df-text)" : "var(--df-text-muted)",
}}
onClick={() => onTabChange(tab.id)}
onClick={() => {
if (suppressTabClickRef.current) {
suppressTabClickRef.current = false;
return;
}
onTabChange(tab.id);
}}
onDoubleClick={(event) => {
handleConfiguredTabMouseAction(event, tab, doubleClickAction);
}}
@@ -982,6 +1083,10 @@ function TabBar({
handleConfiguredTabMouseAction(event, tab, rightClickAction);
}}
onPointerDown={(event) => handlePointerDown(event, tab.id)}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerEnd}
onPointerCancel={handlePointerCancel}
onDragStart={(event) => handleDragStart(event, tab.id)}
onDragEnd={(event) => {
handleDragEnd(event);