panelgrid 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +327 -0
- package/dist/index.cjs +648 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.mts +124 -0
- package/dist/index.mjs +644 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +67 -0
- package/package.json +78 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
|
|
4
|
+
//#region src/helpers/rearrangement.ts
|
|
5
|
+
/**
|
|
6
|
+
* Check if two rectangles overlap using AABB (Axis-Aligned Bounding Box) test
|
|
7
|
+
* 2つの矩形が重なっているかをAABBテストで判定
|
|
8
|
+
*/
|
|
9
|
+
function rectanglesOverlap(a, b) {
|
|
10
|
+
return !(a.x + a.w <= b.x || b.x + b.w <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detect all panels that collide with the given panel
|
|
14
|
+
* 指定されたパネルと衝突する全てのパネルを検出
|
|
15
|
+
*/
|
|
16
|
+
function detectCollisions(panel, panelMap) {
|
|
17
|
+
const collisions = /* @__PURE__ */ new Set();
|
|
18
|
+
for (const [id, other] of panelMap) {
|
|
19
|
+
if (id === panel.id) continue;
|
|
20
|
+
if (rectanglesOverlap(panel, other)) collisions.add(id);
|
|
21
|
+
}
|
|
22
|
+
return Array.from(collisions);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Check if a panel at the given position would collide with any existing panels
|
|
26
|
+
* 指定された位置にパネルを配置した場合に衝突があるかをチェック
|
|
27
|
+
*/
|
|
28
|
+
function hasCollision(candidate, excludeId, panelMap) {
|
|
29
|
+
for (const [id, panel] of panelMap) {
|
|
30
|
+
if (id === excludeId) continue;
|
|
31
|
+
if (rectanglesOverlap(candidate, panel)) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Calculate the minimum distance to push a panel to avoid collision
|
|
37
|
+
* パネルを押しのけるための最小距離を計算
|
|
38
|
+
*/
|
|
39
|
+
function calculatePushDistance(pusher, pushed, columnCount) {
|
|
40
|
+
const pushRight = pusher.x + pusher.w - pushed.x;
|
|
41
|
+
const canPushRight = pushed.x + pushed.w + pushRight <= columnCount;
|
|
42
|
+
const pushDown = pusher.y + pusher.h - pushed.y;
|
|
43
|
+
if (canPushRight && pushRight > 0) return {
|
|
44
|
+
direction: "right",
|
|
45
|
+
distance: pushRight
|
|
46
|
+
};
|
|
47
|
+
if (pushDown > 0) return {
|
|
48
|
+
direction: "down",
|
|
49
|
+
distance: pushDown
|
|
50
|
+
};
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Find a new position for a panel by pushing it away from the colliding panel
|
|
55
|
+
* Priority: horizontal (right) first, then vertical (down)
|
|
56
|
+
* 衝突したパネルを押しのける方向に移動させる
|
|
57
|
+
* 優先順位: 横方向(右)→縦方向(下)
|
|
58
|
+
*/
|
|
59
|
+
function findNewPosition(panel, pusher, columnCount) {
|
|
60
|
+
const pushInfo = calculatePushDistance(pusher, panel, columnCount);
|
|
61
|
+
if (!pushInfo) return {
|
|
62
|
+
x: panel.x,
|
|
63
|
+
y: panel.y + 1
|
|
64
|
+
};
|
|
65
|
+
if (pushInfo.direction === "right") {
|
|
66
|
+
const newX = panel.x + pushInfo.distance;
|
|
67
|
+
if (newX + panel.w <= columnCount) return {
|
|
68
|
+
x: newX,
|
|
69
|
+
y: panel.y
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
x: panel.x,
|
|
74
|
+
y: panel.y + pushInfo.distance
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Constrain a panel to stay within grid boundaries
|
|
79
|
+
* パネルをグリッド境界内に制約
|
|
80
|
+
*/
|
|
81
|
+
function constrainToGrid(panel, columnCount) {
|
|
82
|
+
const maxX = Math.max(0, columnCount - panel.w);
|
|
83
|
+
const constrainedX = Math.max(0, Math.min(panel.x, maxX));
|
|
84
|
+
const constrainedY = Math.max(0, panel.y);
|
|
85
|
+
return {
|
|
86
|
+
...panel,
|
|
87
|
+
x: constrainedX,
|
|
88
|
+
y: constrainedY
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Rearrange panels to resolve collisions when a panel is moved or resized
|
|
93
|
+
* Panels are moved horizontally first, then vertically if needed
|
|
94
|
+
* パネルの移動・リサイズ時に衝突を解決するようにパネルを再配置
|
|
95
|
+
* 横方向を優先し、必要に応じて縦方向に移動
|
|
96
|
+
*/
|
|
97
|
+
function rearrangePanels(movingPanel, allPanels, columnCount) {
|
|
98
|
+
const constrainedMovingPanel = constrainToGrid(movingPanel, columnCount);
|
|
99
|
+
const panelMap = /* @__PURE__ */ new Map();
|
|
100
|
+
for (const panel of allPanels) panelMap.set(panel.id, { ...panel });
|
|
101
|
+
panelMap.set(constrainedMovingPanel.id, { ...constrainedMovingPanel });
|
|
102
|
+
const queue = [{ ...constrainedMovingPanel }];
|
|
103
|
+
const processed = /* @__PURE__ */ new Set();
|
|
104
|
+
while (queue.length > 0) {
|
|
105
|
+
const current = queue.shift();
|
|
106
|
+
if (processed.has(current.id)) continue;
|
|
107
|
+
processed.add(current.id);
|
|
108
|
+
const collidingIds = detectCollisions(current, panelMap);
|
|
109
|
+
if (collidingIds.length === 0) {
|
|
110
|
+
panelMap.set(current.id, current);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const collidingId of collidingIds) {
|
|
114
|
+
const colliding = panelMap.get(collidingId);
|
|
115
|
+
if (!colliding) continue;
|
|
116
|
+
const newPos = findNewPosition(colliding, current, columnCount);
|
|
117
|
+
const updated = {
|
|
118
|
+
...colliding,
|
|
119
|
+
x: newPos.x,
|
|
120
|
+
y: newPos.y
|
|
121
|
+
};
|
|
122
|
+
panelMap.set(collidingId, updated);
|
|
123
|
+
queue.push(updated);
|
|
124
|
+
}
|
|
125
|
+
panelMap.set(current.id, current);
|
|
126
|
+
}
|
|
127
|
+
return Array.from(panelMap.values());
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Find a new position for a panel to be added
|
|
131
|
+
* 追加するパネルの新しい位置を見つける
|
|
132
|
+
*/
|
|
133
|
+
function findNewPositionToAddPanel(panelToAdd, allPanels, columnCount) {
|
|
134
|
+
const id = panelToAdd.id || Math.random().toString(36).substring(2, 15);
|
|
135
|
+
const w = panelToAdd.w || 1;
|
|
136
|
+
const h = panelToAdd.h || 1;
|
|
137
|
+
const panelMap = /* @__PURE__ */ new Map();
|
|
138
|
+
for (const panel of allPanels) panelMap.set(panel.id, panel);
|
|
139
|
+
const maxExistingY = allPanels.length > 0 ? Math.max(...allPanels.map((p) => p.y + p.h)) : 0;
|
|
140
|
+
const MAX_ROWS = Math.max(maxExistingY + 100, 1e3);
|
|
141
|
+
for (let y = 0; y < MAX_ROWS; y++) for (let x = 0; x <= columnCount - w; x++) {
|
|
142
|
+
const candidate = {
|
|
143
|
+
id,
|
|
144
|
+
x,
|
|
145
|
+
y,
|
|
146
|
+
w,
|
|
147
|
+
h
|
|
148
|
+
};
|
|
149
|
+
if (!hasCollision(candidate, candidate.id, panelMap)) return {
|
|
150
|
+
x,
|
|
151
|
+
y
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
x: 0,
|
|
156
|
+
y: maxExistingY
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
//#endregion
|
|
161
|
+
//#region src/helpers/animation.ts
|
|
162
|
+
/**
|
|
163
|
+
* Applies snap-back animation to element
|
|
164
|
+
* Smoothly animates the element from its dropped position to the snapped grid position
|
|
165
|
+
*/
|
|
166
|
+
function applySnapAnimation(options) {
|
|
167
|
+
const { element, droppedLeft, droppedTop, nextLeft, nextTop, originalTransition } = options;
|
|
168
|
+
const deltaX = droppedLeft - nextLeft;
|
|
169
|
+
const deltaY = droppedTop - nextTop;
|
|
170
|
+
element.style.transform = `translate3D(${deltaX}px, ${deltaY}px, 0)`;
|
|
171
|
+
element.style.transition = "";
|
|
172
|
+
window.requestAnimationFrame(() => {
|
|
173
|
+
element.style.transform = "translate3D(0, 0, 0)";
|
|
174
|
+
element.style.transition = "transform 0.1s ease-out";
|
|
175
|
+
});
|
|
176
|
+
element.style.left = `${nextLeft}px`;
|
|
177
|
+
element.style.top = `${nextTop}px`;
|
|
178
|
+
element.style.transition = originalTransition;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
//#endregion
|
|
182
|
+
//#region src/helpers/gridCalculations.ts
|
|
183
|
+
/**
|
|
184
|
+
* Converts a pixel value to grid units by dividing by the cell size (baseSize + gap)
|
|
185
|
+
* and rounding up with Math.ceil. Ensures the result is at least 1 and does not exceed available space.
|
|
186
|
+
* When columnCount and xPosition are provided, ensures x + width <= columnCount.
|
|
187
|
+
*
|
|
188
|
+
* ピクセル値をグリッド単位に変換します。セルサイズ (baseSize + gap) で割り、小数点は切り上げて整数にします。
|
|
189
|
+
* 結果は最小1で、columnCountとxPositionが指定された場合はx + width <= columnCountを満たすように制限されます。
|
|
190
|
+
*/
|
|
191
|
+
function pixelsToGridSize(pixels, baseSize, gap, columnCount, xPosition) {
|
|
192
|
+
const gridSize = Math.ceil(pixels / (baseSize + gap));
|
|
193
|
+
const constrainedSize = Math.max(1, gridSize);
|
|
194
|
+
if (columnCount !== void 0 && xPosition !== void 0) {
|
|
195
|
+
const maxWidth = Math.max(1, columnCount - xPosition);
|
|
196
|
+
return Math.min(constrainedSize, maxWidth);
|
|
197
|
+
}
|
|
198
|
+
if (columnCount !== void 0) return Math.min(constrainedSize, columnCount);
|
|
199
|
+
return constrainedSize;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Converts a pixel coordinate to grid coordinate by dividing by the cell size
|
|
203
|
+
* and rounding down with Math.floor, ensuring the result is not negative and does not cause overflow.
|
|
204
|
+
* When columnCount and width are provided, ensures x + width <= columnCount
|
|
205
|
+
*
|
|
206
|
+
* ピクセル座標をグリッド座標に変換します。セルサイズで割り、小数点は切り捨てて整数にします。
|
|
207
|
+
* 結果が負にならず、columnCountとwidthが指定された場合はx + width <= columnCountを満たすようにします。
|
|
208
|
+
*/
|
|
209
|
+
function pixelsToGridPosition(pixels, baseSize, gap, columnCount, width) {
|
|
210
|
+
const gridPosition = Math.max(0, Math.floor(pixels / (baseSize + gap)));
|
|
211
|
+
if (columnCount !== void 0 && width !== void 0) {
|
|
212
|
+
const maxPosition = Math.max(0, columnCount - width);
|
|
213
|
+
return Math.min(gridPosition, maxPosition);
|
|
214
|
+
}
|
|
215
|
+
if (columnCount !== void 0) return Math.min(gridPosition, columnCount - 1);
|
|
216
|
+
return gridPosition;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Converts grid units to pixels
|
|
220
|
+
* Formula: gridUnits * baseSize + max(0, gridUnits - 1) * gap
|
|
221
|
+
* This accounts for gaps between grid cells but not after the last cell
|
|
222
|
+
*
|
|
223
|
+
* グリッド単位をピクセルに変換します。
|
|
224
|
+
* 計算式: gridUnits * baseSize + max(0, gridUnits - 1) * gap
|
|
225
|
+
* グリッドセル間の gap を考慮しますが、最後のセルの後には gap を含めません。
|
|
226
|
+
*/
|
|
227
|
+
function gridToPixels(gridUnits, baseSize, gap) {
|
|
228
|
+
return gridUnits * baseSize + Math.max(0, gridUnits - 1) * gap;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Converts grid coordinate to pixel coordinate for positioning
|
|
232
|
+
* Formula: max(0, gridCoord * (baseSize + gap))
|
|
233
|
+
* This includes the gap after each cell for proper positioning in the grid
|
|
234
|
+
*
|
|
235
|
+
* グリッド座標をピクセル座標に変換します(位置計算用)。
|
|
236
|
+
* 計算式: max(0, gridCoord * (baseSize + gap))
|
|
237
|
+
* 各セルの後に gap を含めて、グリッド内での適切な位置決めを行います。
|
|
238
|
+
*/
|
|
239
|
+
function gridPositionToPixels(gridCoord, baseSize, gap) {
|
|
240
|
+
return Math.max(0, gridCoord * (baseSize + gap));
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Gets the maximum Y coordinate of the panels
|
|
244
|
+
* パネルの最大Y座標を取得します。
|
|
245
|
+
*/
|
|
246
|
+
function getGridRowCount(panels) {
|
|
247
|
+
return Math.max(...panels.map((p) => p.y + p.h));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region src/helpers/panelDetection.ts
|
|
252
|
+
/**
|
|
253
|
+
* Detects which panels have changed position/size and marks them for animation
|
|
254
|
+
* Returns a Set of panel IDs that should be animated
|
|
255
|
+
*/
|
|
256
|
+
function detectAnimatingPanels(options) {
|
|
257
|
+
const { oldPanels, newPanels, excludePanelId } = options;
|
|
258
|
+
const animatingPanels = /* @__PURE__ */ new Set();
|
|
259
|
+
oldPanels.forEach((oldPanel) => {
|
|
260
|
+
const newPanel = newPanels.find((p) => p.id === oldPanel.id);
|
|
261
|
+
if (newPanel && oldPanel.id !== excludePanelId) {
|
|
262
|
+
if (oldPanel.x !== newPanel.x || oldPanel.y !== newPanel.y || oldPanel.w !== newPanel.w || oldPanel.h !== newPanel.h) animatingPanels.add(oldPanel.id);
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
return animatingPanels;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
//#endregion
|
|
269
|
+
//#region src/usePanelGrid.ts
|
|
270
|
+
const ANIMATION_DURATION = 300;
|
|
271
|
+
function usePanelGrid({ panels, columnCount, baseSize, gap, rearrangement }) {
|
|
272
|
+
const [state, setState] = useState({ panels });
|
|
273
|
+
const ghostPanelRef = useRef(null);
|
|
274
|
+
const animationTimeoutsRef = useRef(/* @__PURE__ */ new Set());
|
|
275
|
+
const internalState = useRef({
|
|
276
|
+
activePanelId: null,
|
|
277
|
+
draggableElements: {},
|
|
278
|
+
animatingPanels: /* @__PURE__ */ new Set()
|
|
279
|
+
}).current;
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
return () => {
|
|
282
|
+
animationTimeoutsRef.current.forEach((timeoutId) => clearTimeout(timeoutId));
|
|
283
|
+
animationTimeoutsRef.current.clear();
|
|
284
|
+
};
|
|
285
|
+
}, []);
|
|
286
|
+
const showGhostPanel = useCallback((left, top, width, height) => {
|
|
287
|
+
if (!ghostPanelRef.current) return;
|
|
288
|
+
ghostPanelRef.current.style.display = "block";
|
|
289
|
+
ghostPanelRef.current.style.left = `${left}px`;
|
|
290
|
+
ghostPanelRef.current.style.top = `${top}px`;
|
|
291
|
+
ghostPanelRef.current.style.width = `${width}px`;
|
|
292
|
+
ghostPanelRef.current.style.height = `${height}px`;
|
|
293
|
+
ghostPanelRef.current.style.outline = "1px dashed rgba(0, 0, 0, 0.2)";
|
|
294
|
+
}, []);
|
|
295
|
+
const updateGhostPanelPosition = useCallback((left, top) => {
|
|
296
|
+
if (!ghostPanelRef.current) return;
|
|
297
|
+
ghostPanelRef.current.style.left = `${left}px`;
|
|
298
|
+
ghostPanelRef.current.style.top = `${top}px`;
|
|
299
|
+
}, []);
|
|
300
|
+
const updateGhostPanelSize = useCallback((width, height) => {
|
|
301
|
+
if (!ghostPanelRef.current) return;
|
|
302
|
+
ghostPanelRef.current.style.width = `${width}px`;
|
|
303
|
+
ghostPanelRef.current.style.height = `${height}px`;
|
|
304
|
+
}, []);
|
|
305
|
+
const hideGhostPanel = useCallback(() => {
|
|
306
|
+
if (!ghostPanelRef.current) return;
|
|
307
|
+
ghostPanelRef.current.style.display = "none";
|
|
308
|
+
}, []);
|
|
309
|
+
const updatePanelsWithAnimation = useCallback((updatedPanel, currentPanels) => {
|
|
310
|
+
const nextPanels = (rearrangement || rearrangePanels)(updatedPanel, currentPanels, columnCount);
|
|
311
|
+
internalState.animatingPanels = detectAnimatingPanels({
|
|
312
|
+
oldPanels: currentPanels,
|
|
313
|
+
newPanels: nextPanels,
|
|
314
|
+
excludePanelId: updatedPanel.id
|
|
315
|
+
});
|
|
316
|
+
setState((current) => ({
|
|
317
|
+
...current,
|
|
318
|
+
panels: nextPanels
|
|
319
|
+
}));
|
|
320
|
+
const timeoutId = setTimeout(() => {
|
|
321
|
+
internalState.animatingPanels.clear();
|
|
322
|
+
animationTimeoutsRef.current.delete(timeoutId);
|
|
323
|
+
}, ANIMATION_DURATION);
|
|
324
|
+
animationTimeoutsRef.current.add(timeoutId);
|
|
325
|
+
}, [
|
|
326
|
+
columnCount,
|
|
327
|
+
internalState,
|
|
328
|
+
rearrangement
|
|
329
|
+
]);
|
|
330
|
+
const createDragHandler = useCallback((panel) => (e) => {
|
|
331
|
+
internalState.activePanelId = panel.id;
|
|
332
|
+
const draggingElement = internalState.draggableElements[panel.id];
|
|
333
|
+
if (!draggingElement) return;
|
|
334
|
+
let isDragging = true;
|
|
335
|
+
const initialX = e.clientX;
|
|
336
|
+
const initialY = e.clientY;
|
|
337
|
+
const offsetX = draggingElement.offsetLeft;
|
|
338
|
+
const offsetY = draggingElement.offsetTop;
|
|
339
|
+
const originalTransition = draggingElement.style.transition;
|
|
340
|
+
draggingElement.classList.add("panelgrid-panel--dragging");
|
|
341
|
+
draggingElement.style.transition = "";
|
|
342
|
+
showGhostPanel(offsetX, offsetY, draggingElement.offsetWidth, draggingElement.offsetHeight);
|
|
343
|
+
const mouseUpListenerCtrl = new AbortController();
|
|
344
|
+
const mouseMoveListenerCtrl = new AbortController();
|
|
345
|
+
const onMouseMove = (e$1) => {
|
|
346
|
+
if (!isDragging) return;
|
|
347
|
+
if (!draggingElement) return;
|
|
348
|
+
const currentX = e$1.clientX;
|
|
349
|
+
const currentY = e$1.clientY;
|
|
350
|
+
const deltaX = currentX - initialX;
|
|
351
|
+
const deltaY = currentY - initialY;
|
|
352
|
+
draggingElement.style.left = offsetX + deltaX + "px";
|
|
353
|
+
draggingElement.style.top = offsetY + deltaY + "px";
|
|
354
|
+
const droppedLeft = offsetX + deltaX;
|
|
355
|
+
const droppedTop = offsetY + deltaY;
|
|
356
|
+
const nextGridX = pixelsToGridPosition(droppedLeft, baseSize, gap, columnCount, panel.w);
|
|
357
|
+
const nextGridY = pixelsToGridPosition(droppedTop, baseSize, gap);
|
|
358
|
+
updateGhostPanelPosition(gridPositionToPixels(nextGridX, baseSize, gap), gridPositionToPixels(nextGridY, baseSize, gap));
|
|
359
|
+
e$1.preventDefault();
|
|
360
|
+
};
|
|
361
|
+
const onMouseUp = () => {
|
|
362
|
+
if (!draggingElement) return;
|
|
363
|
+
isDragging = false;
|
|
364
|
+
draggingElement.classList.remove("panelgrid-panel--dragging");
|
|
365
|
+
hideGhostPanel();
|
|
366
|
+
const droppedLeft = parseFloat(draggingElement.style.left) || 0;
|
|
367
|
+
const droppedTop = parseFloat(draggingElement.style.top) || 0;
|
|
368
|
+
const nextGridX = pixelsToGridPosition(droppedLeft, baseSize, gap, columnCount, panel.w);
|
|
369
|
+
const nextGridY = pixelsToGridPosition(droppedTop, baseSize, gap);
|
|
370
|
+
applySnapAnimation({
|
|
371
|
+
element: draggingElement,
|
|
372
|
+
droppedLeft,
|
|
373
|
+
droppedTop,
|
|
374
|
+
nextLeft: gridPositionToPixels(nextGridX, baseSize, gap),
|
|
375
|
+
nextTop: gridPositionToPixels(nextGridY, baseSize, gap),
|
|
376
|
+
originalTransition
|
|
377
|
+
});
|
|
378
|
+
updatePanelsWithAnimation({
|
|
379
|
+
...panel,
|
|
380
|
+
x: nextGridX,
|
|
381
|
+
y: nextGridY
|
|
382
|
+
}, state.panels);
|
|
383
|
+
internalState.activePanelId = null;
|
|
384
|
+
mouseMoveListenerCtrl.abort();
|
|
385
|
+
mouseUpListenerCtrl.abort();
|
|
386
|
+
};
|
|
387
|
+
document.addEventListener("mousemove", onMouseMove, { signal: mouseMoveListenerCtrl.signal });
|
|
388
|
+
document.addEventListener("mouseup", onMouseUp, { signal: mouseUpListenerCtrl.signal });
|
|
389
|
+
}, [
|
|
390
|
+
baseSize,
|
|
391
|
+
gap,
|
|
392
|
+
internalState,
|
|
393
|
+
state.panels,
|
|
394
|
+
updatePanelsWithAnimation,
|
|
395
|
+
showGhostPanel,
|
|
396
|
+
updateGhostPanelPosition,
|
|
397
|
+
hideGhostPanel,
|
|
398
|
+
columnCount
|
|
399
|
+
]);
|
|
400
|
+
const createResizeHandler = useCallback((panel) => (e) => {
|
|
401
|
+
e.stopPropagation();
|
|
402
|
+
let isResizing = true;
|
|
403
|
+
internalState.activePanelId = panel.id;
|
|
404
|
+
const draggingElement = internalState.draggableElements[panel.id];
|
|
405
|
+
if (!draggingElement) return;
|
|
406
|
+
const startX = e.clientX;
|
|
407
|
+
const startY = e.clientY;
|
|
408
|
+
const initialWidth = draggingElement.offsetWidth;
|
|
409
|
+
const initialHeight = draggingElement.offsetHeight;
|
|
410
|
+
const initialZIndex = draggingElement.style.zIndex;
|
|
411
|
+
draggingElement.style.cursor = "nwse-resize";
|
|
412
|
+
draggingElement.style.transition = "";
|
|
413
|
+
showGhostPanel(draggingElement.offsetLeft, draggingElement.offsetTop, initialWidth, initialHeight);
|
|
414
|
+
const mouseMoveController = new AbortController();
|
|
415
|
+
const mouseUpController = new AbortController();
|
|
416
|
+
const onMouseMove = (e$1) => {
|
|
417
|
+
if (!isResizing) return;
|
|
418
|
+
if (!draggingElement) return;
|
|
419
|
+
const deltaX = e$1.clientX - startX;
|
|
420
|
+
const deltaY = e$1.clientY - startY;
|
|
421
|
+
draggingElement.style.width = `${initialWidth + deltaX}px`;
|
|
422
|
+
draggingElement.style.height = `${initialHeight + deltaY}px`;
|
|
423
|
+
draggingElement.style.zIndex = "calc(infinity)";
|
|
424
|
+
const newWidth = initialWidth + deltaX;
|
|
425
|
+
const newHeight = initialHeight + deltaY;
|
|
426
|
+
const nextGridW = pixelsToGridSize(newWidth, baseSize, gap, columnCount, panel.x);
|
|
427
|
+
const nextGridH = pixelsToGridSize(newHeight, baseSize, gap);
|
|
428
|
+
updateGhostPanelSize(gridToPixels(nextGridW, baseSize, gap), gridToPixels(nextGridH, baseSize, gap));
|
|
429
|
+
};
|
|
430
|
+
const onMouseUp = () => {
|
|
431
|
+
if (!draggingElement) return;
|
|
432
|
+
hideGhostPanel();
|
|
433
|
+
const rect = draggingElement.getBoundingClientRect();
|
|
434
|
+
const nextGridW = pixelsToGridSize(rect.width, baseSize, gap, columnCount, panel.x);
|
|
435
|
+
const nextGridH = pixelsToGridSize(rect.height, baseSize, gap);
|
|
436
|
+
const width = gridToPixels(nextGridW, baseSize, gap);
|
|
437
|
+
const height = gridToPixels(nextGridH, baseSize, gap);
|
|
438
|
+
draggingElement.style.width = `${rect.width}px`;
|
|
439
|
+
draggingElement.style.height = `${rect.height}px`;
|
|
440
|
+
draggingElement.style.cursor = "default";
|
|
441
|
+
draggingElement.style.transition = "";
|
|
442
|
+
window.requestAnimationFrame(() => {
|
|
443
|
+
draggingElement.style.width = `${width}px`;
|
|
444
|
+
draggingElement.style.height = `${height}px`;
|
|
445
|
+
draggingElement.style.zIndex = initialZIndex;
|
|
446
|
+
draggingElement.style.transition = "width 0.1s ease-out, height 0.1s ease-out";
|
|
447
|
+
});
|
|
448
|
+
updatePanelsWithAnimation({
|
|
449
|
+
...panel,
|
|
450
|
+
w: nextGridW,
|
|
451
|
+
h: nextGridH
|
|
452
|
+
}, state.panels);
|
|
453
|
+
isResizing = false;
|
|
454
|
+
internalState.activePanelId = null;
|
|
455
|
+
mouseMoveController.abort();
|
|
456
|
+
mouseUpController.abort();
|
|
457
|
+
};
|
|
458
|
+
document.addEventListener("mousemove", onMouseMove, { signal: mouseMoveController.signal });
|
|
459
|
+
document.addEventListener("mouseup", onMouseUp, { signal: mouseUpController.signal });
|
|
460
|
+
}, [
|
|
461
|
+
baseSize,
|
|
462
|
+
gap,
|
|
463
|
+
internalState,
|
|
464
|
+
state.panels,
|
|
465
|
+
updatePanelsWithAnimation,
|
|
466
|
+
showGhostPanel,
|
|
467
|
+
updateGhostPanelSize,
|
|
468
|
+
hideGhostPanel,
|
|
469
|
+
columnCount
|
|
470
|
+
]);
|
|
471
|
+
const createRefCallback = useCallback((panelId) => (element) => {
|
|
472
|
+
if (!element) return;
|
|
473
|
+
if (!internalState.draggableElements[panelId]) internalState.draggableElements[panelId] = element;
|
|
474
|
+
}, [internalState]);
|
|
475
|
+
return {
|
|
476
|
+
panels: useMemo(() => {
|
|
477
|
+
return state.panels.map((panel) => {
|
|
478
|
+
const isAnimating = internalState.animatingPanels.has(panel.id);
|
|
479
|
+
const isActive = internalState.activePanelId === panel.id;
|
|
480
|
+
return {
|
|
481
|
+
panelProps: {
|
|
482
|
+
key: panel.id,
|
|
483
|
+
x: panel.x,
|
|
484
|
+
y: panel.y,
|
|
485
|
+
w: panel.w,
|
|
486
|
+
h: panel.h,
|
|
487
|
+
style: {
|
|
488
|
+
top: gridPositionToPixels(panel.y, baseSize, gap),
|
|
489
|
+
left: gridPositionToPixels(panel.x, baseSize, gap),
|
|
490
|
+
width: gridToPixels(panel.w, baseSize, gap),
|
|
491
|
+
height: gridToPixels(panel.h, baseSize, gap),
|
|
492
|
+
transition: isAnimating && !isActive ? "top 0.3s ease-out, left 0.3s ease-out, width 0.3s ease-out, height 0.3s ease-out" : void 0
|
|
493
|
+
},
|
|
494
|
+
ref: createRefCallback(panel.id),
|
|
495
|
+
onMouseDown: createDragHandler(panel)
|
|
496
|
+
},
|
|
497
|
+
resizeHandleProps: { onMouseDown: createResizeHandler(panel) }
|
|
498
|
+
};
|
|
499
|
+
});
|
|
500
|
+
}, [
|
|
501
|
+
state.panels,
|
|
502
|
+
baseSize,
|
|
503
|
+
gap,
|
|
504
|
+
internalState.animatingPanels,
|
|
505
|
+
internalState.activePanelId,
|
|
506
|
+
createRefCallback,
|
|
507
|
+
createDragHandler,
|
|
508
|
+
createResizeHandler
|
|
509
|
+
]),
|
|
510
|
+
ghostPanelRef,
|
|
511
|
+
addPanel: useCallback((panel) => {
|
|
512
|
+
setState((current) => {
|
|
513
|
+
const newPosition = findNewPositionToAddPanel(panel, current.panels, columnCount);
|
|
514
|
+
const newPanel = {
|
|
515
|
+
id: panel.id || Math.random().toString(36).substring(2, 15),
|
|
516
|
+
x: newPosition.x,
|
|
517
|
+
y: newPosition.y,
|
|
518
|
+
w: panel.w || 1,
|
|
519
|
+
h: panel.h || 1
|
|
520
|
+
};
|
|
521
|
+
return {
|
|
522
|
+
...current,
|
|
523
|
+
panels: [...current.panels, newPanel]
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
}, [columnCount]),
|
|
527
|
+
removePanel: useCallback((id) => {
|
|
528
|
+
setState((current) => ({
|
|
529
|
+
...current,
|
|
530
|
+
panels: current.panels.filter((panel) => panel.id !== id)
|
|
531
|
+
}));
|
|
532
|
+
}, []),
|
|
533
|
+
exportState: useCallback(() => {
|
|
534
|
+
return state.panels;
|
|
535
|
+
}, [state.panels])
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
//#endregion
|
|
540
|
+
//#region src/PanelGridProvider.tsx
|
|
541
|
+
const PanelGridStateContext = createContext(void 0);
|
|
542
|
+
const PanelGridControlsContext = createContext(void 0);
|
|
543
|
+
function PanelGridProvider({ panels: initialPanels, columnCount, gap, children, rearrangement }) {
|
|
544
|
+
const [baseSize, setBaseSize] = useState(null);
|
|
545
|
+
const { panels, addPanel, removePanel, exportState, ghostPanelRef } = usePanelGrid({
|
|
546
|
+
panels: initialPanels,
|
|
547
|
+
columnCount,
|
|
548
|
+
baseSize: baseSize || 256,
|
|
549
|
+
gap,
|
|
550
|
+
rearrangement
|
|
551
|
+
});
|
|
552
|
+
return /* @__PURE__ */ jsx(PanelGridStateContext.Provider, {
|
|
553
|
+
value: {
|
|
554
|
+
panels,
|
|
555
|
+
columnCount,
|
|
556
|
+
gap,
|
|
557
|
+
baseSize,
|
|
558
|
+
ghostPanelRef
|
|
559
|
+
},
|
|
560
|
+
children: /* @__PURE__ */ jsx(PanelGridControlsContext.Provider, {
|
|
561
|
+
value: {
|
|
562
|
+
setBaseSize,
|
|
563
|
+
addPanel,
|
|
564
|
+
removePanel,
|
|
565
|
+
exportState
|
|
566
|
+
},
|
|
567
|
+
children
|
|
568
|
+
})
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
function usePanelGridState() {
|
|
572
|
+
const context = useContext(PanelGridStateContext);
|
|
573
|
+
if (!context) throw new Error("usePanelGridState must be used within a PanelGridProvider");
|
|
574
|
+
return context;
|
|
575
|
+
}
|
|
576
|
+
function usePanelGridControls() {
|
|
577
|
+
const context = useContext(PanelGridControlsContext);
|
|
578
|
+
if (!context) throw new Error("usePanelGridControls must be used within a PanelGridProvider");
|
|
579
|
+
return context;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
//#endregion
|
|
583
|
+
//#region src/PanelGridRenderer.tsx
|
|
584
|
+
function PanelGridRenderer({ itemRenderer: ItemRenderer }) {
|
|
585
|
+
const { panels, columnCount, gap, baseSize, ghostPanelRef } = usePanelGridState();
|
|
586
|
+
const { setBaseSize } = usePanelGridControls();
|
|
587
|
+
const containerRef = useRef(null);
|
|
588
|
+
const rowCount = getGridRowCount(panels.map(({ panelProps: p }) => ({
|
|
589
|
+
id: p.key,
|
|
590
|
+
x: p.x,
|
|
591
|
+
y: p.y,
|
|
592
|
+
w: p.w,
|
|
593
|
+
h: p.h
|
|
594
|
+
})));
|
|
595
|
+
const count = Math.max(columnCount * (rowCount + 1), columnCount * columnCount);
|
|
596
|
+
useLayoutEffect(() => {
|
|
597
|
+
if (!containerRef.current) return;
|
|
598
|
+
const observer = new ResizeObserver((entries) => {
|
|
599
|
+
const [entry] = entries;
|
|
600
|
+
const rect = entry.contentRect;
|
|
601
|
+
setBaseSize(Math.floor((rect.width - gap * (columnCount - 1)) / columnCount));
|
|
602
|
+
});
|
|
603
|
+
observer.observe(containerRef.current);
|
|
604
|
+
return () => observer.disconnect();
|
|
605
|
+
}, [
|
|
606
|
+
columnCount,
|
|
607
|
+
gap,
|
|
608
|
+
setBaseSize
|
|
609
|
+
]);
|
|
610
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
611
|
+
className: "panelgrid-renderer",
|
|
612
|
+
style: {
|
|
613
|
+
"--column-count": `${columnCount}`,
|
|
614
|
+
"--gap": `${gap}px`,
|
|
615
|
+
opacity: baseSize ? 1 : 0
|
|
616
|
+
},
|
|
617
|
+
ref: containerRef,
|
|
618
|
+
children: [
|
|
619
|
+
Array.from({ length: count }).map((_, i) => {
|
|
620
|
+
return /* @__PURE__ */ jsx("div", { className: "panelgrid-panel-placeholder" }, i);
|
|
621
|
+
}),
|
|
622
|
+
/* @__PURE__ */ jsx("div", {
|
|
623
|
+
className: "panelgrid-panel-ghost",
|
|
624
|
+
ref: ghostPanelRef
|
|
625
|
+
}),
|
|
626
|
+
panels.map((panel) => {
|
|
627
|
+
const { panelProps: _panelProps, resizeHandleProps } = panel;
|
|
628
|
+
const { key,...panelProps } = _panelProps;
|
|
629
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
630
|
+
className: "panelgrid-panel",
|
|
631
|
+
...panelProps,
|
|
632
|
+
children: [/* @__PURE__ */ jsx(ItemRenderer, { id: key }), /* @__PURE__ */ jsx("span", {
|
|
633
|
+
className: "panelgrid-resize-handle",
|
|
634
|
+
...resizeHandleProps
|
|
635
|
+
})]
|
|
636
|
+
}, key);
|
|
637
|
+
})
|
|
638
|
+
]
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
//#endregion
|
|
643
|
+
export { PanelGridProvider, PanelGridRenderer, rearrangePanels, usePanelGridControls, usePanelGridState };
|
|
644
|
+
//# sourceMappingURL=index.mjs.map
|