@aiquants/resize-panels 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) 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/debugOverlayStore-D7FT6zPE.js +135 -0
  5. package/dist/debugOverlayStore-DY09saU8.cjs +1 -0
  6. package/dist/index-DZ0itiBD.js +1299 -0
  7. package/dist/index-DqATP_BA.cjs +2 -0
  8. package/dist/index.cjs +1 -1
  9. package/dist/index.d.ts +2 -29
  10. package/dist/index.js +25 -13
  11. package/dist/src/GlobalDebugOverlay.d.ts.map +1 -0
  12. package/dist/src/Panel.d.ts +46 -0
  13. package/dist/src/Panel.d.ts.map +1 -0
  14. package/dist/src/PanelDebugInfo.d.ts +34 -0
  15. package/dist/src/PanelDebugInfo.d.ts.map +1 -0
  16. package/dist/src/PanelGroup.d.ts.map +1 -0
  17. package/dist/src/PanelResizeHandle.d.ts.map +1 -0
  18. package/dist/src/allocateLayout.d.ts +60 -0
  19. package/dist/src/allocateLayout.d.ts.map +1 -0
  20. package/dist/src/context.d.ts.map +1 -0
  21. package/dist/{debugOverlayStore.d.ts → src/debugOverlayStore.d.ts} +10 -2
  22. package/dist/src/debugOverlayStore.d.ts.map +1 -0
  23. package/dist/src/hooks.d.ts +48 -0
  24. package/dist/src/hooks.d.ts.map +1 -0
  25. package/dist/src/index.d.ts +32 -0
  26. package/dist/src/index.d.ts.map +1 -0
  27. package/dist/src/reducer.d.ts +49 -0
  28. package/dist/src/reducer.d.ts.map +1 -0
  29. package/dist/src/roundHalfToEven.d.ts.map +1 -0
  30. package/dist/{types.d.ts → src/types.d.ts} +164 -60
  31. package/dist/src/types.d.ts.map +1 -0
  32. package/dist/src/utils/simple-logger.d.ts.map +1 -0
  33. package/dist/src/utils.d.ts +164 -0
  34. package/dist/src/utils.d.ts.map +1 -0
  35. package/dist/styles/resize-panels.standalone.css +1 -1
  36. package/dist/tests/support/dom-harness.d.ts +89 -0
  37. package/dist/tests/support/dom-harness.d.ts.map +1 -0
  38. package/package.json +1 -1
  39. package/src/GlobalDebugOverlay.tsx +3 -3
  40. package/src/Panel.tsx +203 -541
  41. package/src/PanelDebugInfo.tsx +52 -106
  42. package/src/PanelGroup.tsx +27 -42
  43. package/src/PanelResizeHandle.tsx +470 -509
  44. package/src/allocateLayout.ts +412 -0
  45. package/src/debugOverlayStore.ts +19 -15
  46. package/src/hooks.ts +187 -779
  47. package/src/index.ts +12 -3
  48. package/src/reducer.ts +364 -1126
  49. package/src/types.ts +149 -63
  50. package/src/utils.ts +205 -164
  51. package/dist/GlobalDebugOverlay-sEQN6exo.cjs +0 -1
  52. package/dist/GlobalDebugOverlay.d.ts.map +0 -1
  53. package/dist/Panel.d.ts +0 -7
  54. package/dist/Panel.d.ts.map +0 -1
  55. package/dist/PanelDebugInfo.d.ts +0 -26
  56. package/dist/PanelDebugInfo.d.ts.map +0 -1
  57. package/dist/PanelGroup.d.ts.map +0 -1
  58. package/dist/PanelResizeHandle.d.ts.map +0 -1
  59. package/dist/context.d.ts.map +0 -1
  60. package/dist/debugOverlayStore-Cntyxboe.js +0 -141
  61. package/dist/debugOverlayStore-Dkl-fHoa.cjs +0 -1
  62. package/dist/debugOverlayStore.d.ts.map +0 -1
  63. package/dist/hooks.d.ts +0 -45
  64. package/dist/hooks.d.ts.map +0 -1
  65. package/dist/index-B6YGX2DH.js +0 -2009
  66. package/dist/index-DssZUxGw.cjs +0 -2
  67. package/dist/index.d.ts.map +0 -1
  68. package/dist/reducer.d.ts +0 -39
  69. package/dist/reducer.d.ts.map +0 -1
  70. package/dist/roundHalfToEven.d.ts.map +0 -1
  71. package/dist/types.d.ts.map +0 -1
  72. package/dist/utils/simple-logger.d.ts.map +0 -1
  73. package/dist/utils.d.ts +0 -61
  74. package/dist/utils.d.ts.map +0 -1
  75. /package/dist/{GlobalDebugOverlay.d.ts → src/GlobalDebugOverlay.d.ts} +0 -0
  76. /package/dist/{PanelGroup.d.ts → src/PanelGroup.d.ts} +0 -0
  77. /package/dist/{PanelResizeHandle.d.ts → src/PanelResizeHandle.d.ts} +0 -0
  78. /package/dist/{context.d.ts → src/context.d.ts} +0 -0
  79. /package/dist/{roundHalfToEven.d.ts → src/roundHalfToEven.d.ts} +0 -0
  80. /package/dist/{utils → src/utils}/simple-logger.d.ts +0 -0
@@ -1,13 +1,23 @@
1
1
  /**
2
2
  * @file PanelResizeHandle component for custom resizable panels
3
3
  * カスタムリサイズ可能パネルのための PanelResizeHandle コンポーネント
4
+ *
5
+ * The handle does not compute where the boundary is. It renders a zero-length flex item — the anchor —
6
+ * between its two panels, so flex layout itself places it exactly on the boundary, and the interactive
7
+ * button is drawn symmetrically around that anchor. Whatever the panels do (a container resize between two
8
+ * React renders, padding on the group, right-to-left text, a vertical axis), the handle is on the boundary
9
+ * because it *is* the boundary.
10
+ * ハンドルは境界位置を計算しない。2 枚のパネルの間に長さ 0 のフレックスアイテム (アンカー) を描画するため、
11
+ * フレックスレイアウト自身がそれを境界へ配置し、操作用ボタンはそのアンカーを中心に対称に描かれる。
12
+ * コンテナのリサイズ・グループのパディング・RTL・垂直方向のいずれであっても、ハンドルは境界そのものなので必ず一致する。
4
13
  */
