@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.
- package/LICENSE +21 -0
- package/README.md +32 -10
- package/dist/drag-drop/hooks/useCustomDragHandlers.d.ts.map +1 -1
- package/dist/index.es.js +88 -88
- package/dist/index.umd.js +2 -2
- package/dist/styles/drag-drop-panels.standalone.css +3 -0
- package/package.json +11 -4
- package/src/DragDropLayout.tsx +751 -0
- package/src/drag-drop/ColumnControlPanel.tsx +123 -0
- package/src/drag-drop/ColumnInsertPanel.tsx +106 -0
- package/src/drag-drop/DebugComponents.tsx +222 -0
- package/src/drag-drop/DebugControlPanel.tsx +200 -0
- package/src/drag-drop/DebugOverlay.tsx +250 -0
- package/src/drag-drop/DragDropLayout.css +130 -0
- package/src/drag-drop/ResizeHandle.tsx +208 -0
- package/src/drag-drop/ResponsiveControl.tsx +33 -0
- package/src/drag-drop/hooks/dragGhostScale.spec.tsx +153 -0
- package/src/drag-drop/hooks/useColumnLayout.ts +457 -0
- package/src/drag-drop/hooks/useCustomDragHandlers.tsx +333 -0
- package/src/drag-drop/hooks/useDragDropColumns.tsx +446 -0
- package/src/drag-drop/hooks/useDragDropState.tsx +200 -0
- package/src/drag-drop/hooks/useFitScale.ts +48 -0
- package/src/drag-drop/hooks/useMouseTracking.tsx +102 -0
- package/src/drag-drop/hooks/useNormalDragHandlers.tsx +427 -0
- package/src/drag-drop/hooks/usePanelManagement.tsx +238 -0
- package/src/drag-drop/hooks/useResponsiveColumns.ts +210 -0
- package/src/drag-drop/resize-handle.css +71 -0
- package/src/drag-drop/types.ts +248 -0
- package/src/drag-drop/utils/columnIdUtils.ts +104 -0
- package/src/drag-drop/utils/edgeScrollUtils.ts +124 -0
- package/src/drag-drop/utils/simple-logger.ts +162 -0
- package/src/index.ts +60 -0
- 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
|
+
}
|