mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-17 12:24:09 +08:00
🐛 fix(sidebar): 优化 Host 拖入分组的命中与反馈
- 扩大分组中心放入热区并保留边缘排序 - 增加整行目标高亮、紧凑预览与悬停展开 - 覆盖拖拽意图、DOM 命中和移动目标回归 Refs #877
This commit is contained in:
@@ -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'],
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user