@aiquants/resize-panels 1.7.2 → 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.
@@ -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
+ })