@aiquants/drag-drop-panels 0.7.2 → 0.8.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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -10
  3. package/dist/drag-drop/hooks/useCustomDragHandlers.d.ts.map +1 -1
  4. package/dist/index.es.js +88 -88
  5. package/dist/index.umd.js +2 -2
  6. package/dist/styles/drag-drop-panels.standalone.css +3 -0
  7. package/package.json +11 -4
  8. package/src/DragDropLayout.tsx +751 -0
  9. package/src/drag-drop/ColumnControlPanel.tsx +123 -0
  10. package/src/drag-drop/ColumnInsertPanel.tsx +106 -0
  11. package/src/drag-drop/DebugComponents.tsx +222 -0
  12. package/src/drag-drop/DebugControlPanel.tsx +200 -0
  13. package/src/drag-drop/DebugOverlay.tsx +250 -0
  14. package/src/drag-drop/DragDropLayout.css +130 -0
  15. package/src/drag-drop/ResizeHandle.tsx +208 -0
  16. package/src/drag-drop/ResponsiveControl.tsx +33 -0
  17. package/src/drag-drop/hooks/dragGhostScale.spec.tsx +153 -0
  18. package/src/drag-drop/hooks/useColumnLayout.ts +457 -0
  19. package/src/drag-drop/hooks/useCustomDragHandlers.tsx +333 -0
  20. package/src/drag-drop/hooks/useDragDropColumns.tsx +446 -0
  21. package/src/drag-drop/hooks/useDragDropState.tsx +200 -0
  22. package/src/drag-drop/hooks/useFitScale.ts +48 -0
  23. package/src/drag-drop/hooks/useMouseTracking.tsx +102 -0
  24. package/src/drag-drop/hooks/useNormalDragHandlers.tsx +427 -0
  25. package/src/drag-drop/hooks/usePanelManagement.tsx +238 -0
  26. package/src/drag-drop/hooks/useResponsiveColumns.ts +210 -0
  27. package/src/drag-drop/resize-handle.css +71 -0
  28. package/src/drag-drop/types.ts +248 -0
  29. package/src/drag-drop/utils/columnIdUtils.ts +104 -0
  30. package/src/drag-drop/utils/edgeScrollUtils.ts +124 -0
  31. package/src/drag-drop/utils/simple-logger.ts +162 -0
  32. package/src/index.ts +60 -0
  33. package/src/styles/standalone.entry.css +27 -0