5
14
 
6
15
  import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
7
16
  import { twMerge } from "tailwind-merge"
17
+ import { resolvePanelBounds } from "./allocateLayout"
8
18
  import { usePanelGroup } from "./context"
9
- import { roundHalfToEven } from "./roundHalfToEven"
10
19
  import {
20
+ PANEL_ALLOCATION_EPSILON,
11
21
  PANEL_COLLAPSE_THRESHOLD_MAX,
12
22
  PANEL_COLLAPSE_THRESHOLD_RATIO,
13
23
  PANEL_EXPAND_THRESHOLD_MAX,
@@ -23,14 +33,15 @@ import {
23
33
  PANEL_INDICATOR_THICKNESS,
24
34
  PANEL_KEYBOARD_RESIZE_STEP,
25
35
  PANEL_VISIBLE_INDICATOR_SPACING,
36
+ type PanelLayoutData,
26
37
  type PanelResizeHandleProps,
27
38
  type ResizeHandleLayoutData,
28
39
  } from "./types"
29
- import { calculateSnapThreshold, getConstraintInPixels, getContainerSize } from "./utils"
40
+ import { calculateSnapThreshold, findOwningGroup, getGroupLayoutElements, getVisualScale } from "./utils"
30
41
 
31
42
  /**
32
- * Common styles applied to hide the handle and its interactive area entirely.
33
- * 非表示時のハンドルおよび操作領域全体に適用する共通スタイル定数。
43
+ * Styles that remove the handle and its anchor from layout and interaction.
44
+ * ハンドルとアンカーをレイアウトと操作の両方から外すスタイル。
34
45
  */
35
46
  const HIDDEN_HANDLE_STYLE: CSSProperties = {
36
47
  display: "none",
@@ -39,8 +50,8 @@ const HIDDEN_HANDLE_STYLE: CSSProperties = {
39
50
  }
40
51
 
41
52
  /**
42
- * Common styles applied to collapse the visual indicator.
43
- * 視覚インジケーターを非表示にする共通スタイル定数。
53
+ * Style that collapses the visual indicator.
54
+ * 視覚インジケーターを非表示にするスタイル。
44
55
  */
45
56
  const HIDDEN_INDICATOR_STYLE: CSSProperties = {
46
57
  display: "none",
@@ -48,7 +59,7 @@ const HIDDEN_INDICATOR_STYLE: CSSProperties = {
48
59
 
49
60
  /**
50
61
  * Sanitize an optional dimension to ensure a positive finite number.
51
- * オプション寸法を正の有限数値へ正規化する。
62
+ * オプション寸法を正の有限数値へ正規化する処理。
52
63
  */
53
64
  const sanitizeDimension = (value: unknown, defaultValue: number): number => {
54
65
  if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
@@ -59,278 +70,298 @@ const sanitizeDimension = (value: unknown, defaultValue: number): number => {
59
70
 
60
71
  /**
61
72
  * Ensure a numeric spacing value is an even integer for symmetric pixel alignment.
62
- * ピクセル配置の対称性を担保するため数値を偶数整数に整流する。
73
+ * ピクセル配置の対称性を担保するため数値を偶数整数に整流する処理。
63
74
  */
64
75
  const ensureEven = (value: number): number => {
65
76
  const rounded = Math.round(value)
66
77
  return rounded % 2 === 0 ? rounded : rounded + 1
67
78
  }
68
79
 
80
+ /**
81
+ * Report whether an element is laid out right-to-left at this moment.
82
+ * その時点で要素が右から左へレイアウトされているかの判定。
83
+ *
84
+ * The direction is read at the start of each gesture instead of being cached, so a language switch takes
85
+ * effect without remounting the group.
86
+ * 方向はキャッシュせずジェスチャー開始のたびに読む。言語切替がグループの再マウント無しで反映されるようにするため。
87
+ */
88
+ const isRightToLeft = (element: HTMLElement | null): boolean => {
89
+ if (!element || typeof window === "undefined") {
90
+ return false
91
+ }
92
+ return window.getComputedStyle(element).direction === "rtl"
93
+ }
94
+
95
+ /**
96
+ * Travel range of the boundary a handle sits on, in pixels from the start of the panel pair.
97
+ * ハンドルが載る境界の可動域 (パネル対の start からのピクセル距離)。
98
+ */
99
+ type SeparatorBounds = {
100
+ /** Where the boundary sits now / 境界の現在位置 */
101
+ position: number
102
+ /** Lowest position this handle can reach, collapsing included / このハンドルが到達できる最小位置 (折りたたみを含む) */
103
+ lower: number
104
+ /** Highest position this handle can reach, collapsing included / このハンドルが到達できる最大位置 (折りたたみを含む) */
105
+ upper: number
106
+ /** Lowest position a plain resize can reach / 単なるリサイズで到達できる最小位置 */
107
+ resizeLower: number
108
+ /** Highest position a plain resize can reach / 単なるリサイズで到達できる最大位置 */
109
+ resizeUpper: number
110
+ }
111
+
112
+ /**
113
+ * Resolve where the boundary between two panels is and how far it can travel.
114
+ * 2 枚のパネルの境界が今どこにあり、どこまで動けるかを解決する処理。
115
+ *
116
+ * The range is what **this** handle can reach, not what the pair could reach through any means. Collapsing
117
+ * is a position the boundary can hold, so it widens the range — but only in the direction this handle is
118
+ * allowed to collapse, and a panel already collapsed can only be reopened from the side it was closed from
119
+ * (`EXPAND_PANEL` rejects any other direction). A handle that can reach nothing therefore reports a range of
120
+ * zero width instead of advertising travel it would swallow the key press for.
121
+ * 公開するのは **このハンドル** が到達できる範囲であり、対が何らかの手段で到達しうる範囲ではない。
122
+ * 折りたたみは境界が取りうる位置なので範囲を広げるが、広がるのはこのハンドルが折りたためる向きだけで、
123
+ * 既に折りたたまれたパネルは閉じたのと同じ向きからしか開けない (`EXPAND_PANEL` が他の向きを拒否する)。
124
+ * どこへも動けないハンドルは、幅 0 の範囲を報告する (動けると偽って キー入力だけ飲み込まないために)。
125
+ *
126
+ * @param startPanel - Panel before the boundary / 境界より手前のパネル
127
+ * @param endPanel - Panel after the boundary / 境界より奥のパネル
128
+ * @param containerSize - Container content-box length / コンテナ内容領域長
129
+ * @returns Boundary position and its travel range, or undefined when the pair has no length / 境界位置と可動域、対に長さが無ければ undefined
130
+ */
131
+ const resolveSeparatorBounds = (startPanel: PanelLayoutData, endPanel: PanelLayoutData, containerSize: number): SeparatorBounds | undefined => {
132
+ const startSize = startPanel.collapsed ? 0 : startPanel.size
133
+ const endSize = endPanel.collapsed ? 0 : endPanel.size
134
+ const total = startSize + endSize
135
+ if (total <= 0) {
136
+ return undefined
137
+ }
138
+ const startBounds = resolvePanelBounds(startPanel, containerSize)
139
+ const endBounds = resolvePanelBounds(endPanel, containerSize)
140
+ // 単なるリサイズで届く範囲は、双方の制約が同時に効いた区間 (reducer の RESIZE_PANELS と同じ式)
141
+ const resizeLower = Math.max(startBounds.min, total - endBounds.max)
142
+ const resizeUpper = Math.max(resizeLower, Math.min(startBounds.max, total - endBounds.min))
143
+
144
+ if (startPanel.collapsed) {
145
+ // 閉じた向きと同じ向きからしか開けない。手前のパネルはこのハンドルから見て "end" 側で閉じている
146
+ const canReopen = startPanel.collapsedByDirection === "end"
147
+ return { position: 0, lower: 0, upper: canReopen ? Math.max(0, resizeUpper) : 0, resizeLower, resizeUpper }
148
+ }
149
+ if (endPanel.collapsed) {
150
+ const canReopen = endPanel.collapsedByDirection === "start"
151
+ return { position: total, lower: canReopen ? Math.min(total, resizeLower) : total, upper: total, resizeLower, resizeUpper }
152
+ }
153
+
154
+ // 折りたたみは境界が対の端へ到達した状態そのものなので、許される向きだけ範囲を端まで広げる
155
+ const lower = Math.min(startSize, startPanel.collapseFromEnd ? 0 : resizeLower)
156
+ const upper = Math.max(startSize, lower, endPanel.collapseFromStart ? total : resizeUpper)
157
+ return { position: startSize, lower, upper, resizeLower, resizeUpper }
158
+ }
159
+
69
160
  /**
70
161
  * Render an interactive divider that manages adjacent panel resizing gestures.
71
162
  * 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
72
163
  */
73
164
  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()
165
+ const { id, disabled = false, className, style, children, onDragging, thickness, indicator = true, indicatorThickness, indicatorLength, indicatorBarThickness, indicatorBarLength, title, onPointerDown, onKeyDown, ...elementProps } = props
166
+ const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, layoutOrderKey, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
167
+
76
168
  const [isDragging, setIsDragging] = useState(false)
77
- const handleRef = useRef<HTMLButtonElement>(null)
78
- const containerRef = useRef<HTMLElement | null>(null)
169
+ const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ start: string | null; end: string | null }>({ start: null, end: null })
79
170
 
80
- const leftPanelId = useRef<string>("")
81
- const rightPanelId = useRef<string>("")
171
+ const anchorRef = useRef<HTMLDivElement>(null)
172
+ const handleRef = useRef<HTMLButtonElement>(null)
173
+ // ドラッグ中の move ハンドラーは開始時のクロージャで動き続けるため、最新の状態は ref 経由で読む。
174
+ // これが無いと、同じジェスチャーの中で折りたたんだパネルを引き戻しても展開できない
175
+ const layoutRef = useRef({ getPanel, containerSize })
176
+ layoutRef.current = { getPanel, containerSize }
177
+ const startPanelIdRef = useRef("")
178
+ const endPanelIdRef = useRef("")
82
179
  // 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
83
180
  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 })
181
+ const lastMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
182
+
87
183
  const generatedId = useId()
88
184
  const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
89
- const lastHandleMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
90
185
  const resolvedHandleId = id ?? handleIdRef.current
186
+ const isHorizontal = direction === "horizontal"
91
187
  const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
92
188
  const isHandleDisabled = disabled || hasLayoutConstraintViolation
93
189
 
94
190
  /**
95
- * Detect the panels adjacent to this handle by scanning sibling DOM elements.
96
- * DOM の兄弟要素を走査してこのハンドルに隣接する左右のパネルを検出する。
191
+ * Detect the panels this handle sits between by scanning its siblings in the DOM.
192
+ * DOM の兄弟要素を走査して、このハンドルが挟んでいるパネルを検出する処理。
97
193
  */
98
194
  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")
195
+ const anchor = anchorRef.current
196
+ // 中間要素が挟まると親はグループではないため、所有グループを辿って解決する
197
+ const group = findOwningGroup(anchor)
198
+ if (!(anchor && group)) {
199
+ return
108
200
  }
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
201
 
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
202
+ const elements = getGroupLayoutElements(group)
203
+ const index = elements.indexOf(anchor)
204
+ let startId = ""
205
+ let endId = ""
206
+ for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
207
+ const candidate = elements[cursor].dataset.panelId
208
+ if (candidate !== undefined) {
209
+ startId = candidate
119
210
  break
120
211
  }
121
212
  }
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
213
+ for (let cursor = index + 1; cursor < elements.length; cursor += 1) {
214
+ const candidate = elements[cursor].dataset.panelId
215
+ if (candidate !== undefined) {
216
+ endId = candidate
127
217
  break
128
218
  }
129
219
  }
130
220
 
131
- const nextLeftId = leftPanelElement?.dataset.panelId || ""
132
- const nextRightId = rightPanelElement?.dataset.panelId || ""
133
-
134
- if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
221
+ if (startId === startPanelIdRef.current && endId === endPanelIdRef.current) {
135
222
  return
136
223
  }
137
-
138
- leftPanelId.current = nextLeftId
139
- rightPanelId.current = nextRightId
140
-
141
- setAdjacentPanelIds({
142
- left: nextLeftId || null,
143
- right: nextRightId || null,
144
- })
224
+ startPanelIdRef.current = startId
225
+ endPanelIdRef.current = endId
226
+ setAdjacentPanelIds({ start: startId || null, end: endId || null })
145
227
  }, [])
