🐛 fix(sidebar): 优化 Host 拖入分组的命中与反馈

- 扩大分组中心放入热区并保留边缘排序
- 增加整行目标高亮、紧凑预览与悬停展开
- 覆盖拖拽意图、DOM 命中和移动目标回归

Refs #877
This commit is contained in:
Syngnat
2026-08-08 12:31:21 +08:00
parent 9e661958fe
commit a9595f6fb0
5 changed files with 696 additions and 19 deletions

View File

@@ -25,9 +25,12 @@ import Sidebar, {
resolveV2CommandSearchPersistentFilter,
type V2CommandSearchItem,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropDomHit,
resolveSidebarHostGroupDropDestination,
resolveSidebarTagDropInsertBefore,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarDropInsertBefore,
resolveSidebarTreeDropPlacement,
resolveSidebarNodeConnectionId,
resolveSidebarSwitcherLoadKey,
resolveV2ActiveConnectionId,
@@ -1353,6 +1356,104 @@ describe('Sidebar locate toolbar', () => {
})).toBe(false);
});
it('makes the group row the primary drop target when moving a Host into a group', () => {
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'connection',
dropNodeType: 'tag',
relativeDropPosition: -1,
dropToGap: true,
fallbackInsertBefore: true,
})).toBe('inside');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'connection',
dropNodeType: 'tag',
relativeDropPosition: 1,
dropToGap: true,
fallbackInsertBefore: false,
})).toBe('inside');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'connection',
dropNodeType: 'tag',
relativeDropPosition: 1,
dropToGap: true,
fallbackInsertBefore: false,
metrics: { clientY: 115, top: 100, height: 30 },
})).toBe('inside');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'connection',
dropNodeType: 'tag',
relativeDropPosition: 0,
dropToGap: false,
fallbackInsertBefore: false,
metrics: { clientY: 102, top: 100, height: 30 },
})).toBe('before');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'connection',
dropNodeType: 'tag',
relativeDropPosition: 0,
dropToGap: false,
fallbackInsertBefore: true,
metrics: { clientY: 128, top: 100, height: 30 },
})).toBe('after');
});
it('preserves explicit before and after gaps when dragging groups or reordering Hosts', () => {
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'tag',
dropNodeType: 'tag',
relativeDropPosition: -1,
dropToGap: true,
fallbackInsertBefore: true,
})).toBe('before');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'tag',
dropNodeType: 'tag',
relativeDropPosition: 1,
dropToGap: true,
fallbackInsertBefore: false,
})).toBe('after');
expect(resolveSidebarTreeDropPlacement({
dragNodeType: 'tag',
dropNodeType: 'tag',
relativeDropPosition: 0,
dropToGap: false,
fallbackInsertBefore: false,
})).toBe('inside');
});
it('maps Host group drop intent to stable moveConnectionToTag arguments', () => {
const common = {
targetTagId: 'child',
targetTagParentId: 'parent',
targetTagToken: 'tag:child',
};
expect(resolveSidebarHostGroupDropDestination({
...common,
placement: 'inside',
})).toEqual({
targetParentTagId: 'child',
targetToken: null,
insertBefore: false,
});
expect(resolveSidebarHostGroupDropDestination({
...common,
placement: 'before',
})).toEqual({
targetParentTagId: 'parent',
targetToken: 'tag:child',
insertBefore: true,
});
expect(resolveSidebarHostGroupDropDestination({
...common,
placement: 'after',
})).toEqual({
targetParentTagId: 'parent',
targetToken: 'tag:child',
insertBefore: false,
});
});
it('resolves sidebar drop node metadata from DOM markers', () => {
vi.stubGlobal('document', {
elementFromPoint: () => null,
@@ -1403,6 +1504,81 @@ describe('Sidebar locate toolbar', () => {
vi.unstubAllGlobals();
});
it('resolves sidebar drop metadata and row geometry from the same DOM hit', () => {
const elementFromPoint = vi.fn();
const treeNode = {
getAttribute: (name: string) => {
if (name === 'data-sidebar-node-key') return 'tag-prod';
if (name === 'data-sidebar-node-type') return 'tag';
return null;
},
querySelector: () => null,
getBoundingClientRect: () => ({ top: 96, height: 30 }),
};
const target = {
closest: (selector: string) => selector === '.ant-tree-treenode' ? treeNode : null,
};
elementFromPoint.mockReturnValue(target);
vi.stubGlobal('document', { elementFromPoint });
expect(resolveSidebarDropDomHit({ clientX: 80, clientY: 111 })).toEqual({
key: 'tag-prod',
type: 'tag',
metrics: { top: 96, height: 30 },
});
expect(elementFromPoint).toHaveBeenCalledTimes(1);
vi.unstubAllGlobals();
});
it('renders a clear whole-row group target for Host drops', () => {
const baseOptions = {
node: {
type: 'tag',
key: 'tag-prod',
title: '生产环境',
dataRef: { id: 'prod' },
},
hoverTitle: '生产环境',
statusBadge: null,
getV2TreeMetaText: () => '',
sidebarTableMetadataFields: [],
snapshotTreeSelectionBeforeDrag: vi.fn(),
restoreTreeSelectionAfterDrag: vi.fn(),
treeDragSelectSuppressUntilRef: { current: 0 },
setIsTreeDragging: vi.fn(),
};
const targetMarkup = renderToStaticMarkup(renderSidebarV2TreeTitle({
...baseOptions,
sidebarDropPlacement: 'inside',
}));
const idleMarkup = renderToStaticMarkup(renderSidebarV2TreeTitle(baseOptions));
expect(targetMarkup).toContain('is-connection-group');
expect(targetMarkup).toContain('is-drop-inside');
expect(targetMarkup).toContain('data-sidebar-drop-placement="inside"');
expect(idleMarkup).not.toContain('is-drop-inside');
});
it('uses V2-only capture DnD with a compact preview and stable whole-row states', () => {
const source = readSourceFile('./Sidebar.tsx');
const css = readV2ThemeCss();
expect(source).toContain('onDragOverCapture={handleSidebarTreeDragOverCapture}');
expect(source).toContain('onDropCapture={handleSidebarTreeDropCapture}');
expect(source).toContain('if (!isV2Ui) return null;');
expect(source).toContain('resolveSidebarDropDomHit(event)');
expect(source).toContain('resolveSidebarHostGroupDropDestination({');
expect(source).toContain('sidebarTreeDragPreviewElementRef.current = isV2Ui');
expect(source).toContain("&& sidebarTreeDragNodeRef.current?.type === 'connection'");
expect(source).toContain('dataTransfer.setDragImage(preview, 18, 15)');
expect(source).toContain('SIDEBAR_GROUP_HOVER_EXPAND_DELAY_MS = 500');
expect(css).toContain('.ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside)');
expect(css).toContain('.gn-v2-sidebar-tree-drag-preview');
expect(css).toContain('cursor: grabbing !important;');
expect(css).not.toContain('.gn-v2-tree-host-drop-hint');
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
});
it('treats centered tag drops as directional reordering instead of no-op', () => {
expect(resolveSidebarTagDropInsertBefore({
currentTagOrder: ['tag-dev', 'tag-test', 'tag-prod'],

View File

@@ -217,9 +217,12 @@ import {
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarConnectionRefreshKeys,
resolveSidebarDropDomHit,
resolveSidebarHostGroupDropDestination,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarTreeDropPlacement,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveSidebarSingleDatabaseExpandedKeys,
@@ -235,6 +238,7 @@ import {
shouldRunV2CommandSearchEnter,
sortSidebarTableEntries,
type SidebarConnectionState,
type SidebarTreeDropPlacement,
type SidebarTreeNode as TreeNode,
type V2CommandSearchItem,
} from './sidebarV2Utils';
@@ -256,9 +260,12 @@ export {
normalizeSidebarTreeRelativeDropPosition,
resolveSidebarConnectionIdFromKey,
resolveSidebarConnectionRefreshKeys,
resolveSidebarDropDomHit,
resolveSidebarHostGroupDropDestination,
resolveSidebarDropInsertBefore,
resolveSidebarDropNodeFromDomEvent,
resolveSidebarDropTargetMetricsFromDomEvent,
resolveSidebarTreeDropPlacement,
resolveSidebarDatabaseTreePruneKeys,
resolveSidebarNodeConnectionId,
resolveV2ActiveConnectionId,
@@ -271,7 +278,7 @@ export {
sortSidebarTableEntries,
};
export { resolveSidebarTagDropInsertBefore } from './sidebarV2Utils';
export type { V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils';
export type { SidebarDropDomHit, SidebarTreeDropPlacement, V2CommandSearchItem, V2RailConnectionGroup } from './sidebarV2Utils';
type SidebarTreeSwitcherNodeLike = {
key?: React.Key;
@@ -327,6 +334,50 @@ const SIDEBAR_LOCATE_LOAD_WAIT_INTERVAL_MS = 50;
const SIDEBAR_LOCATE_LOAD_WAIT_ATTEMPTS = 160;
const SIDEBAR_CACHED_DATABASE_TREE_LIMIT = 12;
const NACOS_SERVICES_CHANGED_EVENT = 'gonavi:nacos-services-changed';
const SIDEBAR_GROUP_HOVER_EXPAND_DELAY_MS = 500;
type SidebarTreeDragEventLike = {
dataTransfer?: DataTransfer | null;
target?: EventTarget | null;
};
const createSidebarTreeDragPreview = (
event: SidebarTreeDragEventLike,
node: Pick<TreeNode, 'title' | 'type'>,
): HTMLElement | null => {
if (typeof document === 'undefined' || !document.body || !event.dataTransfer) return null;
const preview = document.createElement('div');
preview.className = 'gn-v2-sidebar-tree-drag-preview';
preview.setAttribute('aria-hidden', 'true');
preview.setAttribute('data-node-type', String(node.type || ''));
const sourceRow = event.target && typeof (event.target as Element).closest === 'function'
? (event.target as Element).closest('.ant-tree-treenode')
: null;
const sourceIcon = sourceRow?.querySelector('.ant-tree-iconEle > *');
const icon = document.createElement('span');
icon.className = 'gn-v2-sidebar-tree-drag-preview-icon';
if (sourceIcon) {
icon.appendChild(sourceIcon.cloneNode(true));
}
preview.appendChild(icon);
const label = document.createElement('span');
label.className = 'gn-v2-sidebar-tree-drag-preview-label';
label.textContent = String(node.title || '');
preview.appendChild(label);
document.body.appendChild(preview);
try {
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setDragImage(preview, 18, 15);
} catch {
preview.remove();
return null;
}
return preview;
};
type NacosServiceRefreshTreeNode = {
key: React.Key;
@@ -1058,6 +1109,22 @@ const Sidebar: React.FC<{
// Connection Status State: key -> 'loading' | 'success' | 'error'
const [connectionStates, setConnectionStates] = useState<Record<string, SidebarConnectionState>>({});
const [isTreeDragging, setIsTreeDragging] = useState(false);
const [sidebarTreeDragNodeType, setSidebarTreeDragNodeType] = useState<string | null>(null);
const [sidebarTreeDropPreview, setSidebarTreeDropPreview] = useState<{
nodeKey: string;
placement: SidebarTreeDropPlacement;
} | null>(null);
const sidebarTreeDragNodeRef = useRef<TreeNode | null>(null);
const sidebarTreeDropPreviewRef = useRef<typeof sidebarTreeDropPreview>(null);
const sidebarTreeDragPreviewElementRef = useRef<HTMLElement | null>(null);
const sidebarGroupHoverExpandTimerRef = useRef<number | null>(null);
useEffect(() => () => {
if (sidebarGroupHoverExpandTimerRef.current !== null) {
window.clearTimeout(sidebarGroupHoverExpandTimerRef.current);
}
sidebarTreeDragPreviewElementRef.current?.remove();
}, []);
// Create Database Modal
const [isCreateDbModalOpen, setIsCreateDbModalOpen] = useState(false);
@@ -1201,6 +1268,8 @@ const Sidebar: React.FC<{
key: conn.id,
icon: getDbIcon(iconType, iconColor, 22),
type: 'connection',
'data-sidebar-node-key': conn.id,
'data-sidebar-node-type': 'connection',
dataRef: nacosNamespaceDiscoveryMode
? { ...conn, nacosNamespaceDiscoveryMode }
: conn,
@@ -1225,6 +1294,8 @@ const Sidebar: React.FC<{
</span>
),
type: 'tag',
'data-sidebar-node-key': `tag-${item.tag.id}`,
'data-sidebar-node-type': 'tag',
dataRef: item.tag,
isLeaf: false,
children: item.children.map(buildTreeNode),
@@ -1955,6 +2026,17 @@ const Sidebar: React.FC<{
};
const onExpand = (newExpandedKeys: React.Key[], info?: any) => {
// rc-tree auto-expands any loaded node after a drag hover. During a V2 Host
// move, group expansion is controlled by the explicit 500ms inside target;
// ignore rc-tree's competing expansion so connection resource rows do not
// unexpectedly open and move the target under the pointer.
if (
isV2Ui
&& isTreeDragging
&& sidebarTreeDragNodeRef.current?.type === 'connection'
) {
return;
}
if (!info?.expanded && shouldClearSidebarNodeChildrenOnCollapse(info?.node)) {
const collapsedKey = String(info.node?.key || '').trim();
const keysToClear = [
@@ -2957,9 +3039,13 @@ const Sidebar: React.FC<{
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
sidebarDropPlacement: sidebarTreeDropPreview?.nodeKey === String(node.key || '')
? sidebarTreeDropPreview.placement
: null,
}), [
restoreTreeSelectionAfterDrag,
setIsTreeDragging,
sidebarTreeDropPreview,
sidebarTableMetadataFields,
snapshotTreeSelectionBeforeDrag,
treeDragSelectSuppressUntilRef,
@@ -3183,6 +3269,127 @@ const Sidebar: React.FC<{
return null;
};
const clearSidebarGroupHoverExpandTimer = () => {
if (sidebarGroupHoverExpandTimerRef.current === null) return;
window.clearTimeout(sidebarGroupHoverExpandTimerRef.current);
sidebarGroupHoverExpandTimerRef.current = null;
};
const updateSidebarTreeDropPreview = (
nextPreview: { nodeKey: string; placement: SidebarTreeDropPlacement } | null,
) => {
const previousPreview = sidebarTreeDropPreviewRef.current;
if (
previousPreview?.nodeKey === nextPreview?.nodeKey
&& previousPreview?.placement === nextPreview?.placement
) {
return;
}
clearSidebarGroupHoverExpandTimer();
sidebarTreeDropPreviewRef.current = nextPreview;
setSidebarTreeDropPreview(nextPreview);
if (!nextPreview || nextPreview.placement !== 'inside') return;
if (expandedKeysRef.current.some((key) => String(key) === nextPreview.nodeKey)) return;
sidebarGroupHoverExpandTimerRef.current = window.setTimeout(() => {
sidebarGroupHoverExpandTimerRef.current = null;
const activePreview = sidebarTreeDropPreviewRef.current;
if (
activePreview?.nodeKey !== nextPreview.nodeKey
|| activePreview.placement !== 'inside'
) {
return;
}
setExpandedKeys((previous) => previous.some((key) => String(key) === nextPreview.nodeKey)
? previous
: [...previous, nextPreview.nodeKey]);
setAutoExpandParent(false);
}, SIDEBAR_GROUP_HOVER_EXPAND_DELAY_MS);
};
const clearSidebarTreeDragVisuals = () => {
clearSidebarGroupHoverExpandTimer();
sidebarTreeDropPreviewRef.current = null;
setSidebarTreeDropPreview(null);
sidebarTreeDragNodeRef.current = null;
setSidebarTreeDragNodeType(null);
sidebarTreeDragPreviewElementRef.current?.remove();
sidebarTreeDragPreviewElementRef.current = null;
setIsTreeDragging(false);
};
const resolveSidebarHostGroupDropAtEvent = (event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
}) => {
if (!isV2Ui) return null;
const dragNode = sidebarTreeDragNodeRef.current;
if (dragNode?.type !== 'connection') return null;
const hit = resolveSidebarDropDomHit(event);
if (!hit || hit.type !== 'tag') return null;
const dropNode = findTreeNodeByKeyRef.current(treeDataRef.current, hit.key);
if (!dropNode || dropNode.type !== 'tag') return null;
const placement = resolveSidebarTreeDropPlacement({
dragNodeType: dragNode.type,
dropNodeType: dropNode.type,
relativeDropPosition: 0,
dropToGap: undefined,
fallbackInsertBefore: false,
metrics: hit.metrics ? {
clientY: event.clientY,
top: hit.metrics.top,
height: hit.metrics.height,
} : null,
});
return { dragNode, dropNode, hit, placement };
};
const handleSidebarTreeDragOverCapture = (event: React.DragEvent<HTMLDivElement>) => {
const resolvedDrop = resolveSidebarHostGroupDropAtEvent(event);
if (!resolvedDrop) {
updateSidebarTreeDropPreview(null);
return;
}
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
updateSidebarTreeDropPreview({
nodeKey: resolvedDrop.hit.key,
placement: resolvedDrop.placement,
});
};
const handleSidebarTreeDropCapture = (event: React.DragEvent<HTMLDivElement>) => {
const resolvedDrop = resolveSidebarHostGroupDropAtEvent(event);
if (!resolvedDrop) return;
event.preventDefault();
event.stopPropagation();
const connectionId = String(resolvedDrop.dragNode.key || '').trim();
const tagId = String(resolvedDrop.dropNode?.dataRef?.id || '').trim();
if (connectionId && tagId) {
const destination = resolveSidebarHostGroupDropDestination({
targetTagId: tagId,
targetTagParentId: getTagParentId(tagId),
targetTagToken: getNodeOrderToken(resolvedDrop.dropNode),
placement: resolvedDrop.placement,
});
moveConnectionToTag(
connectionId,
destination.targetParentTagId,
destination.targetToken,
destination.insertBefore,
);
}
restoreTreeSelectionAfterDrag();
clearSidebarTreeDragVisuals();
};
const allowSidebarTreeDrop = ({ dragNode, dropNode, dropPosition }: any): boolean => {
if (!dragNode || !dropNode) return false;
if ((dragNode.type !== 'tag' && dragNode.type !== 'connection') || (dropNode.type !== 'tag' && dropNode.type !== 'connection')) {
@@ -3202,13 +3409,14 @@ const Sidebar: React.FC<{
};
const handleDrop = (info: any) => {
setIsTreeDragging(false);
clearSidebarTreeDragVisuals();
const dropPosition = normalizeSidebarTreeRelativeDropPosition(
Number(info.dropPosition || 0),
info?.node?.pos,
);
const domDropNode = resolveSidebarDropNodeFromDomEvent(info?.event);
const dropTargetMetrics = resolveSidebarDropTargetMetricsFromDomEvent(info?.event);
const domDropHit = resolveSidebarDropDomHit(info?.event);
const domDropNode = domDropHit ? { key: domDropHit.key, type: domDropHit.type } : null;
const dropTargetMetrics = domDropHit?.metrics || null;
const insertBefore = resolveSidebarDropInsertBefore(dropPosition, dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
@@ -3222,14 +3430,28 @@ const Sidebar: React.FC<{
: info.node);
if (!dragNode || !dropNode) return;
const droppingIntoTag = dropNode.type === 'tag' && (
info?.dropToGap === false || (info?.dropToGap === undefined && dropPosition === 0)
);
const placement: SidebarTreeDropPlacement = isV2Ui
? resolveSidebarTreeDropPlacement({
dragNodeType: dragNode.type,
dropNodeType: dropNode.type,
relativeDropPosition: dropPosition,
dropToGap: info?.dropToGap,
fallbackInsertBefore: insertBefore,
metrics: dropTargetMetrics ? {
clientY: info?.event?.clientY,
top: dropTargetMetrics.top,
height: dropTargetMetrics.height,
} : null,
})
: (dropNode.type === 'tag' && info?.dropToGap === false
? 'inside'
: (insertBefore ? 'before' : 'after'));
const droppingIntoTag = dropNode.type === 'tag' && placement === 'inside';
const targetParentTagId = droppingIntoTag
? String(dropNode?.dataRef?.id || '').trim() || null
: getNodeParentTagId(dropNode);
const targetToken = droppingIntoTag ? null : getNodeOrderToken(dropNode);
const targetInsertBefore = droppingIntoTag ? false : insertBefore;
const targetInsertBefore = droppingIntoTag ? false : placement === 'before';
if (dragNode.type === 'tag') {
const dragTagId = String(dragNode?.dataRef?.id || '').trim();
@@ -4069,9 +4291,18 @@ const Sidebar: React.FC<{
<div
ref={treeContainerRef}
className={`sidebar-tree-scroll-shell${isV2Ui ? ' gn-v2-explorer-tree-shell' : ''}${isTreeScrolling ? ' is-vertical-scrolling' : ''}`}
className={`sidebar-tree-scroll-shell${isV2Ui ? ' gn-v2-explorer-tree-shell' : ''}${isTreeScrolling ? ' is-vertical-scrolling' : ''}${sidebarTreeDragNodeType === 'connection' ? ' is-host-tree-dragging' : ''}${sidebarTreeDropPreview ? ' has-host-group-drop-preview' : ''}`}
onWheelCapture={handleTreeWheel}
onTouchMoveCapture={markTreeScrollActivity}
onDragEnterCapture={handleSidebarTreeDragOverCapture}
onDragOverCapture={handleSidebarTreeDragOverCapture}
onDropCapture={handleSidebarTreeDropCapture}
onDragLeaveCapture={(event) => {
const relatedTarget = event.relatedTarget as Node | null;
if (!relatedTarget || !event.currentTarget.contains(relatedTarget)) {
updateSidebarTreeDropPreview(null);
}
}}
style={{
flex: 1,
overflow: 'hidden',
@@ -4088,9 +4319,16 @@ const Sidebar: React.FC<{
nodeDraggable: (node: any) => node.type === 'connection' || node.type === 'tag'
}}
allowDrop={allowSidebarTreeDrop}
onDragStart={() => {
onDragStart={({ event, node }: any) => {
snapshotTreeSelectionBeforeDrag();
treeDragSelectSuppressUntilRef.current = Date.now() + 600;
sidebarTreeDragNodeRef.current = node;
setSidebarTreeDragNodeType(isV2Ui ? String(node?.type || '') || null : null);
if (isV2Ui) updateSidebarTreeDropPreview(null);
sidebarTreeDragPreviewElementRef.current?.remove();
sidebarTreeDragPreviewElementRef.current = isV2Ui
? createSidebarTreeDragPreview(event, node)
: null;
setIsTreeDragging(true);
}}
onDragEnter={() => {
@@ -4099,7 +4337,7 @@ const Sidebar: React.FC<{
}}
onDragEnd={() => {
restoreTreeSelectionAfterDrag();
setIsTreeDragging(false);
clearSidebarTreeDragVisuals();
}}
onDrop={handleDrop}
loadData={onLoadData}

View File

@@ -29,6 +29,7 @@ type SidebarV2TreeTitleOptions = {
restoreTreeSelectionAfterDrag: () => void;
treeDragSelectSuppressUntilRef: React.MutableRefObject<number>;
setIsTreeDragging: (dragging: boolean) => void;
sidebarDropPlacement?: 'before' | 'inside' | 'after' | null;
};
const SIDEBAR_TREE_NODE_CONTENT_SELECTOR = '.ant-tree-node-content-wrapper';
@@ -119,6 +120,7 @@ export const renderSidebarV2TreeTitle = ({
restoreTreeSelectionAfterDrag,
treeDragSelectSuppressUntilRef,
setIsTreeDragging,
sidebarDropPlacement,
}: SidebarV2TreeTitleOptions): React.ReactNode => {
const rawTitle = String(node.title ?? '');
const groupKey = String(node?.dataRef?.groupKey || '');
@@ -173,6 +175,8 @@ export const renderSidebarV2TreeTitle = ({
'gn-v2-tree-title',
isMono ? 'is-mono' : '',
node.type === 'object-group' ? 'is-group' : '',
node.type === 'tag' ? 'is-connection-group' : '',
sidebarDropPlacement ? `is-drop-${sidebarDropPlacement}` : '',
node.type === 'redis-db' ? 'is-redis-db' : '',
node.type === 'table' && node?.dataRef?.pinnedSidebarTable ? 'is-pinned-table' : '',
].filter(Boolean).join(' ');
@@ -219,6 +223,7 @@ export const renderSidebarV2TreeTitle = ({
data-group-key={groupKey || undefined}
data-sidebar-node-key={String(node.key || '')}
data-sidebar-node-type={String(node.type || '')}
data-sidebar-drop-placement={sidebarDropPlacement || undefined}
onPointerOverCapture={tableHoverInfo ? clearSidebarTableNativeHoverTitle : undefined}
onMouseOverCapture={tableHoverInfo ? clearSidebarTableNativeHoverTitle : undefined}
onDragStart={dragText ? (event) => {

View File

@@ -1127,6 +1127,96 @@ export const resolveSidebarDropInsertBefore = (
return clientY < (top + height / 2);
};
export type SidebarTreeDropPlacement = 'before' | 'inside' | 'after';
export type SidebarHostGroupDropDestination = {
targetParentTagId: string | null;
targetToken: string | null;
insertBefore: boolean;
};
type SidebarTreeDropPlacementOptions = {
dragNodeType: unknown;
dropNodeType: unknown;
relativeDropPosition: number;
dropToGap?: boolean;
fallbackInsertBefore: boolean;
metrics?: {
clientY?: number;
top?: number;
height?: number;
} | null;
};
const SIDEBAR_HOST_GROUP_DROP_EDGE_PX = 4;
/**
* Resolves the user-facing drop intent from the real row under the pointer.
*
* rc-tree normally requires a hidden horizontal-indent gesture to drop into a
* collapsed node. Host rows should instead treat the visible group row as the
* primary target, while retaining narrow top/bottom gaps for explicit sorting.
*/
export const resolveSidebarTreeDropPlacement = ({
dragNodeType,
dropNodeType,
relativeDropPosition,
dropToGap,
fallbackInsertBefore,
metrics,
}: SidebarTreeDropPlacementOptions): SidebarTreeDropPlacement => {
const isHostMovingToGroup = dragNodeType === 'connection' && dropNodeType === 'tag';
if (isHostMovingToGroup) {
const clientY = metrics?.clientY;
const top = metrics?.top;
const height = metrics?.height;
if (
typeof clientY === 'number'
&& typeof top === 'number'
&& typeof height === 'number'
&& Number.isFinite(clientY)
&& Number.isFinite(top)
&& Number.isFinite(height)
&& height > 0
) {
const edgeSize = Math.min(SIDEBAR_HOST_GROUP_DROP_EDGE_PX, height / 4);
const offset = clientY - top;
if (offset < edgeSize) return 'before';
if (offset > height - edgeSize) return 'after';
}
return 'inside';
}
if (
dropNodeType === 'tag'
&& (dropToGap === false || (dropToGap === undefined && relativeDropPosition === 0))
) {
return 'inside';
}
if (relativeDropPosition < 0) return 'before';
if (relativeDropPosition > 0) return 'after';
return fallbackInsertBefore ? 'before' : 'after';
};
export const resolveSidebarHostGroupDropDestination = (options: {
targetTagId: string;
targetTagParentId: string | null;
targetTagToken: string | null;
placement: SidebarTreeDropPlacement;
}): SidebarHostGroupDropDestination => (
options.placement === 'inside'
? {
targetParentTagId: options.targetTagId,
targetToken: null,
insertBefore: false,
}
: {
targetParentTagId: options.targetTagParentId,
targetToken: options.targetTagToken,
insertBefore: options.placement === 'before',
}
);
const resolveSidebarDropBaseElementFromDomEvent = (
event: {
clientX?: number;
@@ -1149,6 +1239,44 @@ const resolveSidebarDropBaseElementFromDomEvent = (
return baseElement;
};
export type SidebarDropDomHit = {
key: string;
type: string;
metrics: { top: number; height: number } | null;
};
export const resolveSidebarDropDomHit = (
event: {
clientX?: number;
clientY?: number;
target?: EventTarget | null;
} | null | undefined,
): SidebarDropDomHit | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const treeNode = baseElement.closest('.ant-tree-treenode') as HTMLElement | null;
const rowKey = String(treeNode?.getAttribute?.('data-sidebar-node-key') || '').trim();
const rowType = String(treeNode?.getAttribute?.('data-sidebar-node-type') || '').trim();
const nestedMarker = treeNode?.querySelector?.('[data-sidebar-node-key]') as HTMLElement | null;
const fallbackMarker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null;
const marker = rowKey && rowType ? treeNode : (nestedMarker || fallbackMarker);
if (!marker) return null;
const key = rowKey || String(marker.getAttribute('data-sidebar-node-key') || '').trim();
const type = rowType || String(marker.getAttribute('data-sidebar-node-type') || '').trim();
if (!key || !type) return null;
let metrics: SidebarDropDomHit['metrics'] = null;
if (treeNode && typeof treeNode.getBoundingClientRect === 'function') {
const rect = treeNode.getBoundingClientRect();
if (Number.isFinite(rect.top) && Number.isFinite(rect.height) && rect.height > 0) {
metrics = { top: rect.top, height: rect.height };
}
}
return { key, type, metrics };
};
export const resolveSidebarDropNodeFromDomEvent = (
event: {
clientX?: number;
@@ -1156,14 +1284,8 @@ export const resolveSidebarDropNodeFromDomEvent = (
target?: EventTarget | null;
} | null | undefined,
): { key: string; type: string } | null => {
const baseElement = resolveSidebarDropBaseElementFromDomEvent(event);
if (!baseElement) return null;
const marker = baseElement.closest('[data-sidebar-node-key]') as HTMLElement | null;
if (!marker) return null;
const key = String(marker.getAttribute('data-sidebar-node-key') || '').trim();
const type = String(marker.getAttribute('data-sidebar-node-type') || '').trim();
if (!key || !type) return null;
return { key, type };
const hit = resolveSidebarDropDomHit(event);
return hit ? { key: hit.key, type: hit.type } : null;
};
export const resolveSidebarDropTargetMetricsFromDomEvent = (

View File

@@ -3218,6 +3218,142 @@ body[data-ui-version="v2"] .gn-v2-tree-title.is-connection {
color: var(--gn-fg-1);
}
/* Host grouping drag: group rows are magnetic targets, gaps remain sortable. */
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-connection-group) .ant-tree-node-content-wrapper {
cursor: grabbing !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside) {
position: relative;
border-radius: 6px;
background: color-mix(in srgb, var(--gn-accent) 12%, var(--gn-bg-selected)) !important;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--gn-accent) 56%, transparent), var(--gn-shadow-sm) !important;
transition: background-color 90ms ease, box-shadow 90ms ease;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside) .ant-tree-node-content-wrapper {
background: transparent !important;
color: var(--gn-fg-1) !important;
box-shadow: none !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside) .gn-v2-tree-folder-icon,
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside) .gn-v2-tree-folder-icon .anticon {
color: var(--gn-accent) !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-before) .ant-tree-node-content-wrapper,
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-after) .ant-tree-node-content-wrapper {
position: relative;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-before) .ant-tree-node-content-wrapper::after,
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-after) .ant-tree-node-content-wrapper::after {
position: absolute;
z-index: 3;
right: 4px;
left: 4px;
height: 2px;
border-radius: 2px;
background: var(--gn-accent);
content: "";
pointer-events: none;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-before) .ant-tree-node-content-wrapper::after {
top: -1px;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-after) .ant-tree-node-content-wrapper::after {
bottom: -1px;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.has-host-group-drop-preview .ant-tree-drop-indicator {
display: none !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell .ant-tree-drop-indicator::after {
display: none !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode.dragging::after {
display: none !important;
animation: none !important;
}
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode.dragging .ant-tree-node-content-wrapper {
background: transparent !important;
box-shadow: none !important;
outline: 1px dashed color-mix(in srgb, var(--gn-fg-4) 48%, transparent);
outline-offset: -3px;
opacity: 0.38;
}
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview {
position: fixed;
top: -1000px;
left: -1000px;
z-index: 99999;
display: inline-flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: max-content;
max-width: 240px;
height: 30px;
padding: 0 10px 0 8px;
overflow: hidden;
border: 1px solid var(--gn-br-2);
border-radius: 7px;
background: var(--gn-bg-panel-2);
color: var(--gn-fg-1);
box-shadow: var(--gn-shadow-md);
font-family: var(--gn-font-sans);
font-size: var(--gn-sidebar-tree-font-size, var(--gn-font-size-sm, 12px));
font-weight: 500;
line-height: 1;
pointer-events: none;
}
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-icon {
position: relative;
display: inline-flex;
flex: 0 0 18px;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
}
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-icon:empty::before {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--gn-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gn-accent) 14%, transparent);
content: "";
}
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-icon > *,
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-icon img,
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-icon svg {
max-width: 18px !important;
max-height: 18px !important;
}
body[data-ui-version="v2"] .gn-v2-sidebar-tree-drag-preview-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
body[data-ui-version="v2"] .gn-v2-explorer-tree-shell.is-host-tree-dragging .ant-tree-treenode:has(.gn-v2-tree-title.is-drop-inside) {
transition: none;
}
}
body[data-ui-version="v2"] .gn-v2-tree-status {
position: relative;
margin: 0 2px 0 0;