@@ -0,0 +1,153 @@
1
+ import { act, renderHook } from "@testing-library/react"
2
+ import type { DragEvent } from "react"
3
+ import { beforeEach, describe, expect, it, vi } from "vitest"
4
+ import { DEFAULT_DEBUG_CONFIG } from "../types"
5
+ import { useNormalDragHandlers } from "./useNormalDragHandlers"
6
+ import { usePanelManagement } from "./usePanelManagement"
7
+
8
+ /**
9
+ * 縮小表示 (scale < 1) 時にドラッグゴーストが同倍率で縮小されることの回帰テスト。
10
+ *
11
+ * 過去バグ: scale をコールバックのクロージャで参照し useCallback 依存にも ref にも入れていなかったため、
12
+ * 初回 render の値 (=1) で凍結し `if (scale < 1)` ブロックが恒久 dead code 化していた。
13
+ * → 本テストは「mount 時 scale=1 → rerender で 0.5」に変え、かつ scale 以外の props 参照を固定して
14
+ * ハンドラが再生成されない状況を作る。旧実装ではゴーストが生成されず fail する。
15
+ */
16
+
17
+ const NATURAL_W = 360
18
+ const NATURAL_H = 200
19
+
20
+ const mockRect = (left: number, top: number, w: number, h: number): DOMRect => ({ left, top, width: w, height: h, right: left + w, bottom: top + h, x: left, y: top, toJSON: () => ({}) }) as DOMRect
21
+
22
+ const makePanel = (id: string, rect: DOMRect): HTMLElement => {
23
+ const el = document.createElement("div")
24
+ el.setAttribute("data-panel-id", id)
25
+ Object.defineProperty(el, "offsetWidth", { value: NATURAL_W, configurable: true })
26
+ Object.defineProperty(el, "offsetHeight", { value: NATURAL_H, configurable: true })
27
+ el.getBoundingClientRect = () => rect
28
+ document.body.appendChild(el)
29
+ return el
30
+ }
31
+
32
+ beforeEach(() => {
33
+ document.body.innerHTML = ""
34
+ })
35
+
36
+ describe("useNormalDragHandlers — native drag ghost scaling", () => {
37
+ // scale 以外は参照を固定する (これらが変わるとハンドラが再生成され、旧バグでも scale を拾えてしまい回帰検出にならない)
38
+ const stable = {
39
+ debugConfig: DEFAULT_DEBUG_CONFIG,
40
+ dragState: { isDragging: false, draggedPanel: null, dragOverColumn: null },
41
+ customDrag: { isActive: false, panelId: "", startX: 0, startY: 0 },
42
+ dragModes: {} as Record<string, "normal" | "custom">,
43
+ columnPanels: { col1: ["p1"] },
44
+ dragOverPosition: null,
45
+ originalPanelPosition: null,
46
+ setDragState: vi.fn(),
47
+ setDragOverPosition: vi.fn(),
48
+ setOriginalPanelPosition: vi.fn(),
49
+ performPanelMove: vi.fn(),
50
+ showMousePosition: vi.fn(),
51
+ updateMousePosition: vi.fn(),
52
+ resetDragState: vi.fn(),
53
+ handlePlaceholderDragEnter: vi.fn(),
54
+ handlePlaceholderDragLeave: vi.fn(),
55
+ }
56
+ // biome-ignore lint/suspicious/noExplicitAny: テスト用の緩い props 型
57
+ const props = (scale: number): any => ({ ...stable, scale })
58
+
59
+ const fireDragStart = (handleDragStart: (e: DragEvent, id: string) => void, panel: HTMLElement) => {
60
+ const captured: { img?: HTMLElement; x?: number; y?: number } = {}
61
+ const event = {
62
+ currentTarget: panel,
63
+ clientX: 90,
64
+ clientY: 15,
65
+ preventDefault: vi.fn(),
66
+ dataTransfer: {
67
+ setData: vi.fn(),
68
+ effectAllowed: "",
69
+ setDragImage: vi.fn((img: HTMLElement, x: number, y: number) => {
70
+ captured.img = img
71
+ captured.x = x
72
+ captured.y = y
73
+ }),
74
+ },
75
+ }
76
+ handleDragStart(event as unknown as DragEvent, "p1")
77
+ return captured
78
+ }
79
+
80
+ it("does NOT build a custom ghost at scale = 1 (browser default is used)", () => {
81
+ const panel = makePanel("p1", mockRect(0, 0, NATURAL_W, NATURAL_H))
82
+ const { result } = renderHook((p) => useNormalDragHandlers(p), { initialProps: props(1) })
83
+ const captured = fireDragStart(result.current.handleDragStart, panel)
84
+ expect(captured.img).toBeUndefined()
85
+ })
86
+
87
+ it("REGRESSION: builds a ghost sized to natural × scale after scale updates post-mount (no stale closure)", () => {
88
+ // 画面上 (縮小後) の rect は 180×100、レイアウト原寸は 360×200
89
+ const panel = makePanel("p1", mockRect(0, 0, NATURAL_W * 0.5, NATURAL_H * 0.5))
90
+ const { result, rerender } = renderHook((p) => useNormalDragHandlers(p), { initialProps: props(1) })
91
+ const handlerBefore = result.current.handleDragStart
92
+ rerender(props(0.5)) // scale だけ変更 (他 props は参照固定)
93
+
94
+ // ハンドラは再生成されない = scale を ref 経由で読んでいる証拠。
95
+ // scale を useCallback 依存に入れると再生成され、この安定性が壊れる (= 本テストの回帰検出条件を固定する)
96
+ expect(result.current.handleDragStart).toBe(handlerBefore)
97
+
98
+ const captured = fireDragStart(result.current.handleDragStart, panel)
99
+
100
+ // 外枠 = 縮小後サイズ (raster キャンバスを縮小後に一致させる)
101
+ expect(captured.img).toBeDefined()
102
+ expect(captured.img?.style.width).toBe(`${NATURAL_W * 0.5}px`) // 180px
103
+ expect(captured.img?.style.height).toBe(`${NATURAL_H * 0.5}px`) // 100px
104
+
105
+ // 内側クローン = 原寸 + transform:scale(0.5) / origin top-left
106
+ const inner = captured.img?.firstElementChild as HTMLElement
107
+ expect(inner.style.width).toBe(`${NATURAL_W}px`)
108
+ expect(inner.style.transform).toBe("scale(0.5)")
109
+ expect(inner.style.transformOrigin).toBe("top left")
110
+ // 同一性属性は除去 (画面外クローンが [data-panel-id] を重複させない)
111
+ expect(inner.getAttribute("data-panel-id")).toBeNull()
112
+
113
+ // ホットスポット = 画面上 (縮小後) 要素内のカーソル位置
114
+ expect(captured.x).toBe(90)
115
+ expect(captured.y).toBe(15)
116
+ })
117
+ })
118
+
119
+ describe("usePanelManagement — custom drag ghost scaling", () => {
120
+ it("REGRESSION: custom ghost uses offsetWidth (no scale²) and reads live scale after update", () => {
121
+ const panel = makePanel("p1", mockRect(50, 60, NATURAL_W * 0.5, NATURAL_H * 0.5))
122
+ // createDragImage/updateDragImagePosition は元々安定参照。rerender で scale だけ 1 → 0.5 に更新する
123
+ const { result, rerender } = renderHook((scale: number) => usePanelManagement({}, scale), { initialProps: 1 })
124
+ const createBefore = result.current.createDragImage
125
+ const updateBefore = result.current.updateDragImagePosition
126
+ rerender(0.5)
127
+
128
+ // 両ハンドラは安定参照のまま (scale は ref 経由)。依存に scale を入れると再生成され壊れる
129
+ expect(result.current.createDragImage).toBe(createBefore)
130
+ expect(result.current.updateDragImagePosition).toBe(updateBefore)
131
+
132
+ act(() => {
133
+ result.current.createDragImage("p1", 10, 10)
134
+ })
135
+
136
+ const clone = document.getElementById("drag-image-p1")
137
+ expect(clone).not.toBeNull()
138
+ // 幅は offsetWidth(360) を使う。rect.width(180) を使うと transform:scale(0.5) で二重縮小 (scale²) になる
139
+ expect(clone?.style.width).toBe(`${NATURAL_W}px`)
140
+ expect(clone?.style.transformOrigin).toBe("top left")
141
+ expect(clone?.style.transform).toContain("scale(0.5)")
142
+ // data-panel-id は除去済 (getPanelElement の querySelector がクローンを誤検出しない)
143
+ expect(clone?.getAttribute("data-panel-id")).toBeNull()
144
+ // 元パネルは残っている (クローンに奪われていない)
145
+ expect(document.querySelector('[data-panel-id="p1"]')).toBe(panel)
146
+
147
+ act(() => {
148
+ result.current.updateDragImagePosition(100, 100)
149
+ })
150
+ // 移動中は掴んだ倍率 × 1.05。旧実装 (stale/固定 1) なら scale(1.05) になり fail する
151
+ expect(clone?.style.transform).toContain(`scale(${0.5 * 1.05})`)
152
+ })
153
+ })
@@ -0,0 +1,457 @@
1
+ /**
2
+ * @fileoverview This file defines the `useColumnLayout` custom hook, which is the core logic for managing
3
+ * the state and interactions of a dynamic, multi-column, drag-and-drop layout system.
4
+ * It handles column and panel management, resizing, and responsive adjustments.
5
+ *
6
+ * このファイルは `useColumnLayout` カスタムフックを定義します。これは、動的な複数カラムの
7
+ * ドラッグ&ドロップレイアウトシステムの、状態とインタラクションを管理するためのコアロジックです。
8
+ * カラムとパネルの管理、リサイズ、レスポンシブ調整などを扱います。
9
+ */
10
+ import { useCallback, useEffect, useRef, useState } from "react"
11
+ import { debugLog } from "../DebugComponents"
12
+ import type { DebugConfig } from "../types"
13
+ import { DEFAULT_COLUMN_ID_CONFIG, generateColumnIds, normalizeColumnPanels } from "../utils/columnIdUtils"
14
+
15
+ export interface ColumnConfig {
16
+ id: string
17
+ title: string
18
+ minWidth?: number
19
+ maxWidth?: number
20
+ initialWidth?: number
21
+ resizable?: boolean
22
+ collapsible?: boolean
23
+ }
24
+
25
+ export interface ColumnLayoutState {
26
+ columns: ColumnConfig[]
27
+ columnWidths: Record<string, number>
28
+ isResizing: boolean
29
+ resizingColumn: string | null
30
+ columnPanels: Record<string, string[]>
31
+ showInsertPlaceholders: boolean
32
+ }
33
+
34
+ export interface UseColumnLayoutProps {
35
+ initialColumns: ColumnConfig[]
36
+ initialPanels: Record<string, string[]>
37
+ debug?: DebugConfig
38
+ onLayoutChange?: (layout: ColumnLayoutState) => void
39
+ }
40
+
41
+ export interface UseColumnLayoutReturn {
42
+ layoutState: ColumnLayoutState
43
+ addColumn: (config: ColumnConfig, position?: number) => void
44
+ removeColumn: (columnId: string) => void
45
+ moveColumn: (fromIndex: number, toIndex: number) => void
46
+ startResize: (columnId: string, startX: number) => void
47
+ handleResize: (currentX: number) => void
48
+ endResize: () => void
49
+ setColumnWidth: (columnId: string, width: number) => void
50
+ resetColumnWidths: () => void
51
+ movePanel: (panelId: string, fromColumn: string, toColumn: string, insertIndex: number) => void
52
+ addPanel: (panelId: string, columnId: string, insertIndex?: number) => void
53
+ removePanel: (panelId: string) => void
54
+ updateColumnCount: (count: number) => void
55
+ autoBalanceColumns: () => void
56
+ getColumnById: (columnId: string) => ColumnConfig | undefined
57
+ getTotalWidth: () => number
58
+ getColumnElement: (columnId: string) => HTMLElement | null
59
+ setShowInsertPlaceholders: (show: boolean) => void
60
+ }
61
+
62
+ export const useColumnLayout = ({ initialColumns, initialPanels, debug = {}, onLayoutChange }: UseColumnLayoutProps): UseColumnLayoutReturn => {
63
+ const [layoutState, setLayoutState] = useState<ColumnLayoutState>(() => {
64
+ const initialWidths: Record<string, number> = {}
65
+ for (const col of initialColumns) {
66
+ initialWidths[col.id] = col.initialWidth || 400
67
+ }
68
+
69
+ return {
70
+ columns: initialColumns,
71
+ columnWidths: initialWidths,
72
+ isResizing: false,
73
+ resizingColumn: null,
74
+ columnPanels: initialPanels,
75
+ showInsertPlaceholders: false,
76
+ }
77
+ })
78
+
79
+ const resizeStartX = useRef<number>(0)
80
+ const initialColumnWidth = useRef<number>(0)
81
+
82
+ useEffect(() => {
83
+ onLayoutChange?.(layoutState)
84
+ }, [layoutState, onLayoutChange])
85
+
86
+ const addColumn = useCallback(
87
+ (config: ColumnConfig, position?: number) => {
88
+ debugLog(debug, 1, "カラム追加:", config.id, "at position:", position)
89
+
90
+ setLayoutState((prev) => {
91
+ const newColumns = [...prev.columns]
92
+ const insertPos = position ?? newColumns.length
93
+ newColumns.splice(insertPos, 0, config)
94
+
95
+ const newColumnWidths = {
96
+ ...prev.columnWidths,
97
+ [config.id]: config.initialWidth || 400,
98
+ }
99
+
100
+ const newColumnPanels = {
101
+ ...prev.columnPanels,
102
+ [config.id]: [],
103
+ }
104
+
105
+ return {
106
+ ...prev,
107
+ columns: newColumns,
108
+ columnWidths: newColumnWidths,
109
+ columnPanels: newColumnPanels,
110
+ }
111
+ })
112
+ },
113
+ [debug],
114
+ )
115
+
116
+ const removeColumn = useCallback(
117
+ (columnId: string) => {
118
+ debugLog(debug, 1, "カラム削除:", columnId)
119
+
120
+ setLayoutState((prev) => {
121
+ const panelsToMove = prev.columnPanels[columnId] || []
122
+ const remainingColumns = prev.columns.filter((col) => col.id !== columnId)
123
+
124
+ if (remainingColumns.length === 0) {
125
+ debugLog(debug, 1, "警告: 最後のカラムを削除しようとしています")
126
+ return prev
127
+ }
128
+
129
+ const firstColumnId = remainingColumns[0].id
130
+ const newColumnPanels = { ...prev.columnPanels }
131
+
132
+ if (panelsToMove.length > 0) {
133
+ newColumnPanels[firstColumnId] = [...newColumnPanels[firstColumnId], ...panelsToMove]
134
+ }
135
+
136
+ delete newColumnPanels[columnId]
137
+
138
+ const newColumnWidths = { ...prev.columnWidths }
139
+ delete newColumnWidths[columnId]
140
+
141
+ return {
142
+ ...prev,
143
+ columns: remainingColumns,
144
+ columnWidths: newColumnWidths,
145
+ columnPanels: newColumnPanels,
146
+ }
147
+ })
148
+ },
149
+ [debug],
150
+ )
151
+
152
+ const moveColumn = useCallback(
153
+ (fromIndex: number, toIndex: number) => {
154
+ debugLog(debug, 1, "カラム移動:", fromIndex, "->", toIndex)
155
+
156
+ setLayoutState((prev) => {
157
+ const newColumns = [...prev.columns]
158
+ const [movedColumn] = newColumns.splice(fromIndex, 1)
159
+ newColumns.splice(toIndex, 0, movedColumn)
160
+
161
+ return {
162
+ ...prev,
163
+ columns: newColumns,
164
+ }
165
+ })
166
+ },
167
+ [debug],
168
+ )
169
+
170
+ const startResize = useCallback(
171
+ (columnId: string, startX: number) => {
172
+ debugLog(debug, 2, "リサイズ開始:", columnId, "at X:", startX)
173
+
174
+ resizeStartX.current = startX
175
+ initialColumnWidth.current = layoutState.columnWidths[columnId] || 300
176
+
177
+ setLayoutState((prev) => ({
178
+ ...prev,
179
+ isResizing: true,
180
+ resizingColumn: columnId,
181
+ }))
182
+ },
183
+ [debug, layoutState.columnWidths],
184
+ )
185
+
186
+ const handleResize = useCallback(
187
+ (currentX: number) => {
188
+ const { resizingColumn, isResizing } = layoutState
189
+ if (!(isResizing && resizingColumn)) return
190
+
191
+ const deltaX = currentX - resizeStartX.current
192
+ const newWidth = Math.max(100, Math.min(800, initialColumnWidth.current + deltaX))
193
+
194
+ debugLog(debug, 2, "リサイズ中:", resizingColumn, "new width:", newWidth)
195
+
196
+ setLayoutState((prev) => ({
197
+ ...prev,
198
+ columnWidths: {
199
+ ...prev.columnWidths,
200
+ [resizingColumn]: newWidth,
201
+ },
202
+ }))
203
+ },
204
+ [debug, layoutState],
205
+ )
206
+
207
+ const endResize = useCallback(() => {
208
+ debugLog(debug, 2, "リサイズ終了")
209
+
210
+ setLayoutState((prev) => ({
211
+ ...prev,
212
+ isResizing: false,
213
+ resizingColumn: null,
214
+ }))
215
+ }, [debug])
216
+
217
+ const setColumnWidth = useCallback(
218
+ (columnId: string, width: number) => {
219
+ debugLog(debug, 2, "カラム幅設定:", columnId, width)
220
+
221
+ setLayoutState((prev) => ({
222
+ ...prev,
223
+ columnWidths: {
224
+ ...prev.columnWidths,
225
+ [columnId]: Math.max(100, Math.min(800, width)),
226
+ },
227
+ }))
228
+ },
229
+ [debug],
230
+ )
231
+
232
+ const resetColumnWidths = useCallback(() => {
233
+ debugLog(debug, 1, "カラム幅リセット")
234
+
235
+ setLayoutState((prev) => {
236
+ const resetWidths: Record<string, number> = {}
237
+ for (const col of prev.columns) {
238
+ resetWidths[col.id] = col.initialWidth || 400
239
+ }
240
+
241
+ return {
242
+ ...prev,
243
+ columnWidths: resetWidths,
244
+ }
245
+ })
246
+ }, [debug])
247
+
248
+ const movePanel = useCallback(
249
+ (panelId: string, fromColumn: string, toColumn: string, insertIndex: number) => {
250
+ debugLog(debug, 1, "パネル移動:", panelId, "from", fromColumn, "to", toColumn, "at", insertIndex)
251
+
252
+ setLayoutState((prev) => {
253
+ const newColumnPanels = { ...prev.columnPanels }
254
+
255
+ if (fromColumn in newColumnPanels) {
256
+ newColumnPanels[fromColumn] = newColumnPanels[fromColumn].filter((id) => id !== panelId)
257
+ }
258
+
259
+ if (toColumn in newColumnPanels) {
260
+ const targetPanels = [...newColumnPanels[toColumn]]
261
+ targetPanels.splice(insertIndex, 0, panelId)
262
+ newColumnPanels[toColumn] = targetPanels
263
+ }
264
+
265
+ return {
266
+ ...prev,
267
+ columnPanels: newColumnPanels,
268
+ }
269
+ })
270
+ },
271
+ [debug],
272
+ )
273
+
274
+ const addPanel = useCallback(
275
+ (panelId: string, columnId: string, insertIndex?: number) => {
276
+ debugLog(debug, 1, "パネル追加:", panelId, "to column:", columnId)
277
+
278
+ setLayoutState((prev) => {
279
+ const newColumnPanels = { ...prev.columnPanels }
280
+
281
+ if (!(columnId in newColumnPanels)) {
282
+ newColumnPanels[columnId] = []
283
+ }
284
+
285
+ const targetPanels = [...newColumnPanels[columnId]]
286
+ const index = insertIndex ?? targetPanels.length
287
+ targetPanels.splice(index, 0, panelId)
288
+ newColumnPanels[columnId] = targetPanels
289
+
290
+ return {
291
+ ...prev,
292
+ columnPanels: newColumnPanels,
293
+ }
294
+ })
295
+ },
296
+ [debug],
297
+ )
298
+
299
+ const removePanel = useCallback(
300
+ (panelId: string) => {
301
+ debugLog(debug, 1, "パネル削除:", panelId)
302
+
303
+ setLayoutState((prev) => {
304
+ const newColumnPanels: Record<string, string[]> = {}
305
+
306
+ for (const [columnId, panels] of Object.entries(prev.columnPanels)) {
307
+ newColumnPanels[columnId] = panels.filter((id) => id !== panelId)
308
+ }
309
+
310
+ return {
311
+ ...prev,
312
+ columnPanels: newColumnPanels,
313
+ }
314
+ })
315
+ },
316
+ [debug],
317
+ )
318
+
319
+ const updateColumnCount = useCallback(
320
+ (count: number) => {
321
+ debugLog(debug, 1, "カラム数更新:", count)
322
+
323
+ setLayoutState((prev) => {
324
+ const currentCount = prev.columns.length
325
+
326
+ if (count === currentCount) return prev
327
+
328
+ const normalizedColumnPanels = normalizeColumnPanels(prev.columnPanels, count, DEFAULT_COLUMN_ID_CONFIG)
329
+
330
+ const columnIds = generateColumnIds(count, DEFAULT_COLUMN_ID_CONFIG)
331
+
332
+ const shouldForceEndResize = prev.isResizing && prev.resizingColumn && !columnIds.includes(prev.resizingColumn)
333
+
334
+ if (shouldForceEndResize) {
335
+ debugLog(debug, 1, "リサイズ中のカラムが削除されるためリサイズを強制終了:", prev.resizingColumn)
336
+ }
337
+ const newColumns = columnIds.map((columnId: string, index: number) => ({
338
+ id: columnId,
339
+ title: `カラム ${index + 1}`,
340
+ minWidth: 350,
341
+ maxWidth: 800,
342
+ initialWidth: 400,
343
+ resizable: !(index === 1 && count === 3),
344
+ }))
345
+
346
+ const newColumnWidths: Record<string, number> = {}
347
+ const existingWidths = Object.entries(prev.columnWidths)
348
+ const hasCustomWidths = existingWidths.some(([, width]) => width !== 400)
349
+
350
+ if (hasCustomWidths) {
351
+ columnIds.forEach((columnId: string) => {
352
+ const existingWidth = prev.columnWidths[columnId]
353
+ if (existingWidth && existingWidth !== 400) {
354
+ newColumnWidths[columnId] = existingWidth
355
+ } else {
356
+ newColumnWidths[columnId] = 400
357
+ }
358
+ })
359
+ } else {
360
+ const redistributeWidth = currentCount > count ? Object.values(prev.columnWidths).reduce((sum, w) => sum + w, 0) : count * 400
361
+ const evenWidth = Math.max(redistributeWidth / count, 350)
362
+
363
+ columnIds.forEach((columnId: string) => {
364
+ newColumnWidths[columnId] = evenWidth
365
+ })
366
+ }
367
+
368
+ return {
369
+ ...prev,
370
+ columns: newColumns,
371
+ columnPanels: normalizedColumnPanels,
372
+ columnWidths: newColumnWidths,
373
+ isResizing: shouldForceEndResize ? false : prev.isResizing,
374
+ resizingColumn: shouldForceEndResize ? null : prev.resizingColumn,
375
+ }
376
+ })
377
+ },
378
+ [debug],
379
+ )
380
+
381
+ const autoBalanceColumns = useCallback(() => {
382
+ debugLog(debug, 1, "カラム自動バランス調整")
383
+
384
+ setLayoutState((prev) => {
385
+ const panelList: string[] = []
386
+ for (const columnId of Object.keys(prev.columnPanels)) {
387
+ for (const panelId of prev.columnPanels[columnId]) {
388
+ panelList.push(panelId)
389
+ }
390
+ }
391
+
392
+ const newColumnPanels: Record<string, string[]> = {}
393
+ for (const column of prev.columns) {
394
+ newColumnPanels[column.id] = []
395
+ }
396
+
397
+ let currentColumnIndex = 0
398
+ for (const panelId of panelList) {
399
+ const columnId = prev.columns[currentColumnIndex % prev.columns.length].id
400
+ newColumnPanels[columnId].push(panelId)
401
+ currentColumnIndex += 1
402
+ }
403
+
404
+ return {
405
+ ...prev,
406
+ columnPanels: newColumnPanels,
407
+ }
408
+ })
409
+ }, [debug])
410
+
411
+ const getColumnById = useCallback(
412
+ (columnId: string) => {
413
+ return layoutState.columns.find((col) => col.id === columnId)
414
+ },
415
+ [layoutState.columns],
416
+ )
417
+
418
+ const getTotalWidth = useCallback(() => {
419
+ let total = 0
420
+ for (const width of Object.values(layoutState.columnWidths)) {
421
+ total += width
422
+ }
423
+ return total
424
+ }, [layoutState.columnWidths])
425
+
426
+ const getColumnElement = useCallback((columnId: string) => {
427
+ return document.querySelector(`[data-column-id="${columnId}"]`) as HTMLElement | null
428
+ }, [])
429
+
430
+ const setShowInsertPlaceholders = useCallback((show: boolean) => {
431
+ setLayoutState((prev) => ({
432
+ ...prev,
433
+ showInsertPlaceholders: show,
434
+ }))
435
+ }, [])
436
+
437
+ return {
438
+ layoutState,
439
+ addColumn,
440
+ removeColumn,
441
+ moveColumn,
442
+ startResize,
443
+ handleResize,
444
+ endResize,
445
+ setColumnWidth,
446
+ resetColumnWidths,
447
+ movePanel,
448
+ addPanel,
449
+ removePanel,
450
+ updateColumnCount,
451
+ autoBalanceColumns,
452
+ getColumnById,
453
+ getTotalWidth,
454
+ getColumnElement,
455
+ setShowInsertPlaceholders,
456
+ }
457
+ }