146
228
 
147
- // パネル構成 (ID 列) の変化を検知するためのキー。ドラッグ中のサイズ更新では変化しない
148
- const panelIdsKey = useMemo(() => panels.map((panel) => panel.id).join("|"), [panels])
149
-
150
229
  useEffect(() => {
151
- // マウント時とパネルの増減・並び替え時に隣接パネルを再検出する
152
- // (マウント時のみだと兄弟 Panel の条件付き追加/削除でハンドルが古い ID を掴んだままになる)
230
+ // マウント時と、グループの DOM 順が変わるたびに隣接パネルを再検出する。
231
+ // パネルの id 列だけを見ていると、ハンドル自身が別の境界へ移った並べ替えを取りこぼし、
232
+ // 見た目とは違う 2 枚を掴み続ける
153
233
  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])
234
+ }, [detectAdjacentPanels, layoutOrderKey])
170
235
 
171
236
  useEffect(() => {
172
- // コンポーネントのアンマウント時に、このハンドルの測定結果を破棄する
173
- const finalId = handleIdRef.current
174
- return () => {
175
- reportHandleMeasurement(finalId, null)
176
- }
177
- }, [reportHandleMeasurement])
178
-
179
- useEffect(() => {
180
- // ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
181
237
  return () => {
238
+ // ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
182
239
  dragCleanupRef.current?.()
183
240
  }
184
241
  }, [])
