Compare commits

...

2 Commits

Author SHA1 Message Date
Ashwin Bhatkal
e751e44478 feat(dashboard-v2): drag panels between sections
Panels can now be dragged across sections, not just moved via the menu. Each
panel gets a hover grip (a dnd-kit draggable, marked panel-no-drag so
react-grid-layout ignores it) and each section becomes a drop zone; dropping on
another section relocates the panel to its bottom row. The section DndContext
now hosts both drag kinds, routing events by id and switching modifiers/
collision detection to match, with a lightweight DragOverlay preview. RGL keeps
intra-section drag/resize; the grip only appears in sectioned edit mode with
somewhere to move.
2026-07-10 11:37:36 +05:30
Ashwin Bhatkal
255b280037 feat(dashboard-v2): cross-section panel drag foundation (ids + move hook)
Namespaced dnd-kit ids for the two drag kinds a dashboard hosts (section reorder
vs panel move), a pure resolver for the move a panel drop implies, and a
usePanelDragMove hook that owns panel drag state and relocates the panel via the
existing useMovePanelToSection. Unwired here; the layout consumes it next.
2026-07-10 11:37:36 +05:30
13 changed files with 594 additions and 20 deletions

View File

@@ -0,0 +1,23 @@
.preview {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 320px;
padding: 8px 12px;
border: 1px solid var(--bg-robin-500);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
box-shadow: 0 8px 24px rgb(0 0 0 / 40%);
cursor: grabbing;
}
.grip {
flex-shrink: 0;
color: var(--l2-foreground);
}
.title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,25 @@
import { GripVertical } from '@signozhq/icons';
import type { GridItem } from '../../../utils';
import styles from './PanelDragPreview.module.scss';
interface PanelDragPreviewProps {
item: GridItem;
}
/**
* Lightweight preview shown in the DragOverlay while a panel is being dragged
* across sections. Title-only (no chart) so the overlay stays cheap and never
* mounts a query-bound panel.
*/
function PanelDragPreview({ item }: PanelDragPreviewProps): JSX.Element {
const title = item.panel?.spec?.display?.name || 'Panel';
return (
<div className={styles.preview}>
<GripVertical size={14} className={styles.grip} />
<span className={styles.title}>{title}</span>
</div>
);
}
export default PanelDragPreview;

View File

@@ -8,6 +8,14 @@
opacity: 0.8;
}
// A panel is being dragged over this section — highlight it as the drop target.
.dropTarget {
border-radius: 4px;
outline: 1px dashed var(--bg-robin-500);
outline-offset: 2px;
background: color-mix(in srgb, var(--bg-robin-500) 6%, transparent);
}
.deleteModal :global(.ant-modal-confirm-body) {
align-items: center;
}

View File

