react-drag-panel
Version:
Drag & drop panel grid layout for React
295 lines (291 loc) • 8.89 kB
JavaScript
// src/components/PanelGrid.tsx
import React, { cloneElement, isValidElement, useMemo, useRef, useState } from "react";
// src/components/PanelGrid.module.css
var PanelGrid_default = {};
// src/utils/utils.ts
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function isColliding(panel1, panel2) {
const a = { x: 0, y: 0, w: 0, h: 0, ...panel1 };
const b = { x: 0, y: 0, w: 0, h: 0, ...panel2 };
return !(a.x + a.w <= b.x || a.x >= b.x + b.w || a.y + a.h <= b.y || a.y >= b.y + b.h);
}
// src/utils/calculateUtils.ts
function calculatePanelStyle({
dimensions: { x = 0, y = 0, w = 1, h = 1 },
unitWidth,
rowHeight,
cols = 12,
marginX = 0,
marginY = 0,
paddingX = 0,
paddingY = 0
}) {
const clampedX = Math.min(x, cols - w);
const left = clampedX * (unitWidth + marginX) + paddingX;
const top = y * (rowHeight + marginY) + paddingY;
const width = unitWidth * w + marginX * (w - 1);
const height = rowHeight * h + marginY * (h - 1);
return {
transform: `translate(${left}px, ${top}px)`,
width,
height
};
}
function calculateContainerHeight({
panels,
rowHeight,
marginY = 0,
paddingY = 0
}) {
if (panels.length === 0) return 0;
const maxY = Math.max(...panels.map((p) => (p?.y || 0) + (p?.h || 1)));
return maxY * rowHeight + (maxY - 1) * marginY + paddingY * 2;
}
function getDropPosition({
event,
config: {
containerRect,
unitWidth,
rowHeight,
padding,
margin,
cols,
maxRows,
panelSize
}
}) {
const mouseLeft = event.clientX - containerRect.left;
const mouseTop = event.clientY - containerRect.top;
const panelWidth = unitWidth * panelSize.w + margin[0] * (panelSize.w - 1);
const panelHeight = rowHeight * panelSize.h + margin[1] * (panelSize.h - 1);
const adjustedLeft = mouseLeft - padding[0] - panelWidth / 2;
const adjustedTop = mouseTop - padding[1] - panelHeight / 2;
const newX = Math.round(adjustedLeft / (unitWidth + margin[0]));
const newY = Math.round(adjustedTop / (rowHeight + margin[1]));
const maxX = cols - panelSize.w;
const maxY = maxRows - panelSize.h;
return {
x: clamp(newX, 0, maxX),
y: clamp(newY, 0, maxY)
};
}
function resolveCollisions(draggedPanel, panels) {
const newPanels = panels.map(
(p) => p.id === draggedPanel.id ? draggedPanel : { ...p }
);
const visited = /* @__PURE__ */ new Set();
function pushDown(panel) {
if (!panel.id) return;
if (visited.has(panel.id)) return;
visited.add(panel.id);
for (const other of newPanels) {
if (panel.id === other.id) continue;
if (isColliding(panel, other)) {
other.y = (panel.y || 0) + (panel.h || 1);
pushDown(other);
}
}
}
pushDown(draggedPanel);
return newPanels;
}
// src/components/PanelGrid.tsx
import { jsx, jsxs } from "react/jsx-runtime";
var PanelGrid = ({
panels = [],
cols = 12,
rowHeight = 50,
width = 1200,
margin = [0, 0],
padding = [0, 0],
isDraggable = true,
preventCollision = true,
onLayoutChange,
children
}) => {
const [panelList, setPanelList] = useState(panels);
const dragItem = useRef(null);
const containerRef = useRef(null);
const originalPanels = useRef(panels);
const lastDragOverPosRef = useRef(null);
const [dragOverPosition, setDragOverPosition] = useState(null);
const unitWidth = width / cols;
const containerHeight = calculateContainerHeight({
panels: panelList,
rowHeight,
marginY: margin[1],
paddingY: padding[1]
});
const childrenArray = useMemo(() => React.Children.toArray(children), [children]);
const handleDragStart = (panel) => {
dragItem.current = panel;
originalPanels.current = panelList.map((p) => ({ ...p }));
};
const handleDrag = (event) => {
event.preventDefault();
if (dragItem.current === null || !containerRef.current) return;
const draggedPanel = dragItem.current;
const { w = 1, h = 1 } = draggedPanel;
const containerRect = containerRef.current.getBoundingClientRect();
const maxRows = Math.floor(containerHeight / rowHeight);
if (event.clientX === 0 && event.clientY === 0) return;
const { x, y } = getDropPosition({
event,
config: {
containerRect,
unitWidth,
rowHeight,
padding,
margin,
cols,
maxRows,
panelSize: { w, h }
}
});
const last = lastDragOverPosRef.current;
if (last && last.x === x && last.y === y) return;
lastDragOverPosRef.current = { x, y };
setDragOverPosition({ x, y, w, h });
const tempDragged = { ...draggedPanel, x, y };
const isOverlap = panelList.some(
(panel) => panel.id !== draggedPanel.id && isColliding(tempDragged, panel)
);
let nextList = originalPanels.current.map((p) => ({ ...p }));
if (preventCollision && isOverlap) {
nextList = resolveCollisions(tempDragged, panelList);
}
const hasChanged = nextList.some((panel, i) => {
const prev = panelList[i];
return panel.x !== prev.x || panel.y !== prev.y || panel.w !== prev.w || panel.h !== prev.h;
});
if (hasChanged) {
setPanelList(nextList);
}
};
const handleDrop = (event) => {
if (dragItem.current === null || !containerRef.current) return;
const draggedPanel = dragItem.current;
const { w = 1, h = 1 } = draggedPanel;
const containerRect = containerRef.current.getBoundingClientRect();
const maxRows = Math.floor(containerHeight / rowHeight);
const { x, y } = getDropPosition({
event,
config: {
containerRect,
unitWidth,
rowHeight,
padding,
margin,
cols,
maxRows,
panelSize: { w, h }
}
});
const tempDragged = { ...draggedPanel, x, y };
const isOverlap = panelList.some(
(panel) => panel.id !== draggedPanel.id && isColliding(tempDragged, panel)
);
let updatedPanels = panelList;
if (preventCollision && isOverlap) {
updatedPanels = resolveCollisions(tempDragged, panelList);
} else {
updatedPanels = panelList.map(
(panel) => panel.id === draggedPanel.id ? { ...panel, x, y } : panel
);
}
const hasChanged = updatedPanels.some((panel, i) => {
const prev = panelList[i];
return panel.x !== prev.x || panel.y !== prev.y || panel.w !== prev.w || panel.h !== prev.h;
});
if (hasChanged) {
requestAnimationFrame(() => {
setPanelList(updatedPanels);
onLayoutChange?.(updatedPanels);
setDragOverPosition(null);
lastDragOverPosRef.current = null;
});
} else {
setDragOverPosition(null);
lastDragOverPosRef.current = null;
}
};
const dropIndicatorElement = useMemo(() => {
if (!dragOverPosition) return null;
return /* @__PURE__ */ jsx(
"div",
{
className: PanelGrid_default.dropIndicator,
style: calculatePanelStyle({
dimensions: dragOverPosition,
unitWidth,
rowHeight,
cols,
marginX: margin[0],
marginY: margin[1],
paddingX: padding[0],
paddingY: padding[1]
})
}
);
}, [dragOverPosition, unitWidth, rowHeight, cols, margin, padding]);
const styledChildren = useMemo(() => {
return childrenArray.map((child, index) => {
if (!isValidElement(child)) return null;
const panel = panelList[index];
const style = calculatePanelStyle({
dimensions: {
x: panel?.x,
y: panel?.y,
w: panel?.w,
h: panel?.h
},
unitWidth,
rowHeight,
cols,
marginX: margin[0],
marginY: margin[1],
paddingX: padding[0],
paddingY: padding[1]
});
const element = child;
return cloneElement(element, {
"data-testid": "grid-panel",
key: panel?.id || `default-key-${index}`,
style: { ...style, ...element.props.style || {} },
className: [PanelGrid_default.gridItemPanel, element.props.className].filter(Boolean).join(" "),
draggable: isDraggable !== false,
onDragStart: isDraggable !== false ? () => handleDragStart(panel) : void 0,
onDrag: isDraggable !== false ? handleDrag : void 0,
onDragEnd: isDraggable !== false ? handleDrop : void 0
});
});
}, [
childrenArray,
panelList,
unitWidth,
rowHeight,
cols,
margin,
padding
]);
return /* @__PURE__ */ jsxs(
"div",
{
ref: containerRef,
style: { height: containerHeight, width },
className: PanelGrid_default.gridContainer,
"data-testid": "grid-container",
children: [
dropIndicatorElement,
styledChildren
]
}
);
};
var PanelGrid_default2 = PanelGrid;
export {
PanelGrid_default2 as PanelGrid
};
//# sourceMappingURL=index.mjs.map