185
242
 
186
243
  /**
187
- * Initiates a resize drag interaction when the handle receives a pointer down event.
244
+ * Start a resize drag when the handle receives a pointer down event.
188
245
  * リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
189
246
  */
190
247
  const handlePointerDown = useCallback(
191
248
  (event: React.PointerEvent<HTMLButtonElement>) => {
192
- if (isHandleDisabled || !containerRef.current) return
193
-
249
+ if (isHandleDisabled) {
250
+ return
251
+ }
194
252
  // 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
195
- if (dragCleanupRef.current) return
253
+ if (dragCleanupRef.current) {
254
+ return
255
+ }
256
+ // 主ボタン以外は掴まない。右ボタンで掴むと、コンテキストメニューを開きながらレイアウトが動く
257
+ // (タッチ・ペンの pointerdown も主ボタンとして 0 が届く)
258
+ if (event.button !== 0) {
259
+ return
260
+ }
261
+
262
+ const startPanel = getPanel(startPanelIdRef.current)
263
+ const endPanel = getPanel(endPanelIdRef.current)
264
+ if (!(startPanel && endPanel)) {
265
+ return
266
+ }
196
267
 
197
268
  event.preventDefault()
198
269
  event.stopPropagation()
199
270
 
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)
271
+ const startId = startPanelIdRef.current
272
+ const endId = endPanelIdRef.current
273
+ const initialStartSize = startPanel.size
274
+ const initialTotal = startPanel.size + endPanel.size
275
+ const origin = isHorizontal ? event.clientX : event.clientY
276
+ const group = findOwningGroup(anchorRef.current)
277
+ // RTL では DOM 順 (論理順) と視覚順が反転するため、水平ドラッグの変位符号を反転する
278
+ const directionSign = isHorizontal && isRightToLeft(group) ? -1 : 1
279
+ // ポインタ座標は視覚ピクセル、配分器が扱うのはレイアウトピクセル。
280
+ // 変形された祖先の下で換算を省くと、掴んだ点がカーソルから滑り出していく
281
+ const visualScale = group ? getVisualScale(group, direction) : 1
217
282
 
218
- // このドラッグを開始したポインタだけを追跡する (2 本目のタッチ等の混入を防ぐ)
219
283
  const dragPointerId = event.pointerId
220
284
  const captureTarget = event.currentTarget
285
+ // 追従は document の pointermove / pointerup で行うため、キャプチャは無くても成立する。
286
+ // 実装しない環境 (jsdom など) で例外を投げると、利用者側のテストがドラッグを検証できなくなる
287
+ captureTarget.setPointerCapture?.(dragPointerId)
221
288
 
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
289
+ setIsDragging(true)
290
+ onDragging?.(true)
236
291
 
237
- moveEvent.preventDefault()
238
- moveEvent.stopPropagation()
292
+ let lastUpdateTime = 0
293
+ let throttledMove: PointerEvent | null = null
239
294
 
240
- const now = performance.now()
241
- if (now - lastUpdateTime < throttleMs) return
242
- lastUpdateTime = now
295
+ const applyMove = (moveEvent: PointerEvent) => {
296
+ const delta = (((isHorizontal ? moveEvent.clientX : moveEvent.clientY) - origin) * directionSign) / visualScale
297
+ const nextStartSize = initialStartSize + delta
298
+ const nextEndSize = initialTotal - nextStartSize
243
299
 
244
- const rawPixelDelta = direction === "horizontal" ? moveEvent.clientX - startPosition.x : moveEvent.clientY - startPosition.y
245
- const pixelDelta = isRtlDrag ? -rawPixelDelta : rawPixelDelta
246
- if (pixelDelta === 0) {
300
+ const layout = layoutRef.current
301
+ const currentStart = layout.getPanel(startId)
302
+ const currentEnd = layout.getPanel(endId)
303
+ if (!(currentStart && currentEnd)) {
247
304
  return
248
305
  }
249
306
 
250
- const newLeftSizeInPixels = initialLeftSize + pixelDelta
251
- const newRightSizeInPixels = initialRightSize - pixelDelta
252
-
253
- const updatedLeftPanel = getPanel(leftPanelId.current)
254
- const updatedRightPanel = getPanel(rightPanelId.current)
307
+ const collapseThreshold = Math.min(layout.containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
308
+ const expandThreshold = Math.min(layout.containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
255
309
 
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
310
+ if (currentEnd.collapsed) {
311
+ if (nextEndSize > expandThreshold) {
312
+ expandPanel(endId, "start", nextEndSize)
275
313
  }
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
314
+ return
315
+ }
316
+ if (currentStart.collapsed) {
317
+ if (nextStartSize > expandThreshold) {
318
+ expandPanel(startId, "end", nextStartSize)
285
319
  }
320
+ return
321
+ }
322
+ if (currentEnd.collapseFromStart && nextEndSize <= collapseThreshold) {
323
+ collapsePanel(endId, "start")
324
+ return
325
+ }
326
+ if (currentStart.collapseFromEnd && nextStartSize <= collapseThreshold) {
327
+ collapsePanel(startId, "end")
328
+ return
329
+ }
286
330
 
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
- }
331
+ resizePanels(startId, endId, nextStartSize)
332
+ }
314
333
 
315
- if (updatedLeftPanel.collapsed || updatedRightPanel.collapsed) {
316
- return
317
- }
334
+ const handlePointerMove = (moveEvent: PointerEvent) => {
335
+ // 別ポインタのイベントはドラッグ計算に混入させない
336
+ if (moveEvent.pointerId !== dragPointerId) {
337
+ return
338
+ }
339
+ moveEvent.preventDefault()
340
+ moveEvent.stopPropagation()
318
341
 
319
- resizePanels(leftPanelId.current, rightPanelId.current, newLeftSizeInPixels, newRightSizeInPixels)
342
+ const now = performance.now()
343
+ if (now - lastUpdateTime < PANEL_HANDLE_DRAG_THROTTLE_MS) {
344
+ // 間引いた移動は捨てずに保持する。離上時に反映しないと、指を離した位置と確定した境界がずれる
345
+ throttledMove = moveEvent
346
+ return
320
347
  }
348
+ lastUpdateTime = now
349
+ throttledMove = null
350
+ applyMove(moveEvent)
321
351
  }
322
352
 
323
353
  /**
324
354
  * Tear down the active drag: remove listeners, release capture, and reset state.
325
- * 進行中ドラッグの後始末: リスナー除去・キャプチャ解放・状態リセットを一括で行う。
355
+ * 進行中ドラッグの後始末 (リスナー除去・キャプチャ解放・状態リセット)。
326
356
  * pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
327
357
  */
328
358
  let dragActive = true
329
359
  const stopDragging = () => {
330
- if (!dragActive) return
360
+ if (!dragActive) {
361
+ return
362
+ }
331
363
  dragActive = false
332
364
  dragCleanupRef.current = null
333
-
334
365
  setIsDragging(false)
335
366
  onDragging?.(false)
336
367
 
@@ -345,20 +376,27 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
345
376
 
346
377
  const handlePointerUp = (upEvent: PointerEvent) => {
347
378
  // 別ポインタの離上ではドラッグを終了しない
348
- if (upEvent.pointerId !== dragPointerId) return
379
+ if (upEvent.pointerId !== dragPointerId) {
380
+ return
381
+ }
382
+ if (throttledMove) {
383
+ const pending = throttledMove
384
+ throttledMove = null
385
+ applyMove(pending)
386
+ }
349
387
  stopDragging()
350
388
  }
351
-
352
389
  const handlePointerCancel = (cancelEvent: PointerEvent) => {
353
390
  // OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
354
- if (cancelEvent.pointerId !== dragPointerId) return
355
- stopDragging()
391
+ if (cancelEvent.pointerId === dragPointerId) {
392
+ stopDragging()
393
+ }
356
394
  }
357
-
358
395
  const handleLostPointerCapture = (lostEvent: PointerEvent) => {
359
396
  // キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
360
- if (lostEvent.pointerId !== dragPointerId) return
361
- stopDragging()
397
+ if (lostEvent.pointerId === dragPointerId) {
398
+ stopDragging()
399
+ }
362
400
  }
363
401
 
364
402
  document.addEventListener("pointermove", handlePointerMove)
@@ -367,203 +405,103 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
367
405
  captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
368
406
  dragCleanupRef.current = stopDragging
369
407
  },
370
- [isHandleDisabled, direction, resizePanels, getPanel, collapsePanel, expandPanel, onDragging],
408
+ [isHandleDisabled, isHorizontal, direction, getPanel, resizePanels, collapsePanel, expandPanel, onDragging],
371
409
  )
372
410
 
373
411
  /**
374
412
  * Resize adjacent panels with arrow keys for keyboard accessibility.
375
413
  * キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
376
- * Shift 併用で 5 倍のステップ幅を適用する。
377
414
  */
378
415
  const handleKeyDown = useCallback(
379
416
  (event: React.KeyboardEvent<HTMLButtonElement>) => {
380
- if (isHandleDisabled) return
417
+ if (isHandleDisabled) {
418
+ return
419
+ }
420
+ const decreaseKey = isHorizontal ? "ArrowLeft" : "ArrowUp"
421
+ const increaseKey = isHorizontal ? "ArrowRight" : "ArrowDown"
422
+ if (event.key !== decreaseKey && event.key !== increaseKey) {
423
+ return
424
+ }
381
425
 
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
426
+ const startId = startPanelIdRef.current
427
+ const endId = endPanelIdRef.current
428
+ const startPanel = getPanel(startId)
429
+ const endPanel = getPanel(endId)
430
+ if (!(startPanel && endPanel)) {
431
+ return
432
+ }
433
+ const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
434
+ // どこへも動けないハンドルはキーを飲み込まない。飲み込むと、何も起きないまま
435
+ // ページのスクロールまで止まる「効かないキー」になる
436
+ if (!bounds || bounds.upper - bounds.lower <= PANEL_ALLOCATION_EPSILON) {
437
+ return
438
+ }
386
439
 
387
440
  event.preventDefault()
388
441
  event.stopPropagation()
389
442
 
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
443
+ const step = event.shiftKey ? PANEL_KEYBOARD_RESIZE_STEP * 5 : PANEL_KEYBOARD_RESIZE_STEP
444
+ // RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する
445
+ const directionSign = isHorizontal && isRightToLeft(findOwningGroup(anchorRef.current)) ? -1 : 1
446
+ const delta = (event.key === increaseKey ? step : -step) * directionSign
447
+
448
+ // 折りたたまれたパネルは、境界を自分の方へ押し返すキーで開く。
449
+ // 開けないと、キーボードだけを使う利用者にとって折りたたみは戻れない行き止まりになる
450
+ if (startPanel.collapsed) {
451
+ if (delta > 0) {
452
+ // 目標長を渡さずに開く: 折りたたむ前の長さへ戻すのが「開く」の意味であり、
453
+ // 1 ステップ分だけ開いても最小値へ切り上げられるだけで何も伝わらない
454
+ expandPanel(startId, "end")
455
+ }
456
+ return
430
457
  }
431
- for (let index = 0; index < panels.length; index += 1) {
432
- if (panels[index].id === panelId) {
433
- return index
458
+ if (endPanel.collapsed) {
459
+ if (delta < 0) {
460
+ expandPanel(endId, "start")
434
461
  }
462
+ return
435
463
  }
436
- return -1
437
- }
438
464
 
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
465
+ // リサイズで届く端まで来たパネルをさらに押すキーは、ドラッグで端まで引いたときと同じく折りたたみになる。
466
+ // 判定に使うのはパネル自身の最小値ではなく対の可動域の端である。隣の上限が先に効く構成では、
467
+ // パネルは自分の最小値へ決して到達できず、キーボードからは永久に折りたためなくなる
468
+ if (delta < 0 && startPanel.collapseFromEnd && bounds.position <= bounds.resizeLower + PANEL_ALLOCATION_EPSILON) {
469
+ collapsePanel(startId, "end")
470
+ return
451
471
  }
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
472
+ if (delta > 0 && endPanel.collapseFromStart && bounds.position >= bounds.resizeUpper - PANEL_ALLOCATION_EPSILON) {
473
+ collapsePanel(endId, "start")
474
+ return
461
475
  }
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])
476
+ resizePanels(startId, endId, startPanel.size + delta)
477
+ },
478
+ [isHandleDisabled, isHorizontal, getPanel, resizePanels, collapsePanel, expandPanel, containerSize],
479
+ )
485
480
 
486
481
  const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
487
482
  const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
488
483
 
489
- const rawThickness = sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS)
484
+ const resolvedThickness = Math.max(1, sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS))
490
485
  const defaultIndicatorLength = isHorizontal ? PANEL_INDICATOR_LENGTH_HORIZONTAL : PANEL_INDICATOR_LENGTH_VERTICAL