@@ -1,6 +1,8 @@
import { useCallback, useRef, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import ConfirmDeleteDialog from '../../../components/ConfirmDeleteDialog/ConfirmDeleteDialog';
import DisabledControlTooltip from '../../../components/DisabledControlTooltip/DisabledControlTooltip';
@@ -9,6 +11,7 @@ import { useCreatePanel } from '../../../hooks/useCreatePanel';
import type { DashboardSection } from '../../../utils';
import PanelTypeSelectionModal from '../../Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
import { useDashboardStore } from '../../../store/useDashboardStore';
import { sectionDropId } from '../hooks/crossSectionDrag';
import { useCloneSection } from '../hooks/useCloneSection';
import { useDeleteSection } from '../hooks/useDeleteSection';
import { useRenameSection } from '../hooks/useRenameSection';
@@ -27,9 +30,16 @@ interface SectionProps {
sections?: DashboardSection[];
/** Provided by SortableSection in sectioned mode; absent for untitled/free-flow. */
dragHandle?: SectionDragHandle;
/** Register this section as a cross-section panel drop target. */
enablePanelDrag?: boolean;
}
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
function Section({
section,
sections,
dragHandle,
enablePanelDrag = false,
}: SectionProps): JSX.Element {
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
const isLocked = useDashboardStore((s) => s.isLocked);
const {
@@ -66,14 +76,30 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const cloneSection = useCloneSection();
const sectionRef = useRef<HTMLDivElement>(null);
const sectionRef = useRef<HTMLDivElement | null>(null);
useScrollIntoView(section.id, sectionRef);
// Cross-section panel drop target. `setSectionRef` fans the node out to both
// the scroll-into-view ref and dnd-kit's droppable ref; `isOver` highlights
// the section while a panel hovers it.
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: sectionDropId(section.layoutIndex),
disabled: !enablePanelDrag,
});
const setSectionRef = useCallback(
(node: HTMLDivElement | null): void => {
sectionRef.current = node;
setDroppableRef(node);
},
[setDroppableRef],
);
const grid = (
<SectionGrid
items={section.items}
layoutIndex={section.layoutIndex}
sections={sections}
enablePanelDrag={enablePanelDrag}
/>
);
@@ -81,7 +107,8 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
// Untitled section — just the grid, no header chrome.
return (
<div
ref={sectionRef}
ref={setSectionRef}
className={cx(isOver && styles.dropTarget)}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>
@@ -92,8 +119,8 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
return (
<div
ref={sectionRef}
className={styles.section}
ref={setSectionRef}
className={cx(styles.section, isOver && styles.dropTarget)}
data-testid={`dashboard-section-${section.id}`}
data-section-layout-index={section.layoutIndex}
>

View File

@@ -23,6 +23,47 @@
// Lazy-load observer boundary (SectionGridItem); fills the grid cell.
.panelWrapper {
position: relative;
height: 100%;
width: 100%;
}
// Cross-section drag grip: a small handle at the panel's top-left, revealed on
// hover. `panel-no-drag` keeps react-grid-layout from treating it as an
// intra-section move handle, so it exclusively starts the dnd-kit cross-section
// drag.
.panelDragHandle {
position: absolute;
top: 6px;
left: 6px;
z-index: 3;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--bg-ink-400, #0b0c0e);
color: var(--l2-foreground);
opacity: 0;
cursor: grab;
transition: opacity 100ms ease;
&:hover,
&:focus-visible {
opacity: 1;
}
&:active {
cursor: grabbing;
}
}
.panelWrapper:hover .panelDragHandle {
opacity: 1;
}
// The original panel is dimmed while its DragOverlay preview follows the pointer.
.panelDragging {
opacity: 0.4;
}

View File

@@ -14,12 +14,15 @@ interface SectionGridProps {
layoutIndex: number;
/** All sections — layout context for the panel menu's move/delete actions. */
sections?: DashboardSection[];
/** Enable the cross-section drag grip on each panel (sectioned edit mode). */
enablePanelDrag?: boolean;
}
function SectionGrid({
items,
layoutIndex,
sections,
enablePanelDrag = false,
}: SectionGridProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const rglLayout = useMemo<Layout[]>(
@@ -61,6 +64,8 @@ function SectionGrid({
<SectionGridItem
panel={item.panel}
panelId={item.id}
layoutIndex={layoutIndex}
enablePanelDrag={enablePanelDrag}
panelActions={
isEditable
? {

View File

@@ -1,8 +1,12 @@
import { useRef } from 'react';
import { useDraggable } from '@dnd-kit/core';
import { GripVertical } from '@signozhq/icons';
import cx from 'classnames';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import Panel, { type PanelActionsConfig } from '../../Panel/Panel';
import { panelDraggableId } from '../hooks/crossSectionDrag';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import styles from './SectionGrid.module.scss';
@@ -13,17 +17,25 @@ const VIEWPORT_OBSERVER_OPTIONS: IntersectionObserverInit = {
interface SectionGridItemProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** This panel's section, carried on the drag so the drop knows its origin. */
layoutIndex: number;
/** Show the cross-section drag grip (sectioned + editable boards only). */
enablePanelDrag: boolean;
panelActions?: PanelActionsConfig;
}
/**
* Lazy-loads a single panel: watches its own viewport intersection (latched) and
* passes it to the presentational Panel as `isVisible`, so a board of many panels
* only fetches what's on screen.
* only fetches what's on screen. In sectioned edit mode it also exposes a grip
* that starts a cross-section drag (dnd-kit); the grip is `panel-no-drag` so
* react-grid-layout never treats it as an intra-section move handle.
*/
function SectionGridItem({
panel,
panelId,
layoutIndex,
enablePanelDrag,
panelActions,
}: SectionGridItemProps): JSX.Element {
const containerRef = useRef<HTMLDivElement>(null);
@@ -34,8 +46,31 @@ function SectionGridItem({
);
useScrollIntoView(panelId, containerRef);
const { setNodeRef, listeners, attributes, isDragging } = useDraggable({
id: panelDraggableId(panelId),
data: { panelId, fromLayoutIndex: layoutIndex },
disabled: !enablePanelDrag,
});
return (
<div ref={containerRef} className={styles.panelWrapper}>
<div
ref={containerRef}
className={cx(styles.panelWrapper, isDragging && styles.panelDragging)}
>
{enablePanelDrag && (
<button
type="button"
ref={setNodeRef}
className={cx('panel-no-drag', styles.panelDragHandle)}
aria-label="Drag panel to another section"
title="Drag to move to another section"
data-testid={`panel-drag-handle-${panelId}`}
{...attributes}
{...listeners}
>
<GripVertical size={12} />
</button>
)}
<Panel
panel={panel}
panelId={panelId}

View File

@@ -1,5 +1,13 @@
import { useMemo } from 'react';
import { closestCenter, DndContext, DragOverlay } from '@dnd-kit/core';
import { useCallback, useMemo } from 'react';
import {
closestCenter,
type CollisionDetection,
DndContext,
type DragEndEvent,
DragOverlay,
type DragStartEvent,
type Modifier,
} from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
@@ -12,7 +20,9 @@ import type { DashboardtypesLayoutDTO } from 'api/generated/services/sigNoz.sche
import type { DashboardSection } from '../../utils';
import { useDashboardStore } from '../../store/useDashboardStore';
import { usePanelDragMove } from './hooks/usePanelDragMove';
import { useSectionDragReorder } from './hooks/useSectionDragReorder';
import PanelDragPreview from './PanelDragPreview/PanelDragPreview';
import Section from './Section/Section';
import SectionDragPreview from './SectionDragPreview/SectionDragPreview';
import SortableSection from './SortableSection';
@@ -22,6 +32,21 @@ interface SectionListProps {
layouts: DashboardtypesLayoutDTO[] | undefined | null;
}
// Section reorder is confined to a vertical list within its parent; a panel
// cross-section drag moves freely, so it runs with no modifiers.
const SECTION_MODIFIERS: Modifier[] = [
restrictToVerticalAxis,
restrictToParentElement,
];
const NO_MODIFIERS: Modifier[] = [];
/**
* Hosts a single dnd-kit DndContext for two drag kinds: reordering sections
* (sortable list) and moving a panel between sections (a panel grip dragged onto
* another section's drop zone). Each event is routed by its namespaced id —
* panel handlers get first refusal, section-reorder handles the rest — and the
* modifiers/collision detection switch to match the active kind.
*/
function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
@@ -29,11 +54,20 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
sensors,
orderedSections,
activeSection,
onDragStart,
onDragEnd,
onDragCancel,
onDragStart: onSectionDragStart,
onDragEnd: onSectionDragEnd,
onDragCancel: onSectionDragCancel,
} = useSectionDragReorder({ sections, layouts });
const {
activePanel,
isDraggingPanel,
panelCollisionDetection,
handleDragStart: onPanelDragStart,
handleDragEnd: onPanelDragEnd,
handleDragCancel: onPanelDragCancel,
} = usePanelDragMove({ sections });
// Only titled sections participate in reordering; untitled (free-flow)
// blocks render in place without a drag handle.
const sortableIds = useMemo(
@@ -41,6 +75,36 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
[orderedSections],
);
// Cross-section dragging only makes sense with somewhere to move to.
const enablePanelDrag = orderedSections.length > 1;
const handleDragStart = useCallback(
(event: DragStartEvent): void => {
if (!onPanelDragStart(event)) {
onSectionDragStart(event);
}
},
[onPanelDragStart, onSectionDragStart],
);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
if (!onPanelDragEnd(event)) {
onSectionDragEnd(event);
}
},
[onPanelDragEnd, onSectionDragEnd],
);
const handleDragCancel = useCallback((): void => {
onPanelDragCancel();
onSectionDragCancel();
}, [onPanelDragCancel, onSectionDragCancel]);
const collisionDetection: CollisionDetection = isDraggingPanel
? panelCollisionDetection
: closestCenter;
if (!isEditable) {
return (
<>
@@ -54,25 +118,39 @@ function SectionList({ sections, layouts }: SectionListProps): JSX.Element {
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={collisionDetection}
modifiers={isDraggingPanel ? NO_MODIFIERS : SECTION_MODIFIERS}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{orderedSections.map((section) =>
section.title ? (
<SortableSection key={section.id} section={section} sections={sections} />
<SortableSection
key={section.id}
section={section}
sections={sections}
enablePanelDrag={enablePanelDrag}
/>
) : (
<Section key={section.id} section={section} sections={sections} />
<Section
key={section.id}
section={section}
sections={sections}
enablePanelDrag={enablePanelDrag}
/>
),
)}
</SortableContext>
{/* dropAnimation disabled: optimistic reorder already places the section,
so animating the overlay back would cause a visible snap/shake. */}
<DragOverlay dropAnimation={null}>
{activeSection ? <SectionDragPreview section={activeSection} /> : null}
{activeSection ? (
<SectionDragPreview section={activeSection} />
) : activePanel ? (
<PanelDragPreview item={activePanel} />
) : null}
</DragOverlay>
</DndContext>
);

View File

@@ -7,11 +7,13 @@ import Section from './Section/Section';
interface SortableSectionProps {
section: DashboardSection;
sections: DashboardSection[];
enablePanelDrag?: boolean;
}
function SortableSection({
section,
sections,
enablePanelDrag,
}: SortableSectionProps): JSX.Element {
const {
attributes,
@@ -38,6 +40,7 @@ function SortableSection({
<Section
section={section}
sections={sections}
enablePanelDrag={enablePanelDrag}
dragHandle={{ attributes, listeners, setActivatorNodeRef }}
/>
</div>

View File

@@ -0,0 +1,53 @@
import {
isPanelDragId,
isSectionDropId,
layoutIndexFromDropId,
panelDraggableId,
resolvePanelMove,
sectionDropId,
} from '../crossSectionDrag';
describe('crossSectionDrag — id helpers', () => {
it('builds and recognises panel drag ids', () => {
const id = panelDraggableId('abc');
expect(id).toBe('panel:abc');
expect(isPanelDragId(id)).toBe(true);
expect(isPanelDragId('dropzone:0')).toBe(false);
expect(isPanelDragId('sec-abc')).toBe(false);
});
it('builds and parses section drop ids', () => {
expect(sectionDropId(3)).toBe('dropzone:3');
expect(isSectionDropId('dropzone:3')).toBe(true);
expect(isSectionDropId('panel:x')).toBe(false);
expect(layoutIndexFromDropId('dropzone:3')).toBe(3);
expect(layoutIndexFromDropId('dropzone:0')).toBe(0);
expect(layoutIndexFromDropId('panel:x')).toBeNull();
expect(layoutIndexFromDropId('dropzone:abc')).toBeNull();
});
});
describe('resolvePanelMove', () => {
const drag = { panelId: 'p1', fromLayoutIndex: 0 };
it('is a no-op without a drag or a drop target', () => {
expect(resolvePanelMove(null, 'dropzone:1')).toBeNull();
expect(resolvePanelMove(drag, null)).toBeNull();
});
it('is a no-op when dropped over a non-dropzone', () => {
expect(resolvePanelMove(drag, 'sec-abc')).toBeNull();
});
it('is a no-op when dropped back on the same section', () => {
expect(resolvePanelMove(drag, 'dropzone:0')).toBeNull();
});
it('resolves a move to a different section', () => {
expect(resolvePanelMove(drag, 'dropzone:2')).toStrictEqual({
panelId: 'p1',
fromLayoutIndex: 0,
toLayoutIndex: 2,
});
});
});

View File

@@ -0,0 +1,112 @@
import type { DragEndEvent, DragStartEvent } from '@dnd-kit/core';
import { act, renderHook } from '@testing-library/react';
import type { DashboardSection } from '../../../../utils';
import { usePanelDragMove } from '../usePanelDragMove';
const movePanel = jest.fn();
jest.mock('../../../Panel/hooks/useMovePanelToSection', () => ({
useMovePanelToSection: () => movePanel,
}));
const sections: DashboardSection[] = [
{
id: 'sec-p1',
layoutIndex: 0,
title: 'A',
repeatVariable: undefined,
items: [
{
id: 'p1',
x: 0,
y: 0,
width: 6,
height: 6,
panel: { spec: { display: { name: 'Panel 1' } } } as never,
},
],
},
{
id: 'sec-empty-1',
layoutIndex: 1,
title: 'B',
repeatVariable: undefined,
items: [],
},
];
const startEvent = (id: string, data?: unknown): DragStartEvent =>
({ active: { id, data: { current: data } } }) as unknown as DragStartEvent;
const endEvent = (id: string, overId: string | null): DragEndEvent =>
({
active: { id, data: { current: undefined } },
over: overId ? { id: overId } : null,
}) as unknown as DragEndEvent;
beforeEach(() => movePanel.mockClear());
describe('usePanelDragMove', () => {
it('ignores section-reorder drags (returns false, no active panel)', () => {
const { result } = renderHook(() => usePanelDragMove({ sections }));
let handled = true;
act(() => {
handled = result.current.handleDragStart(startEvent('sec-p1'));
});
expect(handled).toBe(false);
expect(result.current.isDraggingPanel).toBe(false);
expect(result.current.activePanel).toBeNull();
});
it('tracks the active panel while a grip is dragged', () => {
const { result } = renderHook(() => usePanelDragMove({ sections }));
act(() => {
result.current.handleDragStart(
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
);
});
expect(result.current.isDraggingPanel).toBe(true);
expect(result.current.activePanel?.id).toBe('p1');
});
it('moves the panel to the dropped section', () => {
const { result } = renderHook(() => usePanelDragMove({ sections }));
act(() => {
result.current.handleDragStart(
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
);
});
let handled = false;
act(() => {
handled = result.current.handleDragEnd(endEvent('panel:p1', 'dropzone:1'));
});
expect(handled).toBe(true);
expect(movePanel).toHaveBeenCalledWith({
panelId: 'p1',
fromLayoutIndex: 0,
toLayoutIndex: 1,
});
expect(result.current.isDraggingPanel).toBe(false);
});
it('does not move when dropped back on the same section', () => {
const { result } = renderHook(() => usePanelDragMove({ sections }));
act(() => {
result.current.handleDragStart(
startEvent('panel:p1', { panelId: 'p1', fromLayoutIndex: 0 }),
);
result.current.handleDragEnd(endEvent('panel:p1', 'dropzone:0'));
});
expect(movePanel).not.toHaveBeenCalled();
});
it('lets section-reorder drag-end pass through (returns false)', () => {
const { result } = renderHook(() => usePanelDragMove({ sections }));
let handled = true;
act(() => {
handled = result.current.handleDragEnd(endEvent('sec-p1', 'sec-empty-1'));
});
expect(handled).toBe(false);
expect(movePanel).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,64 @@
// Pure helpers for cross-section panel drag-and-drop. A single dnd-kit
// DndContext (SectionList) hosts two draggable kinds — sections (reorder) and
// panels (move between sections) — so ids are namespaced to tell them apart,
// and section drop targets get their own id space.
const PANEL_DRAG_PREFIX = 'panel:';
const SECTION_DROP_PREFIX = 'dropzone:';
/** dnd-kit draggable id for a panel grip. */
export function panelDraggableId(panelId: string): string {
return `${PANEL_DRAG_PREFIX}${panelId}`;
}
/** Whether a dnd-kit active/over id belongs to a panel drag (vs a section). */
export function isPanelDragId(id: string): boolean {
return id.startsWith(PANEL_DRAG_PREFIX);
}
/** dnd-kit droppable id for a section's panel drop zone, keyed by layout index. */
export function sectionDropId(layoutIndex: number): string {
return `${SECTION_DROP_PREFIX}${layoutIndex}`;
}
/** Whether an id is a section panel drop zone. */
export function isSectionDropId(id: string): boolean {
return id.startsWith(SECTION_DROP_PREFIX);
}
/** Layout index encoded in a section drop-zone id, or null if it isn't one. */
export function layoutIndexFromDropId(id: string): number | null {
if (!id.startsWith(SECTION_DROP_PREFIX)) {
return null;
}
const value = Number(id.slice(SECTION_DROP_PREFIX.length));
return Number.isInteger(value) ? value : null;
}
/** Data carried on a panel draggable so the drop handler can build the move. */
export interface PanelDragData {
panelId: string;
fromLayoutIndex: number;
}
/**
* Resolve the move a panel drop implies, or null when it is a no-op (no drop
* target, dropped over an unknown target, or dropped back on its own section).
*/
export function resolvePanelMove(
drag: PanelDragData | null,
overId: string | null,
): { panelId: string; fromLayoutIndex: number; toLayoutIndex: number } | null {
if (!drag || !overId) {
return null;
}
const toLayoutIndex = layoutIndexFromDropId(overId);
if (toLayoutIndex === null || toLayoutIndex === drag.fromLayoutIndex) {
return null;
}
return {
panelId: drag.panelId,
fromLayoutIndex: drag.fromLayoutIndex,
toLayoutIndex,
};
}

View File

@@ -0,0 +1,100 @@
import { useCallback, useMemo, useState } from 'react';
import type {
CollisionDetection,
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import { pointerWithin } from '@dnd-kit/core';
import type { DashboardSection, GridItem } from '../../../utils';
import { useMovePanelToSection } from '../../Panel/hooks/useMovePanelToSection';
import {
isPanelDragId,
isSectionDropId,
type PanelDragData,
resolvePanelMove,
} from './crossSectionDrag';
interface Params {
sections: DashboardSection[];
}
interface Result {
/** The panel item currently being dragged across sections (for the overlay). */
activePanel: GridItem | null;
/** True while a panel drag is in progress — the caller relaxes DnD modifiers. */
isDraggingPanel: boolean;
/** Collision detection scoped to section drop zones (panel drags only). */
panelCollisionDetection: CollisionDetection;
/** Returns true if it handled the event (a panel drag), false otherwise. */
handleDragStart: (event: DragStartEvent) => boolean;
handleDragEnd: (event: DragEndEvent) => boolean;
handleDragCancel: () => void;
}
/**
* Owns cross-section panel drag state within the section DndContext. A panel
* grip drag is identified by its namespaced id; on drop over a different
* section's zone the panel is relocated via the shared move hook (landing at the
* target's bottom row). Section-reorder drags are left for the caller to handle.
*/
export function usePanelDragMove({ sections }: Params): Result {
const movePanel = useMovePanelToSection({ sections });
const [activeDrag, setActiveDrag] = useState<PanelDragData | null>(null);
const handleDragStart = useCallback((event: DragStartEvent): boolean => {
if (!isPanelDragId(String(event.active.id))) {
return false;
}
setActiveDrag((event.active.data.current as PanelDragData) ?? null);
return true;
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent): boolean => {
if (!isPanelDragId(String(event.active.id))) {
return false;
}
const overId = event.over ? String(event.over.id) : null;
const move = resolvePanelMove(activeDrag, overId);
setActiveDrag(null);
if (move) {
void movePanel(move);
}
return true;
},
[activeDrag, movePanel],
);
const handleDragCancel = useCallback((): void => {
setActiveDrag(null);
}, []);
// Only section drop zones are valid panel targets; ignore the sortable
// section handles so a panel always resolves to the zone under the pointer.
const panelCollisionDetection = useCallback<CollisionDetection>((args) => {
const zones = args.droppableContainers.filter((c) =>
isSectionDropId(String(c.id)),
);
return pointerWithin({ ...args, droppableContainers: zones });
}, []);
const activePanel = useMemo<GridItem | null>(() => {
if (!activeDrag) {
return null;
}
const source = sections.find(
(s) => s.layoutIndex === activeDrag.fromLayoutIndex,
);
return source?.items.find((i) => i.id === activeDrag.panelId) ?? null;
}, [activeDrag, sections]);
return {
activePanel,
isDraggingPanel: activeDrag !== null,
panelCollisionDetection,
handleDragStart,
handleDragEnd,
handleDragCancel,
};
}