@aiquants/resize-panels 1.9.0 → 2.0.1

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