491
486
  const defaultBarLength = isHorizontal ? PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL : PANEL_INDICATOR_BAR_LENGTH_VERTICAL
492
487
 
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
488
  // 幾何学的内包不変条件: 内部バー寸法がインジケーター外枠およびハンドル操作領域を超えないようクランプ
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])
489
+ const resolvedIndicatorThickness = Math.min(resolvedThickness, Math.max(1, sanitizeDimension(indicatorThickness ?? indicatorConfig?.thickness, PANEL_INDICATOR_THICKNESS)))
490
+ const resolvedIndicatorLength = Math.max(1, sanitizeDimension(indicatorLength ?? indicatorConfig?.length, defaultIndicatorLength))
491
+ const resolvedBarThickness = Math.min(resolvedIndicatorThickness, Math.max(1, sanitizeDimension(indicatorBarThickness ?? indicatorConfig?.barThickness, PANEL_INDICATOR_BAR_THICKNESS)))
492
+ const resolvedBarLength = Math.min(resolvedIndicatorLength, Math.max(1, sanitizeDimension(indicatorBarLength ?? indicatorConfig?.barLength, defaultBarLength)))
545
493
 
546
494
  const indicatorGrouping = useMemo(() => {
547
- const snapThreshold = calculateSnapThreshold(containerLength)
495
+ // 隣接パネルが閉じていると複数のハンドルが同じ境界へ重なるため、その重なり具合を数える
496
+ const snapThreshold = calculateSnapThreshold(containerSize)
548
497
  const isEffectivelyCollapsed = (panelIndex: number) => {
549
- if (panelIndex < 0 || panelIndex >= panels.length) {
550
- return false
551
- }
552
498
  const target = panels[panelIndex]
553
499
  if (!target) {
554
500
  return false
555
501
  }
556
- if (target.collapsed) {
557
- return true
558
- }
559
- return target.size <= snapThreshold
502
+ return target.collapsed || target.size <= snapThreshold
560
503
  }
561
-
562
504
  const countCollapsed = (startIndex: number, step: 1 | -1) => {
563
- if (!isEffectivelyCollapsed(startIndex)) {
564
- return 0
565
- }
566
-
567
505
  let count = 0
568
506
  for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
569
507
  if (!isEffectivelyCollapsed(index)) {
@@ -574,18 +512,13 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
574
512
  return count
575
513
  }
576
514
 
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
515
+ const startIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.start)
516
+ const endIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.end)
517
+ const startCollapsedCount = startIndex >= 0 ? countCollapsed(startIndex, -1) : 0
518
+ const endCollapsedCount = endIndex >= 0 ? countCollapsed(endIndex, 1) : 0
583
519
 
584
- return {
585
- groupSize,
586
- overlapIndex,
587
- }
588
- }, [adjacentIndices.left, adjacentIndices.right, panels, containerLength])
520
+ return { groupSize: startCollapsedCount + endCollapsedCount + 1, overlapIndex: startCollapsedCount }
521
+ }, [adjacentPanelIds.start, adjacentPanelIds.end, panels, containerSize])
589
522
 
