@aiquants/resize-panels 1.7.3 → 1.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 +21 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/styles/resize-panels.css +1 -1
- package/dist/styles/resize-panels.standalone.css +3 -0
- package/package.json +7 -4
- package/src/GlobalDebugOverlay.tsx +284 -0
- package/src/Panel.tsx +635 -0
- package/src/PanelDebugInfo.tsx +148 -0
- package/src/PanelGroup.tsx +260 -0
- package/src/PanelResizeHandle.tsx +718 -0
- package/src/context.ts +25 -0
- package/src/debugOverlayStore.ts +351 -0
- package/src/hooks.ts +968 -0
- package/src/index.ts +62 -0
- package/src/reducer.ts +1234 -0
- package/src/roundHalfToEven.ts +40 -0
- package/src/styles/components.entry.css +8 -0
- package/src/styles/resize-panels.css +3 -0
- package/src/styles/standalone.entry.css +12 -0
- package/src/types.ts +288 -0
- package/src/utils/simple-logger.ts +186 -0
- package/src/utils.ts +279 -0
- package/dist/resize-panels.css +0 -1
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file PanelResizeHandle component for custom resizable panels
|
|
3
|
+
* カスタムリサイズ可能パネルのための PanelResizeHandle コンポーネント
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
7
|
+
import { twMerge } from "tailwind-merge"
|
|
8
|
+
import { usePanelGroup } from "./context"
|
|
9
|
+
import {
|
|
10
|
+
PANEL_COLLAPSE_THRESHOLD_MAX,
|
|
11
|
+
PANEL_COLLAPSE_THRESHOLD_RATIO,
|
|
12
|
+
PANEL_EXPAND_THRESHOLD_MAX,
|
|
13
|
+
PANEL_EXPAND_THRESHOLD_RATIO,
|
|
14
|
+
PANEL_HANDLE_DRAG_THROTTLE_MS,
|
|
15
|
+
PANEL_HANDLE_THICKNESS,
|
|
16
|
+
PANEL_HANDLE_VISIBILITY_THRESHOLD,
|
|
17
|
+
PANEL_KEYBOARD_RESIZE_STEP,
|
|
18
|
+
PANEL_VISIBLE_INDICATOR_SPACING,
|
|
19
|
+
type PanelResizeHandleProps,
|
|
20
|
+
type ResizeHandleLayoutData,
|
|
21
|
+
} from "./types"
|
|
22
|
+
import { calculateSnapThreshold, getConstraintInPixels, getContainerSize } from "./utils"
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Render an interactive divider that manages adjacent panel resizing gestures.
|
|
26
|
+
* 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
|
|
27
|
+
*/
|
|
28
|
+
export const PanelResizeHandle = memo(({ id, disabled = false, className, style, children, onDragging }: PanelResizeHandleProps) => {
|
|
29
|
+
const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
|
|
30
|
+
const [isDragging, setIsDragging] = useState(false)
|
|
31
|
+
const handleRef = useRef<HTMLButtonElement>(null)
|
|
32
|
+
const containerRef = useRef<HTMLElement | null>(null)
|
|
33
|
+
|
|
34
|
+
const leftPanelId = useRef<string>("")
|
|
35
|
+
const rightPanelId = useRef<string>("")
|
|
36
|
+
// 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
|
|
37
|
+
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
38
|
+
// RTL コンテキストでは DOM 順 (論理順) と視覚順が反転するため、座標計算の符号とオーバーレイ位置を切り替える
|
|
39
|
+
const [isRtl, setIsRtl] = useState(false)
|
|
40
|
+
const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ left: string | null; right: string | null }>({ left: null, right: null })
|
|
41
|
+
const generatedId = useId()
|
|
42
|
+
const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
|
|
43
|
+
const lastHandleMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
|
|
44
|
+
const resolvedHandleId = id ?? handleIdRef.current
|
|
45
|
+
const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
|
|
46
|
+
const isHandleDisabled = disabled || hasLayoutConstraintViolation
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect the panels adjacent to this handle by scanning sibling DOM elements.
|
|
50
|
+
* DOM の兄弟要素を走査してこのハンドルに隣接する左右のパネルを検出する。
|
|
51
|
+
*/
|
|
52
|
+
const detectAdjacentPanels = useCallback(() => {
|
|
53
|
+
const handleElement = handleRef.current
|
|
54
|
+
if (!handleElement) return
|
|
55
|
+
|
|
56
|
+
const parent = handleElement.parentElement
|
|
57
|
+
if (!parent) return
|
|
58
|
+
|
|
59
|
+
containerRef.current = parent
|
|
60
|
+
if (typeof window !== "undefined") {
|
|
61
|
+
setIsRtl(window.getComputedStyle(parent).direction === "rtl")
|
|
62
|
+
}
|
|
63
|
+
const childrenElements = Array.from(parent.children)
|
|
64
|
+
const index = childrenElements.indexOf(handleElement)
|
|
65
|
+
|
|
66
|
+
let leftPanelElement: HTMLElement | null = null
|
|
67
|
+
let rightPanelElement: HTMLElement | null = null
|
|
68
|
+
|
|
69
|
+
// ハンドルより前方の要素を逆順に走査して左パネルを特定
|
|
70
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
71
|
+
const element = childrenElements[i] as HTMLElement
|
|
72
|
+
if (element.dataset.panelId) {
|
|
73
|
+
leftPanelElement = element
|
|
74
|
+
break
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ハンドルより後方の要素を走査して右パネルを特定
|
|
79
|
+
for (let i = index + 1; i < childrenElements.length; i += 1) {
|
|
80
|
+
const element = childrenElements[i] as HTMLElement
|
|
81
|
+
if (element.dataset.panelId) {
|
|
82
|
+
rightPanelElement = element
|
|
83
|
+
break
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const nextLeftId = leftPanelElement?.dataset.panelId || ""
|
|
88
|
+
const nextRightId = rightPanelElement?.dataset.panelId || ""
|
|
89
|
+
|
|
90
|
+
// 検出結果が前回と同じなら state 更新を省略する
|
|
91
|
+
if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
leftPanelId.current = nextLeftId
|
|
96
|
+
rightPanelId.current = nextRightId
|
|
97
|
+
|
|
98
|
+
setAdjacentPanelIds({
|
|
99
|
+
left: nextLeftId || null,
|
|
100
|
+
right: nextRightId || null,
|
|
101
|
+
})
|
|
102
|
+
}, [])
|
|
103
|
+
|
|
104
|
+
// パネル構成 (ID 列) の変化を検知するためのキー。ドラッグ中のサイズ更新では変化しない
|
|
105
|
+
const panelIdsKey = useMemo(() => panels.map((panel) => panel.id).join("|"), [panels])
|
|
106
|
+
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
// マウント時とパネルの増減・並び替え時に隣接パネルを再検出する
|
|
109
|
+
// (マウント時のみだと兄弟 Panel の条件付き追加/削除でハンドルが古い ID を掴んだままになる)
|
|
110
|
+
detectAdjacentPanels()
|
|
111
|
+
}, [detectAdjacentPanels, panelIdsKey])
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
// ハンドル ID が外部から変更された場合に、旧 ID の測定結果をクリーンアップする
|
|
115
|
+
if (resolvedHandleId === handleIdRef.current) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const previousId = handleIdRef.current
|
|
120
|
+
handleIdRef.current = resolvedHandleId
|
|
121
|
+
lastHandleMeasurementRef.current = null
|
|
122
|
+
|
|
123
|
+
if (previousId !== resolvedHandleId) {
|
|
124
|
+
reportHandleMeasurement(previousId, null)
|
|
125
|
+
}
|
|
126
|
+
}, [reportHandleMeasurement, resolvedHandleId])
|
|
127
|
+
|
|
128
|
+
useEffect(() => {
|
|
129
|
+
// コンポーネントのアンマウント時に、このハンドルの測定結果を破棄する
|
|
130
|
+
const finalId = handleIdRef.current
|
|
131
|
+
return () => {
|
|
132
|
+
reportHandleMeasurement(finalId, null)
|
|
133
|
+
}
|
|
134
|
+
}, [reportHandleMeasurement])
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
// ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
|
|
138
|
+
return () => {
|
|
139
|
+
dragCleanupRef.current?.()
|
|
140
|
+
}
|
|
141
|
+
}, [])
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Initiates a resize drag interaction when the handle receives a pointer down event.
|
|
145
|
+
* リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
|
|
146
|
+
*/
|
|
147
|
+
const handlePointerDown = useCallback(
|
|
148
|
+
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
149
|
+
if (isHandleDisabled || !containerRef.current) return
|
|
150
|
+
|
|
151
|
+
// 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
|
|
152
|
+
if (dragCleanupRef.current) return
|
|
153
|
+
|
|
154
|
+
event.preventDefault()
|
|
155
|
+
event.stopPropagation()
|
|
156
|
+
|
|
157
|
+
const container = containerRef.current
|
|
158
|
+
const { inner: containerSize } = getContainerSize(container, direction)
|
|
159
|
+
const startPosition = { x: event.clientX, y: event.clientY }
|
|
160
|
+
|
|
161
|
+
const leftPanel = getPanel(leftPanelId.current)
|
|
162
|
+
const rightPanel = getPanel(rightPanelId.current)
|
|
163
|
+
|
|
164
|
+
if (!(leftPanel && rightPanel)) return
|
|
165
|
+
|
|
166
|
+
const initialLeftSize = leftPanel.size
|
|
167
|
+
const initialRightSize = rightPanel.size
|
|
168
|
+
|
|
169
|
+
let lastUpdateTime = 0
|
|
170
|
+
const throttleMs = PANEL_HANDLE_DRAG_THROTTLE_MS
|
|
171
|
+
|
|
172
|
+
setIsDragging(true)
|
|
173
|
+
onDragging?.(true)
|
|
174
|
+
|
|
175
|
+
// このドラッグを開始したポインタだけを追跡する (2 本目のタッチ等の混入を防ぐ)
|
|
176
|
+
const dragPointerId = event.pointerId
|
|
177
|
+
const captureTarget = event.currentTarget
|
|
178
|
+
|
|
179
|
+
// RTL では DOM 順 (論理順) と視覚順が反転するため、水平ドラッグの変位符号を反転する
|
|
180
|
+
const isRtlDrag = direction === "horizontal" && typeof window !== "undefined" && window.getComputedStyle(container).direction === "rtl"
|
|
181
|
+
|
|
182
|
+
// ポインタキャプチャを取得してドラッグ中のイベント配送を固定
|
|
183
|
+
captureTarget.setPointerCapture(dragPointerId)
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Handle pointer move events emitted during an active resize drag interaction.
|
|
187
|
+
* リサイズドラッグ操作中に発生するポインタ移動イベントを処理する関数。
|
|
188
|
+
*/
|
|
189
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
190
|
+
// 別ポインタのイベントはドラッグ計算に混入させない
|
|
191
|
+
if (moveEvent.pointerId !== dragPointerId) return
|
|
192
|
+
if (!handleRef.current) return
|
|
193
|
+
|
|
194
|
+
moveEvent.preventDefault()
|
|
195
|
+
moveEvent.stopPropagation()
|
|
196
|
+
|
|
197
|
+
// スロットル処理: 頻繁な更新を抑制して計算負荷を下げる
|
|
198
|
+
const now = performance.now()
|
|
199
|
+
if (now - lastUpdateTime < throttleMs) return
|
|
200
|
+
lastUpdateTime = now
|
|
201
|
+
|
|
202
|
+
// ドラッグ開始位置からのピクセル変位を計算 (RTL の水平ドラッグは符号を反転)
|
|
203
|
+
const rawPixelDelta = direction === "horizontal" ? moveEvent.clientX - startPosition.x : moveEvent.clientY - startPosition.y
|
|
204
|
+
const pixelDelta = isRtlDrag ? -rawPixelDelta : rawPixelDelta
|
|
205
|
+
// 差分がゼロのときは状態変化がなく後続処理が無駄になるため中断
|
|
206
|
+
if (pixelDelta === 0) {
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const newLeftSizeInPixels = initialLeftSize + pixelDelta
|
|
211
|
+
const newRightSizeInPixels = initialRightSize - pixelDelta
|
|
212
|
+
|
|
213
|
+
// 最新のパネル状態を取得して折りたたみ状態などの変化を反映
|
|
214
|
+
const updatedLeftPanel = getPanel(leftPanelId.current)
|
|
215
|
+
const updatedRightPanel = getPanel(rightPanelId.current)
|
|
216
|
+
|
|
217
|
+
if (updatedLeftPanel && updatedRightPanel && leftPanelId.current && rightPanelId.current) {
|
|
218
|
+
const leftMinSizePixels = getConstraintInPixels(updatedLeftPanel.minSize, 0, containerSize)
|
|
219
|
+
const rightMinSizePixels = getConstraintInPixels(updatedRightPanel.minSize, 0, containerSize)
|
|
220
|
+
const leftMinConstraintPixels = leftMinSizePixels
|
|
221
|
+
const rightMinConstraintPixels = rightMinSizePixels
|
|
222
|
+
|
|
223
|
+
// 折りたたみ・展開の閾値をコンテナサイズから算出
|
|
224
|
+
const collapseThresholdPixels = Math.min(containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
|
|
225
|
+
const expandThresholdPixels = Math.min(containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
|
|
226
|
+
|
|
227
|
+
const rightCanCollapseFromStart = updatedRightPanel.collapseFromStart
|
|
228
|
+
const rightCollapseDirection = updatedRightPanel.collapsedByDirection ?? "start"
|
|
229
|
+
// 右パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
|
|
230
|
+
if (rightCanCollapseFromStart && !updatedRightPanel.collapsed && newRightSizeInPixels <= Math.max(rightMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
231
|
+
collapsePanel(rightPanelId.current, "start")
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const leftCanCollapseFromEnd = updatedLeftPanel.collapseFromEnd
|
|
236
|
+
const leftCollapseDirection = updatedLeftPanel.collapsedByDirection ?? "end"
|
|
237
|
+
// 左パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
|
|
238
|
+
if (leftCanCollapseFromEnd && !updatedLeftPanel.collapsed && newLeftSizeInPixels <= Math.max(leftMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
239
|
+
collapsePanel(leftPanelId.current, "end")
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// 右パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
|
|
244
|
+
if (updatedRightPanel.collapsed && newRightSizeInPixels > Math.max(rightMinSizePixels * 0.3, expandThresholdPixels)) {
|
|
245
|
+
expandPanel(rightPanelId.current, rightCollapseDirection, newRightSizeInPixels)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 左パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
|
|
250
|
+
if (updatedLeftPanel.collapsed && newLeftSizeInPixels > Math.max(leftMinSizePixels * 0.3, expandThresholdPixels)) {
|
|
251
|
+
expandPanel(leftPanelId.current, leftCollapseDirection, newLeftSizeInPixels)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// スナップ処理: 最小サイズ以下になった場合はゼロサイズにスナップさせる
|
|
256
|
+
if (containerSize > 0) {
|
|
257
|
+
const totalPanelPixels = updatedLeftPanel.size + updatedRightPanel.size
|
|
258
|
+
// コンテナ比率と定数に基づきゼロスナップの閾値を算出
|
|
259
|
+
const snapThreshold = calculateSnapThreshold(containerSize)
|
|
260
|
+
let snapped = false
|
|
261
|
+
|
|
262
|
+
// 左パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
|
|
263
|
+
if (leftMinConstraintPixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
|
|
264
|
+
const snappedLeft = 0
|
|
265
|
+
const snappedRight = Math.max(0, totalPanelPixels - snappedLeft)
|
|
266
|
+
|
|
267
|
+
if (snappedRight >= rightMinConstraintPixels) {
|
|
268
|
+
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
269
|
+
snapped = true
|
|
270
|
+
}
|
|
271
|
+
} else if (rightMinConstraintPixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
|
|
272
|
+
// 右パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
|
|
273
|
+
const snappedRight = 0
|
|
274
|
+
const snappedLeft = Math.max(0, totalPanelPixels - snappedRight)
|
|
275
|
+
|
|
276
|
+
if (snappedLeft >= leftMinConstraintPixels) {
|
|
277
|
+
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
278
|
+
snapped = true
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (snapped) {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// いずれかのパネルが折りたたまれている場合はリサイズ処理を中断
|
|
288
|
+
if (updatedLeftPanel.collapsed || updatedRightPanel.collapsed) {
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 通常のリサイズ処理を実行して隣接パネルのサイズを更新
|
|
293
|
+
resizePanels(leftPanelId.current, rightPanelId.current, newLeftSizeInPixels, newRightSizeInPixels)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Tear down the active drag: remove listeners, release capture, and reset state.
|
|
299
|
+
* 進行中ドラッグの後始末: リスナー除去・キャプチャ解放・状態リセットを一括で行う。
|
|
300
|
+
* pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
|
|
301
|
+
*/
|
|
302
|
+
let dragActive = true
|
|
303
|
+
const stopDragging = () => {
|
|
304
|
+
if (!dragActive) return
|
|
305
|
+
dragActive = false
|
|
306
|
+
dragCleanupRef.current = null
|
|
307
|
+
|
|
308
|
+
setIsDragging(false)
|
|
309
|
+
onDragging?.(false)
|
|
310
|
+
|
|
311
|
+
document.removeEventListener("pointermove", handlePointerMove)
|
|
312
|
+
document.removeEventListener("pointerup", handlePointerUp)
|
|
313
|
+
document.removeEventListener("pointercancel", handlePointerCancel)
|
|
314
|
+
captureTarget.removeEventListener("lostpointercapture", handleLostPointerCapture)
|
|
315
|
+
if (captureTarget.hasPointerCapture?.(dragPointerId)) {
|
|
316
|
+
captureTarget.releasePointerCapture(dragPointerId)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const handlePointerUp = (upEvent: PointerEvent) => {
|
|
321
|
+
// 別ポインタの離上ではドラッグを終了しない
|
|
322
|
+
if (upEvent.pointerId !== dragPointerId) return
|
|
323
|
+
stopDragging()
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const handlePointerCancel = (cancelEvent: PointerEvent) => {
|
|
327
|
+
// OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
|
|
328
|
+
if (cancelEvent.pointerId !== dragPointerId) return
|
|
329
|
+
stopDragging()
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const handleLostPointerCapture = (lostEvent: PointerEvent) => {
|
|
333
|
+
// キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
|
|
334
|
+
if (lostEvent.pointerId !== dragPointerId) return
|
|
335
|
+
stopDragging()
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
document.addEventListener("pointermove", handlePointerMove)
|
|
339
|
+
document.addEventListener("pointerup", handlePointerUp)
|
|
340
|
+
document.addEventListener("pointercancel", handlePointerCancel)
|
|
341
|
+
captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
|
|
342
|
+
dragCleanupRef.current = stopDragging
|
|
343
|
+
},
|
|
344
|
+
[isHandleDisabled, direction, resizePanels, getPanel, collapsePanel, expandPanel, onDragging],
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Resize adjacent panels with arrow keys for keyboard accessibility.
|
|
349
|
+
* キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
|
|
350
|
+
* Shift 併用で 5 倍のステップ幅を適用する。
|
|
351
|
+
*/
|
|
352
|
+
const handleKeyDown = useCallback(
|
|
353
|
+
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
354
|
+
if (isHandleDisabled) return
|
|
355
|
+
|
|
356
|
+
const isHorizontalDirection = direction === "horizontal"
|
|
357
|
+
const decreaseKey = isHorizontalDirection ? "ArrowLeft" : "ArrowUp"
|
|
358
|
+
const increaseKey = isHorizontalDirection ? "ArrowRight" : "ArrowDown"
|
|
359
|
+
if (event.key !== decreaseKey && event.key !== increaseKey) return
|
|
360
|
+
|
|
361
|
+
event.preventDefault()
|
|
362
|
+
event.stopPropagation()
|
|
363
|
+
|
|
364
|
+
let delta = (event.key === increaseKey ? 1 : -1) * (event.shiftKey ? PANEL_KEYBOARD_RESIZE_STEP * 5 : PANEL_KEYBOARD_RESIZE_STEP)
|
|
365
|
+
// RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する (ドラッグ開始時と同様に押下時点の値を都度参照)
|
|
366
|
+
const isRtlNow = isHorizontalDirection && typeof window !== "undefined" && containerRef.current !== null && window.getComputedStyle(containerRef.current).direction === "rtl"
|
|
367
|
+
if (isRtlNow) {
|
|
368
|
+
delta = -delta
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const leftPanel = getPanel(leftPanelId.current)
|
|
372
|
+
const rightPanel = getPanel(rightPanelId.current)
|
|
373
|
+
if (!(leftPanel && rightPanel) || leftPanel.collapsed || rightPanel.collapsed) return
|
|
374
|
+
|
|
375
|
+
// 制約の最終判定はリデューサー側で行われる
|
|
376
|
+
resizePanels(leftPanelId.current, rightPanelId.current, leftPanel.size + delta, rightPanel.size - delta)
|
|
377
|
+
},
|
|
378
|
+
[isHandleDisabled, direction, getPanel, resizePanels],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
const isHorizontal = direction === "horizontal"
|
|
382
|
+
const cursor = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
|
|
383
|
+
|
|
384
|
+
const baseHandleClasses = twMerge(
|
|
385
|
+
"group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
|
|
386
|
+
cursor,
|
|
387
|
+
isHorizontal ? "h-full" : "w-full",
|
|
388
|
+
!isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
|
|
389
|
+
!isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
|
|
390
|
+
isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
|
|
391
|
+
isHandleDisabled && "cursor-not-allowed opacity-50",
|
|
392
|
+
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
|
|
393
|
+
className,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
const adjacentIndices = useMemo(() => {
|
|
397
|
+
// パネル ID からパネル配列内のインデックスを探索するヘルパー関数
|
|
398
|
+
const findIndex = (panelId: string | null) => {
|
|
399
|
+
if (!panelId) {
|
|
400
|
+
return -1
|
|
401
|
+
}
|
|
402
|
+
for (let index = 0; index < panels.length; index += 1) {
|
|
403
|
+
if (panels[index].id === panelId) {
|
|
404
|
+
return index
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return -1
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
left: findIndex(adjacentPanelIds.left),
|
|
412
|
+
right: findIndex(adjacentPanelIds.right),
|
|
413
|
+
}
|
|
414
|
+
}, [adjacentPanelIds.left, adjacentPanelIds.right, panels])
|
|
415
|
+
|
|
416
|
+
const containerLength = isHorizontal ? containerSize.width : containerSize.height
|
|
417
|
+
|
|
418
|
+
const boundaryOffset = useMemo(() => {
|
|
419
|
+
// 指定範囲のパネルサイズ合計を安全に算出するヘルパー
|
|
420
|
+
const safeSum = (startIndex: number, endIndexInclusive: number) => {
|
|
421
|
+
if (startIndex > endIndexInclusive) {
|
|
422
|
+
return 0
|
|
423
|
+
}
|
|
424
|
+
let total = 0
|
|
425
|
+
for (let index = startIndex; index <= endIndexInclusive; index += 1) {
|
|
426
|
+
const panel = panels[index]
|
|
427
|
+
if (!panel) {
|
|
428
|
+
continue
|
|
429
|
+
}
|
|
430
|
+
const candidateSize = panel.size ?? 0
|
|
431
|
+
const sanitizedSize = Math.max(0, candidateSize)
|
|
432
|
+
total += panel.collapsed ? 0 : sanitizedSize
|
|
433
|
+
}
|
|
434
|
+
return total
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (adjacentIndices.left >= 0) {
|
|
438
|
+
return safeSum(0, adjacentIndices.left)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (adjacentIndices.right > 0) {
|
|
442
|
+
return safeSum(0, adjacentIndices.right - 1)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return 0
|
|
446
|
+
}, [adjacentIndices.left, adjacentIndices.right, panels])
|
|
447
|
+
|
|
448
|
+
const clampedOffset = useMemo(() => {
|
|
449
|
+
// 境界位置が無効値やコンテナ範囲外に逸脱した場合に補正
|
|
450
|
+
if (!Number.isFinite(boundaryOffset) || boundaryOffset < 0) {
|
|
451
|
+
return 0
|
|
452
|
+
}
|
|
453
|
+
if (containerLength > 0 && boundaryOffset > containerLength) {
|
|
454
|
+
return containerLength
|
|
455
|
+
}
|
|
456
|
+
return boundaryOffset
|
|
457
|
+
}, [boundaryOffset, containerLength])
|
|
458
|
+
|
|
459
|
+
const overlayBaseStyle = useMemo<CSSProperties>(() => {
|
|
460
|
+
const base: CSSProperties = {
|
|
461
|
+
position: "absolute",
|
|
462
|
+
zIndex: 20,
|
|
463
|
+
touchAction: "none",
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (isHorizontal) {
|
|
467
|
+
base.top = 0
|
|
468
|
+
// clampedOffset は DOM 順 (論理順) の先頭からの距離。RTL では右端基準へ変換して物理 left を求める
|
|
469
|
+
base.left = isRtl && containerLength > 0 ? `${containerLength - clampedOffset}px` : `${clampedOffset}px`
|
|
470
|
+
base.transform = "translateX(-50%)"
|
|
471
|
+
base.width = `${PANEL_HANDLE_THICKNESS}px`
|
|
472
|
+
base.height = "100%"
|
|
473
|
+
} else {
|
|
474
|
+
base.left = 0
|
|
475
|
+
base.top = `${clampedOffset}px`
|
|
476
|
+
base.transform = "translateY(-50%)"
|
|
477
|
+
base.height = `${PANEL_HANDLE_THICKNESS}px`
|
|
478
|
+
base.width = "100%"
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
return base
|
|
482
|
+
}, [clampedOffset, isHorizontal, isRtl, containerLength])
|
|
483
|
+
|
|
484
|
+
const indicatorGrouping = useMemo(() => {
|
|
485
|
+
const snapThreshold = calculateSnapThreshold(containerLength)
|
|
486
|
+
// パネルが実質的に折りたたまれているか判定するヘルパー関数
|
|
487
|
+
const isEffectivelyCollapsed = (panelIndex: number) => {
|
|
488
|
+
if (panelIndex < 0 || panelIndex >= panels.length) {
|
|
489
|
+
return false
|
|
490
|
+
}
|
|
491
|
+
const target = panels[panelIndex]
|
|
492
|
+
if (!target) {
|
|
493
|
+
return false
|
|
494
|
+
}
|
|
495
|
+
if (target.collapsed) {
|
|
496
|
+
return true
|
|
497
|
+
}
|
|
498
|
+
return target.size <= snapThreshold
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// 指定インデックスから連続する折りたたみパネルの数を数える
|
|
502
|
+
const countCollapsed = (startIndex: number, step: 1 | -1) => {
|
|
503
|
+
if (!isEffectivelyCollapsed(startIndex)) {
|
|
504
|
+
return 0
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
let count = 0
|
|
508
|
+
for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
|
|
509
|
+
if (!isEffectivelyCollapsed(index)) {
|
|
510
|
+
break
|
|
511
|
+
}
|
|
512
|
+
count += 1
|
|
513
|
+
}
|
|
514
|
+
return count
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const leftIndex = adjacentIndices.left
|
|
518
|
+
const rightIndex = adjacentIndices.right
|
|
519
|
+
const leftCollapsedCount = leftIndex >= 0 ? countCollapsed(leftIndex, -1) : 0
|
|
520
|
+
const rightCollapsedCount = rightIndex >= 0 ? countCollapsed(rightIndex, 1) : 0
|
|
521
|
+
const groupSize = leftCollapsedCount + rightCollapsedCount + 1
|
|
522
|
+
const overlapIndex = leftCollapsedCount
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
groupSize,
|
|
526
|
+
overlapIndex,
|
|
527
|
+
}
|
|
528
|
+
}, [adjacentIndices.left, adjacentIndices.right, panels, containerLength])
|
|
529
|
+
|
|
530
|
+
const shouldHideHandle = useMemo(() => {
|
|
531
|
+
if (hasLayoutConstraintViolation) {
|
|
532
|
+
return true
|
|
533
|
+
}
|
|
534
|
+
// グループサイズが 1 以下の場合は単独ハンドルなので非表示にしない
|
|
535
|
+
if (indicatorGrouping.groupSize <= 1) {
|
|
536
|
+
return false
|
|
537
|
+
}
|
|
538
|
+
// ハンドルがグループ内の中間位置にある場合は重複防止のため非表示にする
|
|
539
|
+
const lastIndex = indicatorGrouping.groupSize - 1
|
|
540
|
+
return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
|
|
541
|
+
}, [indicatorGrouping, hasLayoutConstraintViolation])
|
|
542
|
+
|
|
543
|
+
const indicatorOffset = useMemo(() => {
|
|
544
|
+
// ハンドルが非表示の場合またはグループサイズが 1 以下の場合はオフセット不要
|
|
545
|
+
if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
|
|
546
|
+
return 0
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// 表示すべきインジケーターの個数を決定
|
|
550
|
+
const visibleCount = indicatorGrouping.groupSize > 1 ? 2 : 1
|
|
551
|
+
if (visibleCount === 1) {
|
|
552
|
+
return 0
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// このハンドルが表示インジケーター群内のどの位置にあるか計算
|
|
556
|
+
const visibleIndex = indicatorGrouping.overlapIndex === 0 ? 0 : visibleCount - 1
|
|
557
|
+
const centerIndex = (visibleCount - 1) / 2
|
|
558
|
+
return (visibleIndex - centerIndex) * PANEL_VISIBLE_INDICATOR_SPACING
|
|
559
|
+
}, [indicatorGrouping, shouldHideHandle])
|
|
560
|
+
|
|
561
|
+
const indicatorWrapperStyle = useMemo(() => {
|
|
562
|
+
// ハンドルを非表示にする場合は表示なし
|
|
563
|
+
if (shouldHideHandle) {
|
|
564
|
+
return { display: "none" }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// オフセットがゼロの場合はスタイル不要
|
|
568
|
+
if (indicatorOffset === 0) {
|
|
569
|
+
return undefined
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// オフセット分だけインジケーターを移動
|
|
573
|
+
return isHorizontal ? { transform: `translateX(${indicatorOffset}px)` } : { transform: `translateY(${indicatorOffset}px)` }
|
|
574
|
+
}, [indicatorOffset, isHorizontal, shouldHideHandle])
|
|
575
|
+
|
|
576
|
+
const indicatorWrapperClasses = twMerge(
|
|
577
|
+
"flex items-center justify-center rounded-full bg-white text-slate-700 ring-1 ring-inset ring-white/80 backdrop-blur-sm transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.85),0_1px_3px_rgba(15,23,42,0.18)]",
|
|
578
|
+
"dark:bg-slate-800/95 dark:text-slate-200 dark:ring-slate-500/60 dark:shadow-[0_0_0_1px_rgba(148,163,184,0.45),0_1px_3px_rgba(2,6,23,0.55)]",
|
|
579
|
+
isHorizontal ? "h-12 w-[6px] px-[2px]" : "h-[6px] w-20 py-[2px]",
|
|
580
|
+
isDragging && "bg-blue-200/80 ring-blue-300 shadow-[0_0_0_1px_rgba(191,219,254,0.65),0_1px_4px_rgba(59,130,246,0.45)] dark:bg-blue-900/70 dark:ring-blue-400 dark:shadow-[0_0_0_1px_rgba(147,197,253,0.5),0_1px_4px_rgba(59,130,246,0.55)]",
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
const indicatorBarClasses = twMerge(
|
|
584
|
+
"rounded-full bg-slate-600 transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.9)] dark:bg-slate-200 dark:shadow-[0_0_0_1px_rgba(15,23,42,0.55)]",
|
|
585
|
+
isHorizontal ? "h-8 w-[2px]" : "h-[2px] w-16",
|
|
586
|
+
isDragging && "bg-blue-500 dark:bg-blue-300",
|
|
587
|
+
!isHandleDisabled && "group-hover:bg-slate-700 dark:group-hover:bg-slate-100",
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
const updateHandleMeasurement = useCallback(() => {
|
|
591
|
+
const element = handleRef.current
|
|
592
|
+
if (!element) {
|
|
593
|
+
return
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
let thickness = 0
|
|
597
|
+
|
|
598
|
+
// ハンドルが非表示の場合は厚みをゼロとして扱う
|
|
599
|
+
if (!shouldHideHandle) {
|
|
600
|
+
const baseSize = direction === "horizontal" ? element.offsetWidth : element.offsetHeight
|
|
601
|
+
let marginStart = 0
|
|
602
|
+
let marginEnd = 0
|
|
603
|
+
|
|
604
|
+
// CSS マージンを含めた実測厚みを計算
|
|
605
|
+
if (typeof window !== "undefined") {
|
|
606
|
+
const computedStyle = window.getComputedStyle(element)
|
|
607
|
+
if (direction === "horizontal") {
|
|
608
|
+
marginStart = Number.parseFloat(computedStyle.marginLeft || "0")
|
|
609
|
+
marginEnd = Number.parseFloat(computedStyle.marginRight || "0")
|
|
610
|
+
} else {
|
|
611
|
+
marginStart = Number.parseFloat(computedStyle.marginTop || "0")
|
|
612
|
+
marginEnd = Number.parseFloat(computedStyle.marginBottom || "0")
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const candidates = [baseSize, marginStart, marginEnd]
|
|
617
|
+
thickness = candidates.reduce((sum, value) => {
|
|
618
|
+
if (!Number.isFinite(value)) {
|
|
619
|
+
return sum
|
|
620
|
+
}
|
|
621
|
+
return sum + value
|
|
622
|
+
}, 0)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!Number.isFinite(thickness) || thickness < 0) {
|
|
626
|
+
thickness = 0
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// 測定結果オブジェクトを構築
|
|
630
|
+
const measurement: ResizeHandleLayoutData = {
|
|
631
|
+
id: resolvedHandleId,
|
|
632
|
+
thickness,
|
|
633
|
+
direction,
|
|
634
|
+
visible: !shouldHideHandle && thickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
|
|
635
|
+
startPanelId: adjacentPanelIds.left,
|
|
636
|
+
endPanelId: adjacentPanelIds.right,
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const lastMeasurement = lastHandleMeasurementRef.current
|
|
640
|
+
|
|
641
|
+
// 測定値が前回と実質的に同一であれば更新をスキップ
|
|
642
|
+
if (
|
|
643
|
+
lastMeasurement &&
|
|
644
|
+
Math.abs(lastMeasurement.thickness - measurement.thickness) <= 0.25 &&
|
|
645
|
+
lastMeasurement.visible === measurement.visible &&
|
|
646
|
+
lastMeasurement.direction === measurement.direction &&
|
|
647
|
+
lastMeasurement.startPanelId === measurement.startPanelId &&
|
|
648
|
+
lastMeasurement.endPanelId === measurement.endPanelId
|
|
649
|
+
) {
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
lastHandleMeasurementRef.current = measurement
|
|
654
|
+
reportHandleMeasurement(resolvedHandleId, measurement)
|
|
655
|
+
}, [direction, adjacentPanelIds.left, adjacentPanelIds.right, reportHandleMeasurement, resolvedHandleId, shouldHideHandle])
|
|
656
|
+
|
|
657
|
+
useEffect(() => {
|
|
658
|
+
updateHandleMeasurement()
|
|
659
|
+
}, [updateHandleMeasurement])
|
|
660
|
+
|
|
661
|
+
useEffect(() => {
|
|
662
|
+
const element = handleRef.current
|
|
663
|
+
if (!element) {
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ResizeObserver が利用できない環境では初回のみ測定
|
|
668
|
+
if (typeof ResizeObserver === "undefined") {
|
|
669
|
+
updateHandleMeasurement()
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// ハンドル要素のサイズ変化を監視して測定結果を更新
|
|
674
|
+
const observer = new ResizeObserver(() => {
|
|
675
|
+
updateHandleMeasurement()
|
|
676
|
+
})
|
|
677
|
+
|
|
678
|
+
observer.observe(element)
|
|
679
|
+
|
|
680
|
+
return () => {
|
|
681
|
+
observer.disconnect()
|
|
682
|
+
}
|
|
683
|
+
}, [updateHandleMeasurement])
|
|
684
|
+
|
|
685
|
+
const combinedStyle = useMemo<CSSProperties>(() => {
|
|
686
|
+
const merged = style ? { ...overlayBaseStyle, ...style } : overlayBaseStyle
|
|
687
|
+
if (shouldHideHandle) {
|
|
688
|
+
return {
|
|
689
|
+
...merged,
|
|
690
|
+
display: "none" as CSSProperties["display"],
|
|
691
|
+
pointerEvents: "none" as CSSProperties["pointerEvents"],
|
|
692
|
+
visibility: "hidden" as CSSProperties["visibility"],
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return merged
|
|
696
|
+
}, [overlayBaseStyle, shouldHideHandle, style])
|
|
697
|
+
|
|
698
|
+
return (
|
|
699
|
+
<button
|
|
700
|
+
type="button"
|
|
701
|
+
ref={handleRef}
|
|
702
|
+
className={baseHandleClasses}
|
|
703
|
+
style={combinedStyle}
|
|
704
|
+
onPointerDown={handlePointerDown}
|
|
705
|
+
onKeyDown={handleKeyDown}
|
|
706
|
+
disabled={isHandleDisabled}
|
|
707
|
+
aria-hidden={shouldHideHandle || undefined}
|
|
708
|
+
aria-label={`Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`}
|
|
709
|
+
data-resize-handle-id={resolvedHandleId}
|
|
710
|
+
title={`ドラッグして${isHorizontal ? "列" : "行"}をリサイズ、端まで移動して折りたたみ/展開`}>
|
|
711
|
+
{children || (
|
|
712
|
+
<div className={indicatorWrapperClasses} style={indicatorWrapperStyle}>
|
|
713
|
+
<span className={indicatorBarClasses} />
|
|
714
|
+
</div>
|
|
715
|
+
)}
|
|
716
|
+
</button>
|
|
717
|
+
)
|
|
718
|
+
})
|