590
523
  const shouldHideHandle = useMemo(() => {
591
524
  if (hasLayoutConstraintViolation) {
@@ -595,61 +528,106 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
595
528
  return false
596
529
  }
597
530
  // 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
598
- const lastIndex = indicatorGrouping.groupSize - 1
599
- return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
531
+ return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < indicatorGrouping.groupSize - 1
600
532
  }, [indicatorGrouping, hasLayoutConstraintViolation])
601
533
 
602
534
  const indicatorOffset = useMemo(() => {
603
535
  if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
604
536
  return 0
605
537
  }
538
+ // 重なった 2 つの端点インジケーターを等間隔で振り分ける (半ピクセル除算を避けるため偶数間隔)
539
+ const spacing = ensureEven(Math.max(PANEL_VISIBLE_INDICATOR_SPACING, resolvedIndicatorThickness + 4))
540
+ return (indicatorGrouping.overlapIndex === 0 ? -0.5 : 0.5) * spacing
541
+ }, [indicatorGrouping, shouldHideHandle, resolvedIndicatorThickness])
606
542
 
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
543
+ const anchorStyle = useMemo<CSSProperties>(() => {
544
+ const base: CSSProperties = {
545
+ // 長さ 0 のフレックスアイテム: フレックスレイアウトがこれを境界そのものへ配置する
546
+ position: "relative",
547
+ flex: "0 0 0px",
548
+ alignSelf: "stretch",
549
+ // DOM 順を配置順の真実として扱うため、クラス由来の order で並びが変わらないようにする
550
+ order: 0,
551
+ margin: 0,
552
+ overflow: "visible",
553
+ ...(isHorizontal ? { width: 0, minWidth: 0 } : { height: 0, minHeight: 0 }),
554
+ }
555
+ return shouldHideHandle ? { ...base, ...HIDDEN_HANDLE_STYLE } : base
556
+ }, [isHorizontal, shouldHideHandle])
557
+
558
+ // 利用者が独自に装飾できるよう、解決済みの寸法を CSS 変数として公開する
559
+ const customProperties = useMemo<Record<string, string>>(
560
+ () => ({
561
+ "--rp-handle-thickness": `${resolvedThickness}px`,
562
+ "--rp-indicator-thickness": `${resolvedIndicatorThickness}px`,
563
+ "--rp-indicator-length": `${resolvedIndicatorLength}px`,
564
+ "--rp-indicator-bar-thickness": `${resolvedBarThickness}px`,
565
+ "--rp-indicator-bar-length": `${resolvedBarLength}px`,
566
+ }),
567
+ [resolvedThickness, resolvedIndicatorThickness, resolvedIndicatorLength, resolvedBarThickness, resolvedBarLength],
568
+ )
617
569
 
618
- // 水平かつ RTL の環境では start パネルが物理右側にあるため論理順の正シフトを物理左方向(負)へ反転
619
- return isHorizontal && isRtl ? -rawOffset : rawOffset
620
- }, [indicatorGrouping, shouldHideHandle, resolvedIndicatorThickness, isHorizontal, isRtl])
570
+ const handleStyle = useMemo<CSSProperties>(() => {
571
+ // アンカー原点が境界。半厚みだけ手前へずらすと操作領域の中心線が境界に重なる。
572
+ // 端数は描画時にピクセルへスナップされるため、ここで丸める必要はない
573
+ return {
574
+ ...customProperties,
575
+ ...style,
576
+ // ここから下は境界に載るという前提そのもの。利用者のスタイルやクラスで変わると
577
+ // 操作領域の中心が境界から外れ、touch-action が戻るとタッチではスクロールが優先されてしまう
578
+ position: "absolute",
579
+ zIndex: 20,
580
+ touchAction: "none",
581
+ // 位置は論理プロパティで与える。アンカーは寸法 0 なので RTL でも同じ点に重なるが、
582
+ // 「境界からの距離」という意図をそのまま表せる
583
+ ...(isHorizontal ? { insetBlockStart: 0, insetInlineStart: `${-resolvedThickness / 2}px`, width: `${resolvedThickness}px`, height: "100%" } : { insetInlineStart: 0, insetBlockStart: `${-resolvedThickness / 2}px`, height: `${resolvedThickness}px`, width: "100%" }),
584
+ }
585
+ }, [isHorizontal, resolvedThickness, customProperties, style])
621
586
 
622
587
  const indicatorWrapperStyle = useMemo<CSSProperties>(() => {
623
588
  if (shouldHideHandle || isIndicatorExplicitlyHidden) {
624
589
  return HIDDEN_INDICATOR_STYLE
625
590
  }
626
-
627
- const width = isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength
628
- const height = isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness
629
-
630
591
  const dynamicStyle: CSSProperties = {
631
- width: `${width}px`,
632
- height: `${height}px`,
592
+ width: `${isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength}px`,
593
+ height: `${isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness}px`,
633
594
  flexShrink: 0,
634
595
  }
635
-
636
596
  if (indicatorOffset !== 0) {
637
- dynamicStyle.transform = isHorizontal ? `translateX(${indicatorOffset}px)` : `translateY(${indicatorOffset}px)`
597
+ // 論理方向のオフセットなので RTL では自動的に鏡像になる (符号を分岐させない)
598
+ dynamicStyle.position = "relative"
599
+ if (isHorizontal) {
600
+ dynamicStyle.insetInlineStart = `${indicatorOffset}px`
601
+ } else {
602
+ dynamicStyle.insetBlockStart = `${indicatorOffset}px`
603
+ }
638
604
  }
639
-
640
605
  return dynamicStyle
641
606
  }, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
642
607
 
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`,
608
+ const indicatorBarStyle = useMemo<CSSProperties>(
609
+ () => ({
610
+ width: `${isHorizontal ? resolvedBarThickness : resolvedBarLength}px`,
611
+ height: `${isHorizontal ? resolvedBarLength : resolvedBarThickness}px`,
650
612
  flexShrink: 0,
651
- }
652
- }, [isHorizontal, resolvedBarThickness, resolvedBarLength])
613
+ }),
614
+ [isHorizontal, resolvedBarThickness, resolvedBarLength],
615
+ )
616
+
617
+ const baseHandleClasses = useMemo(
618
+ () =>
619
+ twMerge(
620
+ "group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
621
+ isHorizontal ? "cursor-col-resize h-full" : "cursor-row-resize w-full",
622
+ !isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
623
+ !isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
624
+ isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
625
+ isHandleDisabled && "cursor-not-allowed opacity-50",
626
+ "focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
627
+ className,
628
+ ),
629
+ [isHorizontal, isHandleDisabled, isDragging, className],
630
+ )
653
631
 
654
632
  const indicatorWrapperClasses = useMemo(
655
633
  () =>
@@ -673,125 +651,108 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
673
651
  [isDragging, isHandleDisabled, indicatorConfig?.barClassName],
674
652
  )
675
653
 
676
- const updateHandleMeasurement = useCallback(() => {
677
- const element = handleRef.current
678
- if (!element) {
654
+ useEffect(() => {
655
+ // ハンドル ID が外部から変更された場合に、旧 ID の測定結果を破棄する
656
+ if (resolvedHandleId === handleIdRef.current) {
679
657
  return
680
658
  }
659
+ const previousId = handleIdRef.current
660
+ handleIdRef.current = resolvedHandleId
661
+ lastMeasurementRef.current = null
662
+ reportHandleMeasurement(previousId, null)
663
+ }, [reportHandleMeasurement, resolvedHandleId])
681
664
 
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
-
665
+ useEffect(() => {
713
666
  const measurement: ResizeHandleLayoutData = {
714
667
  id: resolvedHandleId,
715
- thickness,
668
+ thickness: shouldHideHandle ? 0 : resolvedThickness,
716
669
  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
670
+ visible: !shouldHideHandle && resolvedThickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
671
+ startPanelId: adjacentPanelIds.start,
672
+ endPanelId: adjacentPanelIds.end,
733
673
  }
734
-
735
- lastHandleMeasurementRef.current = measurement
674
+ lastMeasurementRef.current = measurement
736
675
  reportHandleMeasurement(resolvedHandleId, measurement)
737
- }, [direction, adjacentPanelIds.left, adjacentPanelIds.right, reportHandleMeasurement, resolvedHandleId, shouldHideHandle])
676
+ }, [resolvedHandleId, resolvedThickness, shouldHideHandle, direction, adjacentPanelIds.start, adjacentPanelIds.end, reportHandleMeasurement])
738
677
 
739
678
  useEffect(() => {
740
- updateHandleMeasurement()
741
- }, [updateHandleMeasurement])
742
-
743
- useEffect(() => {
744
- const element = handleRef.current
745
- if (!element) {
746
- return
679
+ return () => {
680
+ // アンマウント時にこのハンドルの測定結果を破棄する。
681
+ // ID は effect の実行時ではなく後始末の時点で読む (途中で ID を変えたハンドルの登録が残らないように)
682
+ reportHandleMeasurement(handleIdRef.current, null)
747
683
  }
684
+ }, [reportHandleMeasurement])
748
685
 
749
- if (typeof ResizeObserver === "undefined") {
750
- updateHandleMeasurement()
751
- return
752
- }
686
+ // 利用者のハンドラーを黙って捨てず、先に呼んでから自分の処理へ進む。
687
+ // preventDefault() されたイベントは利用者が握り潰したものとして、ジェスチャーを開始しない
688
+ const composedPointerDown = useCallback(
689
+ (event: React.PointerEvent<HTMLButtonElement>) => {
690
+ onPointerDown?.(event)
691
+ if (!event.defaultPrevented) {
692
+ handlePointerDown(event)
693
+ }
694
+ },
695
+ [onPointerDown, handlePointerDown],
696
+ )
753
697
 
754
- const observer = new ResizeObserver(() => {
755
- updateHandleMeasurement()
756
- })
698
+ const composedKeyDown = useCallback(
699
+ (event: React.KeyboardEvent<HTMLButtonElement>) => {
700
+ onKeyDown?.(event)
701
+ if (!event.defaultPrevented) {
702
+ handleKeyDown(event)
703
+ }
704
+ },
705
+ [onKeyDown, handleKeyDown],
706
+ )
757
707
 
758
- observer.observe(element)
708
+ const accessibleLabel = `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`
759
709
 
760
- return () => {
761
- observer.disconnect()
710
+ const separatorValues = useMemo(() => {
711
+ // スプリッターの現在値は「境界より手前のパネルがコンテナに占める割合」。可動域も同じ尺度で公開する。
712
+ // 折りたたまれたパネルは長さ 0 として扱う: 値を落とすと role="separator" が必須属性を失い、
713
+ // 支援技術からは「動かせない仕切り」に見えてしまう
714
+ const startPanel = adjacentPanelIds.start === null ? undefined : getPanel(adjacentPanelIds.start)
715
+ const endPanel = adjacentPanelIds.end === null ? undefined : getPanel(adjacentPanelIds.end)
716
+ if (!(startPanel && endPanel) || containerSize <= 0) {
717
+ return undefined
762
718
  }
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
- }
719
+ const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
720
+ if (!bounds) {
721
+ return undefined
772
722
  }
773
- return merged
774
- }, [overlayBaseStyle, shouldHideHandle, style])
723
+ const toPercentage = (value: number) => Math.round((value / containerSize) * 100)
724
+ return { now: toPercentage(bounds.position), min: toPercentage(bounds.lower), max: toPercentage(bounds.upper) }
725
+ }, [adjacentPanelIds.start, adjacentPanelIds.end, getPanel, containerSize])
775
726
 
776
727
  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>
728
+ <div ref={anchorRef} style={anchorStyle} data-resize-handle-anchor="">
729
+ {/* biome-ignore lint/a11y/useSemanticElements: 操作できるスプリッターは <hr> では表現できない (WAI-ARIA の window splitter パターンは focusable な role="separator" を要求する) */}
730
+ <button
731
+ {...elementProps}
732
+ type="button"
733
+ ref={handleRef}
734
+ className={baseHandleClasses}
735
+ style={handleStyle}
736
+ onPointerDown={composedPointerDown}
737
+ onKeyDown={composedKeyDown}
738
+ disabled={isHandleDisabled}
739
+ role="separator"
740
+ aria-orientation={isHorizontal ? "vertical" : "horizontal"}
741
+ aria-valuenow={separatorValues?.now}
742
+ aria-valuemin={separatorValues?.min}
743
+ aria-valuemax={separatorValues?.max}
744
+ aria-disabled={isHandleDisabled || undefined}
745
+ aria-hidden={shouldHideHandle || undefined}
746
+ aria-label={elementProps["aria-label"] ?? accessibleLabel}
747
+ data-resize-handle-id={resolvedHandleId}
748
+ title={title ?? accessibleLabel}>
749
+ {children ||
750
+ (!isIndicatorExplicitlyHidden && (
751
+ <div className={indicatorWrapperClasses} style={indicatorWrapperStyle} data-resize-handle-indicator="" aria-hidden="true">
752
+ <span className={indicatorBarClasses} style={indicatorBarStyle} data-resize-handle-indicator-bar="" aria-hidden="true" />
753
+ </div>
754
+ ))}
755
+ </button>
756
+ </div>
796
757
  )
797
758
  })