@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
package/src/hooks.ts CHANGED
@@ -1,647 +1,220 @@
1
1
  /**
2
2
  * @file Custom hooks for resizable panels logic
3
3
  * リサイズ可能パネルのロジックのためのカスタムフック
4
+ *
5
+ * The hook owns everything the reducer cannot: it measures the container, keeps the panel order in step
6
+ * with the DOM, and forwards user intent as actions. It never computes a panel size itself.
7
+ * フックはリデューサーが担えない役割だけを持つ: コンテナの計測、DOM 順との同期、利用者の意図のアクション化。
8
+ * パネルサイズ自体はここでは一切計算しない。
4
9
  */
5
10
 
6
11
  import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react"
7
12
  import { usePanelGroup } from "./context"
8
- import { panelReducer } from "./reducer"
9
- import type { LayoutConstraintViolation, PanelCollapseDirection, PanelGroupProps, PanelLayoutData, PanelMeasurement, ResizeHandleLayoutData } from "./types"
10
- import { adjustPixelPanelsByPriority, clamp, getConstraintInPixels, getContainerSize, loadLayout, percentageToPixels, saveLayout, toRoundedPercentage } from "./utils"
13
+ import { getPanelPreferenceInPixels, initialPanelGroupLayoutState, panelReducer } from "./reducer"
14
+ import type { PanelCollapseDirection, PanelGroupProps, PanelMeasurement, PanelRegistration, PersistedPanelSize, ResizeHandleLayoutData } from "./types"
15
+ import { getContainerSize, getGroupLayoutElements, loadLayout, saveLayout, touchesLayoutElements } from "./utils"
11
16
  import { Logger, LogLevel } from "./utils/simple-logger"
12
17
 
13
18
  const logger = new Logger(LogLevel.INFO, "[resize-panels]")
14
19
 
15
20
  /**
16
- * Orchestrate registration, constraint resolution, measurement sync, and persistence for a panel group.
17
- * パネルグループにおける登録処理・制約判定・計測同期・永続化の統括。
21
+ * Delay before the layout is written to storage, in milliseconds.
22
+ * レイアウトを保存領域へ書き出すまでの待機時間 (ms)。
23
+ */
24
+ const AUTO_SAVE_DELAY_MS = 200
25
+
26
+ /**
27
+ * Placeholder that stands for a handle anchor in the group's DOM-order key.
28
+ * グループの DOM 順を表す鍵の中で、ハンドルのアンカーを表す標識。
29
+ */
30
+ const HANDLE_ANCHOR_ORDER_MARK = "\u0000handle"
31
+
32
+ /**
33
+ * Orchestrate measurement, ordering, persistence and user intent for one panel group.
34
+ * 1 つのパネルグループにおける計測・順序同期・永続化・利用者操作の統括。
35
+ *
36
+ * @param props - Panel group props / パネルグループのプロパティ
37
+ * @returns Group ref, context value and the group's base style / グループ ref・コンテキスト値・基準スタイル
18
38
  */
19
39
  export const useResizablePanels = ({ direction, style, onLayout, autoSaveId, showDebugInfo = false }: PanelGroupProps) => {
20
- const [panels, dispatch] = useReducer(panelReducer, [])
21
- const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
22
- const [isContainerReady, setIsContainerReady] = useState(false)
23
- const [isInitialized, setIsInitialized] = useState(false)
24
- const [layoutConstraintViolation, setLayoutConstraintViolation] = useState<LayoutConstraintViolation | null>(null)
40
+ const [layout, dispatch] = useReducer(panelReducer, initialPanelGroupLayoutState)
25
41
  const [panelMeasurements, setPanelMeasurements] = useState<Record<string, PanelMeasurement>>({})
26
42
  const [handleMeasurements, setHandleMeasurements] = useState<Record<string, ResizeHandleLayoutData>>({})
43
+
27
44
  const groupRef = useRef<HTMLDivElement>(null)
45
+ const [layoutOrderKey, setLayoutOrderKey] = useState("")
46
+ const registrationSignaturesRef = useRef<Record<string, string>>({})
28
47
  const layoutCallbackRef = useRef(onLayout)
29
- const initializedRef = useRef(false)
30
- const initializedPanelCountRef = useRef(0)
31
- const prevContainerSizeRef = useRef(containerSize)
32
- const containerInfoRef = useRef(containerSize)
33
- const isContainerReadyRef = useRef(isContainerReady)
34
- const violationReasonRef = useRef<LayoutConstraintViolation["reason"] | null>(layoutConstraintViolation?.reason ?? null)
35
- const panelRegistrationSnapshotRef = useRef<Record<string, string>>({})
36
- const lastStablePanelsRef = useRef<PanelLayoutData[] | null>(null)
37
- const previousViolationRef = useRef<LayoutConstraintViolation | null>(null)
38
- const containerSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
39
- const lastRawContainerSizeRef = useRef({ width: 0, height: 0 })
40
- const committedContainerSizeRef = useRef({ width: 0, height: 0 })
41
- const panelSyncTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
42
- const lastRawPanelMeasurementsRef = useRef<Record<string, PanelMeasurement>>({})
43
-
44
- const clonePanels = useCallback((panelList: PanelLayoutData[]) => {
45
- return panelList.map((panel) => ({ ...panel }))
46
- }, [])
47
-
48
- useEffect(() => {
49
- containerInfoRef.current = containerSize
50
- }, [containerSize])
51
-
52
- useEffect(() => {
53
- isContainerReadyRef.current = isContainerReady
54
- }, [isContainerReady])
55
-
56
- useEffect(() => {
57
- violationReasonRef.current = layoutConstraintViolation?.reason ?? null
58
- }, [layoutConstraintViolation?.reason])
48
+ layoutCallbackRef.current = onLayout
59
49
 
60
- useEffect(() => {
61
- if (layoutConstraintViolation) {
62
- return
63
- }
64
- const axisSize = direction === "horizontal" ? containerSize.width : containerSize.height
65
- if (!Number.isFinite(axisSize) || axisSize <= 4) {
66
- return
67
- }
68
- if (panels.length === 0) {
50
+ useLayoutEffect(() => {
51
+ // ResizeObserver でグループの内容領域長を追跡する。観測値は丸めも遅延もせずそのまま確定させる:
52
+ // 端数を落とすと、状態が持つ長さと描画される長さがずれたままになる。
53
+ // 主軸の長さが動かないフレーム (交差軸だけのリサイズ) はここで捨てる。reducer は同一状態を
54
+ // 返すものの、dispatch 自体がレンダーを予約してしまうため
55
+ const element = groupRef.current
56
+ if (!element || typeof ResizeObserver === "undefined") {
69
57
  return
70
58
  }
71
- lastStablePanelsRef.current = clonePanels(panels)
72
- }, [layoutConstraintViolation, panels, direction, containerSize.width, containerSize.height, clonePanels])
73
-
74
- // 回復 effect から最新のパネル一覧を参照するための ref (依存に panels を入れると復元が違反解除の一回に限定できない)
75
- const latestPanelsRef = useRef(panels)
76
- useEffect(() => {
77
- latestPanelsRef.current = panels
78
- }, [panels])
79
59
 
80
- useEffect(() => {
81
- const previousViolation = previousViolationRef.current
82
- if (layoutConstraintViolation) {
83
- previousViolationRef.current = layoutConstraintViolation
84
- return
85
- }
86
- if (previousViolation && !layoutConstraintViolation && lastStablePanelsRef.current) {
87
- previousViolationRef.current = null
88
- // 現在も登録されているパネルだけを復元する。
89
- // 違反中に登録解除されたパネルをスナップショットから復活させると、対応するコンポーネントが
90
- // 存在しないゾンビパネルとなり、違反も再発して解除不能になる
91
- const registeredIds = new Set(latestPanelsRef.current.map((panel) => panel.id))
92
- const snapshot = clonePanels(lastStablePanelsRef.current).filter((panel) => registeredIds.has(panel.id))
93
- if (snapshot.length > 0) {
94
- dispatch({ type: "SET_PANELS", panels: snapshot })
60
+ let lastContent = Number.NaN
61
+ const commitContentLength = (content: number) => {
62
+ if (content === lastContent) {
63
+ return
95
64
  }
96
- return
65
+ lastContent = content
66
+ dispatch({ type: "SET_CONTAINER_SIZE", containerSize: content })
97
67
  }
98
- previousViolationRef.current = layoutConstraintViolation
99
- }, [layoutConstraintViolation, clonePanels])
100
68
 
101
- // コンテナサイズの変化を監視し、実測値が不安定な場合も安定した寸法を状態へ反映
102
- useLayoutEffect(() => {
103
- if (!groupRef.current) return
104
-
105
- const resizeObserver = new ResizeObserver((entries) => {
106
- for (const entry of entries) {
107
- const targetElement = entry.target as HTMLElement
108
- // getContainerSize の inner (枠線控除済み) を優先し、取得できない場合は contentRect をフォールバックに使用
109
- const widthMeasurement = targetElement ? getContainerSize(targetElement, "horizontal") : null
110
- const heightMeasurement = targetElement ? getContainerSize(targetElement, "vertical") : null
111
- const measuredWidth = widthMeasurement ? widthMeasurement.inner : 0
112
- const measuredHeight = heightMeasurement ? heightMeasurement.inner : 0
113
- const fallbackWidth = entry.contentRect.width
114
- const fallbackHeight = entry.contentRect.height
115
- const width = measuredWidth > 0 ? measuredWidth : fallbackWidth
116
- const height = measuredHeight > 0 ? measuredHeight : fallbackHeight
117
- lastRawContainerSizeRef.current = { width, height }
118
-
119
- const prev = committedContainerSizeRef.current
120
- const isWidthChanged = Math.abs(prev.width - width) >= 0.5
121
- const isHeightChanged = Math.abs(prev.height - height) >= 0.5
122
-
123
- if (isWidthChanged || isHeightChanged) {
124
- // 閾値を超える変化は即座に反映し、待機中の同期をキャンセル
125
- if (containerSyncTimerRef.current) {
126
- clearTimeout(containerSyncTimerRef.current)
127
- containerSyncTimerRef.current = null
128
- }
129
- if (!isContainerReady && width > 0 && height > 0) {
130
- setIsContainerReady(true)
131
- }
132
- setContainerSize({ width, height })
133
- committedContainerSizeRef.current = { width, height }
134
- } else {
135
- // 微小な変化はノイズとして即時更新はせず、静止後に最終同期をスケジュール
136
- if (containerSyncTimerRef.current) {
137
- clearTimeout(containerSyncTimerRef.current)
138
- }
139
- containerSyncTimerRef.current = setTimeout(() => {
140
- const finalSize = lastRawContainerSizeRef.current
141
- setContainerSize(finalSize)
142
- committedContainerSizeRef.current = finalSize
143
- containerSyncTimerRef.current = null
144
- }, 150)
145
- }
146
- }
69
+ const observer = new ResizeObserver((entries) => {
70
+ commitContentLength(getContainerSize(element, direction, entries[0]).content)
147
71
  })
148
72
 
149
- resizeObserver.observe(groupRef.current)
150
- return () => {
151
- resizeObserver.disconnect()
152
- if (containerSyncTimerRef.current) {
153
- clearTimeout(containerSyncTimerRef.current)
154
- }
155
- }
156
- }, [isContainerReady])
73
+ observer.observe(element)
74
+ // 初回描画を正しい配分で塗るための種。観測が届いた時点で厳密値へ置き換わる
75
+ commitContentLength(getContainerSize(element, direction).content)
157
76
 
158
- useEffect(() => {
159
- // アンマウント時に全てのパネル同期タイマーをクリア
160
77
  return () => {
161
- for (const timer of Object.values(panelSyncTimersRef.current)) {
162
- clearTimeout(timer)
163
- }
164
- }
165
- }, [])
166
-
167
- useLayoutEffect(() => {
168
- const prevSize = prevContainerSizeRef.current
169
- const currentSize = containerSize
170
- // 0.5px 未満の微小な変化はノイズとして扱い、リサイズイベントの発火を抑制
171
- const hasResized = direction === "horizontal" ? Math.abs(prevSize.width - currentSize.width) >= 0.5 : Math.abs(prevSize.height - currentSize.height) >= 0.5
172
-
173
- // 初期化完了後にコンテナサイズが一定以上変化した場合、リサイズイベントを発行
174
- if (isInitialized && hasResized) {
175
- const newContainerSize = direction === "horizontal" ? currentSize.width : currentSize.height
176
- dispatch({ type: "CONTAINER_RESIZED", containerSize: newContainerSize, direction })
78
+ observer.disconnect()
177
79
  }
178
-
179
- prevContainerSizeRef.current = currentSize
180
- }, [containerSize, isInitialized, direction])
181
-
182
- /**
183
- * Resolve pending panel initialization, reconcile constraint bounds against fresh measurements, and recover stored sizes.
184
- * 初期化時にパネル整合性を再判定し、最新計測と制約を突き合わせた上で保存済み寸法を復元する処理。
185
- */
186
- const initializePanelsIfNeeded = useCallback(() => {
187
- logger.debug("panels[0]?.id", panels[0]?.id)
188
- // 折り畳み可能パネルや一時的にゼロへスナップしたパネルで保存済みピクセルサイズまたはパーセンテージがあるのにサイズが未解決の場合を検出
189
- const hasUnresolvedPanels = panels.some((panel) => {
190
- if (panel.collapsed) {
191
- return false
192
- }
193
- if (panel.sizeUnit !== "percentage") {
194
- return false
195
- }
196
- // 折り畳み前または直近測定のピクセルサイズを取得し、スナップゼロの復元に備える
197
- const panelIsCollapsible = panel.collapseFromStart || panel.collapseFromEnd
198
- const storedPixel = panelIsCollapsible ? (panel.sizeBeforeCollapse ?? panel.measuredPixelSizeBeforeCollapse ?? panel.originalPixelSize ?? panel.measuredPixelSize ?? 0) : (panel.measuredPixelSize ?? panel.originalPixelSize ?? panel.size)
199
- const hasStoredPercentage = panel.percentageSize !== undefined && panel.percentageSize > 0
200
- return panel.size <= 0 && (storedPixel > 0 || hasStoredPercentage)
201
- })
202
-
203
- // containerSize は getContainerSize(inner) により枠線控除済みの実寸法
204
- const axisSize = direction === "horizontal" ? containerSize.width : containerSize.height
205
- const hasContainerMeasurement = axisSize > 0
206
-
207
- // 制約違反が検出されている場合は初期化をスキップ
208
- if (layoutConstraintViolation) {
209
- return
210
- }
211
-
212
- // コンテナサイズが未計測の場合は初期化をスキップ
213
- if (!hasContainerMeasurement) {
80
+ }, [direction])
81
+
82
+ const panels = layout.panels
83
+ const panelsRef = useRef(panels)
84
+ panelsRef.current = panels
85
+
86
+ const syncPanelOrder = useCallback(() => {
87
+ // パネルは DOM 順に並んでいることが前提の状態 (保存・onLayout・優先度の同順位比較) なので、
88
+ // 登録順と DOM 順がずれたら DOM 側へ合わせ直す
89
+ const element = groupRef.current
90
+ const currentPanels = panelsRef.current
91
+ if (!element) {
214
92
  return
215
93
  }
216
-
217
- // 未初期化パネルや折り畳み復元待ちパネルが残っているか判定
218
- const needsInitialization = panels.length > 0 && (panels.length !== initializedPanelCountRef.current || !isInitialized || hasUnresolvedPanels)
219
-
220
- if (!needsInitialization) {
94
+ const layoutElements = getGroupLayoutElements(element)
95
+ // ハンドルが別の境界へ移っただけの並べ替えはパネルの id 列を変えない。
96
+ // アンカーの位置も含めた並びを鍵にして、ハンドル側の再検出も同じ変化で起こす
97
+ setLayoutOrderKey((previous) => {
98
+ const next = layoutElements.map((layoutElement) => layoutElement.dataset.panelId ?? HANDLE_ANCHOR_ORDER_MARK).join("\u0000")
99
+ return previous === next ? previous : next
100
+ })
101
+ if (currentPanels.length === 0) {
221
102
  return
222
103
  }
223
-
224
- // ピクセル基準パネルを抽出し固定領域を先に確保
225
- const pixelPanels = panels.filter((panel) => !panel.collapsed && (panel.originalPixelSize !== undefined || panel.sizeUnit === "pixels"))
226
- const allocatedPixelSpace = pixelPanels.reduce((sum, panel) => {
227
- const preferredPixelSize = panel.preferredPixelSize ?? panel.originalPixelSize ?? panel.size
228
- return sum + Math.max(0, preferredPixelSize ?? 0)
229
- }, 0)
230
-
231
- const activePercentagePanels = panels.filter((panel) => panel.sizeUnit === "percentage" && !panel.collapsed)
232
- const activePercentageIds = activePercentagePanels.map((panel) => panel.id)
233
- // パーセンテージ指定パネルの希望ピクセル値を再計算
234
- const preferredPixelSnapshots = activePercentagePanels.map((panel) => {
235
- const preferredPercentage = panel.preferredPercentageSize ?? panel.percentageSize
236
- if (preferredPercentage !== undefined && preferredPercentage > 0) {
237
- return percentageToPixels(preferredPercentage, axisSize)
238
- }
239
- if (panel.size > 0) {
240
- return panel.size
241
- }
242
- return 0
243
- })
244
- const totalPreferredPixels = preferredPixelSnapshots.reduce((sum, value) => sum + value, 0)
245
-
246
- // 固定領域を除いた残余スペースを算出し柔軟パネル側の調整有無を判断
247
- const remainingSpace = axisSize - allocatedPixelSpace
248
- const remainingFlexibleSpace = Math.max(0, remainingSpace)
249
- const hasFlexibleRoom = remainingFlexibleSpace > 0
250
-
251
- // パネルごとに復元・再計算した最終的な状態を集約
252
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Panel resolution logic
253
- const resolvedPanels = panels.map((panel) => {
254
- if (panel.collapsed) {
255
- // 折り畳み中パネルの復元用サイズを優先順で決定 (ピクセル基準)
256
- // 優先順: sizeBeforeCollapse → measuredPixelSizeBeforeCollapse → originalPixelSize → size (pixels単位のみ)
257
- const storedPixel = panel.sizeBeforeCollapse ?? panel.measuredPixelSizeBeforeCollapse ?? panel.originalPixelSize ?? (panel.sizeUnit === "pixels" ? panel.size : undefined)
258
-
259
- // パーセンテージ単位パネルの場合は割合からピクセルへ変換
260
- const restoredSizeFromPercentage = panel.sizeUnit === "percentage" && panel.percentageSize !== undefined ? percentageToPixels(panel.percentageSize, axisSize) : undefined
261
-
262
- // 復元後に採用するサイズと測定スナップショットを決定
263
- const recoveredSize = storedPixel ?? restoredSizeFromPercentage ?? 0
264
- const recoveredMeasured = panel.measuredPixelSizeBeforeCollapse ?? panel.measuredPixelSize ?? recoveredSize
265
-
266
- return {
267
- ...panel,
268
- size: 0,
269
- percentageSize: 0,
270
- preferredPercentageSize: panel.preferredPercentageSize,
271
- sizeBeforeCollapse: recoveredSize,
272
- percentageSizeBeforeCollapse: panel.percentageSizeBeforeCollapse ?? panel.percentageSize ?? panel.preferredPercentageSize ?? 0,
273
- preferredPercentageSizeBeforeCollapse: panel.preferredPercentageSizeBeforeCollapse ?? panel.preferredPercentageSize ?? panel.percentageSize ?? undefined,
274
- preferredPixelSizeBeforeCollapse: panel.preferredPixelSizeBeforeCollapse ?? panel.preferredPixelSize ?? recoveredSize,
275
- measuredPixelSizeBeforeCollapse: recoveredMeasured,
276
- }
277
- }
278
-
279
- if (panel.sizeUnit === "pixels" || panel.originalPixelSize !== undefined) {
280
- // 既にピクセル基準のパネルは測定値を再計算しつつそのまま採用
281
- const pixelSize = panel.size
282
- const percentageSize = axisSize > 0 ? toRoundedPercentage(pixelSize, axisSize) : 0
283
-
284
- const nextPanel = {
285
- ...panel,
286
- size: pixelSize,
287
- percentageSize,
288
- preferredPixelSize: panel.preferredPixelSize ?? panel.originalPixelSize ?? pixelSize,
289
- preferredPercentageSize: panel.preferredPercentageSize !== undefined ? panel.preferredPercentageSize : percentageSize > 0 ? percentageSize : undefined,
290
- }
291
-
292
- if (panel.collapseFromStart || panel.collapseFromEnd) {
293
- // 折り畳み可能パネルは復元用のピクセルサイズと測定スナップショットを保持
294
- const storedSize = panel.sizeBeforeCollapse ?? pixelSize
295
- const storedMeasured = panel.measuredPixelSizeBeforeCollapse ?? panel.measuredPixelSize ?? storedSize
296
- nextPanel.sizeBeforeCollapse = storedSize
297
- nextPanel.measuredPixelSizeBeforeCollapse = storedMeasured
298
- nextPanel.preferredPixelSizeBeforeCollapse = panel.preferredPixelSizeBeforeCollapse ?? panel.preferredPixelSize ?? storedSize
299
- }
300
-
301
- return nextPanel
302
- }
303
-
304
- if (panel.sizeUnit === "percentage") {
305
- // パーセンテージ基準パネルは希望寸法と制約を照らし合わせて実ピクセルへ再配分
306
- const activeIndex = activePercentageIds.indexOf(panel.id)
307
- const preferredPixels = activeIndex !== -1 ? preferredPixelSnapshots[activeIndex] : 0
308
- const minSize = getConstraintInPixels(panel.minSize, 0, axisSize)
309
- const maxSize = getConstraintInPixels(panel.maxSize, axisSize, axisSize)
310
- // 過去の測定や折り畳み前サイズを重みとして再利用し柔軟パネルの再配分に最低限の基準を設ける
311
- const fallbackPixels = Math.max(
312
- minSize,
313
- panel.sizeBeforeCollapse ?? 0,
314
- panel.measuredPixelSizeBeforeCollapse ?? 0,
315
- panel.measuredPixelSize ?? 0,
316
- panel.originalPixelSize ?? 0,
317
- panel.size > 0 ? panel.size : 0,
318
- panel.percentageSize !== undefined ? percentageToPixels(panel.percentageSize, axisSize) : 0,
319
- 1,
320
- )
321
- const effectivePreferred = preferredPixels > 0 ? preferredPixels : fallbackPixels
322
-
323
- let targetPixels: number
324
-
325
- if (!hasFlexibleRoom) {
326
- // 柔軟領域が尽きている場合(ピクセルパネルがコンテナを使い切っている)、
327
- // パーセンテージパネルは最小サイズ(通常0)にする
328
- targetPixels = minSize
329
- } else if (activeIndex !== -1 && totalPreferredPixels > 0) {
330
- // 希望比率に沿って残余領域を配分
331
- const ratio = effectivePreferred / totalPreferredPixels
332
- const candidatePixels = remainingFlexibleSpace * ratio
333
- targetPixels = clamp(candidatePixels, minSize, maxSize)
334
- } else {
335
- // 現状または復元候補の寸法を基準に上限・下限内へ補正
336
- const currentPixels = panel.percentageSize !== undefined ? percentageToPixels(panel.percentageSize, axisSize) : panel.size
337
- const baselinePixels = currentPixels > 0 ? currentPixels : effectivePreferred
338
- targetPixels = clamp(baselinePixels, minSize, maxSize)
339
- }
340
-
341
- const nextPercentage = axisSize > 0 ? toRoundedPercentage(targetPixels, axisSize) : 0
342
-
343
- const nextPanel = {
344
- ...panel,
345
- size: targetPixels,
346
- percentageSize: nextPercentage,
347
- preferredPercentageSize: panel.preferredPercentageSize !== undefined ? panel.preferredPercentageSize : nextPercentage > 0 ? nextPercentage : undefined,
348
- }
349
-
350
- if (panel.collapseFromStart || panel.collapseFromEnd) {
351
- const storedSize = panel.sizeBeforeCollapse ?? targetPixels
352
- const storedMeasured = panel.measuredPixelSizeBeforeCollapse ?? panel.measuredPixelSize ?? storedSize
353
- nextPanel.sizeBeforeCollapse = storedSize
354
- nextPanel.measuredPixelSizeBeforeCollapse = storedMeasured
355
- nextPanel.preferredPixelSizeBeforeCollapse = panel.preferredPixelSizeBeforeCollapse ?? panel.preferredPixelSize ?? storedSize
356
- }
357
-
358
- return nextPanel
359
- }
360
-
361
- return panel
362
- })
363
-
364
- // 柔軟パネルのインデックスを収集
365
- const flexibleIndices: number[] = []
366
- for (let index = 0; index < resolvedPanels.length; index += 1) {
367
- const panel = resolvedPanels[index]
368
- if (panel.sizeUnit === "percentage" && !panel.collapsed) {
369
- flexibleIndices.push(index)
104
+ const ids: string[] = []
105
+ for (const layoutElement of layoutElements) {
106
+ const id = layoutElement.dataset.panelId
107
+ if (id !== undefined) {
108
+ ids.push(id)
370
109
  }
371
110
  }
372
-
373
- // 柔軟パネルに割り当てたピクセル合計と残余スペースの差分を計算
374
- const allocatedFlexiblePixels = flexibleIndices.reduce((sum, index) => sum + resolvedPanels[index].size, 0)
375
- const flexibleDiff = remainingSpace - allocatedFlexiblePixels
376
-
377
- // 差分が閾値を超える場合はピクセルパネルの優先度順に吸収
378
- if (Math.abs(flexibleDiff) > 0) {
379
- adjustPixelPanelsByPriority(resolvedPanels, flexibleDiff, axisSize, { enforceAutoMinSize: true })
111
+ if (new Set(ids).size !== ids.length) {
112
+ // 同じ id のパネルが同時に存在すると 1 つの状態を 2 枚が描画し、合計がコンテナを超える
113
+ logger.error("Duplicate panel id in one group.", { ids })
114
+ return
380
115
  }
381
-
382
- dispatch({ type: "SET_PANELS", panels: resolvedPanels })
383
- initializedPanelCountRef.current = panels.length
384
- setIsInitialized(true)
385
- }, [panels, direction, containerSize.width, containerSize.height, isInitialized, layoutConstraintViolation])
386
-
387
- // 制約チェックを独立して実行(コンテナサイズ変更時にも常に動作)
388
- useEffect(() => {
389
- // containerSize は getContainerSize(inner) により枠線控除済みの実寸法
390
- const axisSize = direction === "horizontal" ? containerSize.width : containerSize.height
391
- const hasContainerMeasurement = axisSize > 0
392
-
393
- // 実測コンテナ寸法と登録済みパネルの有無から制約評価の要否を判断
394
- const shouldEvaluateConstraints = hasContainerMeasurement && panels.length > 0
395
-
396
- // パネルが存在しなければ制約違反そのものが成立しない
397
- // (下の stale な違反 totals へのフォールバックに入ると、全パネル登録解除後も違反が永続してしまう)
398
- if (hasContainerMeasurement && panels.length === 0) {
399
- if (layoutConstraintViolation) {
400
- setLayoutConstraintViolation(null)
401
- }
116
+ if (ids.length !== currentPanels.length || ids.every((id, index) => id === currentPanels[index].id)) {
402
117
  return
403
118
  }
119
+ dispatch({ type: "REORDER_PANELS", ids })
120
+ }, [])
404
121
 
405
- // 有効なパネルが存在する場合は最小値と最大値の合計を算出
406
- const totalMinimum = shouldEvaluateConstraints
407
- ? panels.reduce((sum, panel) => {
408
- if (panel.collapsed) {
409
- return sum
410
- }
411
- const minPixels = getConstraintInPixels(panel.minSize, 0, axisSize)
412
- return sum + Math.max(0, minPixels)
413
- }, 0)
414
- : null
415
- const totalMaximum = shouldEvaluateConstraints
416
- ? panels.reduce((sum, panel) => {
417
- if (panel.collapsed) {
418
- return sum
419
- }
420
- const maxPixels = getConstraintInPixels(panel.maxSize, axisSize, axisSize)
421
- return sum + Math.max(0, maxPixels)
422
- }, 0)
423
- : null
424
-
425
- // 直近の違反情報をフォールバックとして利用しつつ最新計算結果を優先
426
- const effectiveMinimum = totalMinimum ?? layoutConstraintViolation?.totalMinimumSize ?? null
427
- const effectiveMaximum = totalMaximum ?? layoutConstraintViolation?.totalMaximumSize ?? null
428
-
429
- if (!hasContainerMeasurement) {
430
- // 寸法が取得できていない場合は違反情報のみ更新し後段処理を停止
431
- if (layoutConstraintViolation) {
432
- setLayoutConstraintViolation((previous) => {
433
- if (!previous) {
434
- return previous
435
- }
436
- if (previous.availableContainerSize === axisSize) {
437
- return previous
438
- }
439
- return {
440
- reason: previous.reason,
441
- // totalMinimumSize: previous.totalMinimumSize,
442
- // totalMaximumSize: previous.totalMaximumSize,
443
- totalMinimumSize: null,
444
- totalMaximumSize: null,
445
- availableContainerSize: axisSize,
446
- }
447
- })
448
- }
122
+ useLayoutEffect(() => {
123
+ // 並べ替えは登録・解除を伴わないことがある (JSX 上の順序だけを入れ替えた場合など)。
124
+ // 状態の変化を待つと取りこぼすため、DOM の子要素の変化そのものを監視する
125
+ const element = groupRef.current
126
+ if (!element) {
449
127
  return
450
128
  }
451
-
452
- // コンテナ寸法と総制約を比較して違反を検出
453
- const minimumExceeded = effectiveMinimum !== null && effectiveMinimum - axisSize > Number.EPSILON
454
- const maximumInsufficient = effectiveMaximum !== null && axisSize - effectiveMaximum > Number.EPSILON
455
-
456
- if (minimumExceeded || maximumInsufficient) {
457
- const reason = minimumExceeded ? "minimum-exceeded" : "maximum-insufficient"
458
- const nextViolation: LayoutConstraintViolation = {
459
- reason,
460
- totalMinimumSize: effectiveMinimum,
461
- totalMaximumSize: effectiveMaximum,
462
- availableContainerSize: axisSize,
463
- }
464
-
465
- // 同一内容の違反通知を繰り返さないよう差分がある場合のみ更新
466
- setLayoutConstraintViolation((previous) => {
467
- if (
468
- previous &&
469
- previous.reason === nextViolation.reason &&
470
- Math.abs((previous.totalMinimumSize ?? 0) - (nextViolation.totalMinimumSize ?? 0)) <= Number.EPSILON &&
471
- Math.abs((previous.totalMaximumSize ?? 0) - (nextViolation.totalMaximumSize ?? 0)) <= Number.EPSILON &&
472
- Math.abs(previous.availableContainerSize - nextViolation.availableContainerSize) <= Number.EPSILON
473
- ) {
474
- return previous
475
- }
476
- return nextViolation
477
- })
129
+ syncPanelOrder()
130
+ if (typeof MutationObserver === "undefined") {
478
131
  return
479
132
  }
480
-
481
- if (layoutConstraintViolation) {
482
- // 違反が発生していた場合は解消済みかどうかを判定
483
- const requiresMinimum = layoutConstraintViolation.reason === "minimum-exceeded"
484
- const requiresMaximum = layoutConstraintViolation.reason === "maximum-insufficient"
485
- const hasMinimumData = effectiveMinimum !== null
486
- const hasMaximumData = effectiveMaximum !== null
487
- const canEvaluateMinimum = !requiresMinimum || hasMinimumData
488
- const canEvaluateMaximum = !requiresMaximum || hasMaximumData
489
-
490
- if (canEvaluateMinimum && canEvaluateMaximum && !minimumExceeded && !maximumInsufficient) {
491
- // 条件が揃い再違反が検出されなければ違反状態を解除
492
- setLayoutConstraintViolation((previous) => (previous ? null : previous))
133
+ const observer = new MutationObserver((records) => {
134
+ // 配置要素に触れない変更は無視する。パネルの内容 (仮想化リストの行など) は毎フレーム
135
+ // 入れ替わるため、すべての変更で走査するとグループ全体に比例した費用を払い続ける
136
+ if (touchesLayoutElements(records)) {
137
+ syncPanelOrder()
493
138
  }
139
+ })
140
+ // 中間要素の中での並べ替えも拾うため subtree ごと監視する
141
+ observer.observe(element, { childList: true, subtree: true })
142
+ return () => {
143
+ observer.disconnect()
494
144
  }
495
- }, [panels, direction, containerSize.width, containerSize.height, layoutConstraintViolation])
145
+ }, [syncPanelOrder])
496
146
 
497
- // 初期化漏れを防止するために必要時にリソルブ処理を呼び出す
498
- useEffect(() => {
499
- initializePanelsIfNeeded()
500
- }, [initializePanelsIfNeeded])
501
-
502
- // 最新のレイアウト通知コールバックを参照に保持
503
- useEffect(() => {
504
- layoutCallbackRef.current = onLayout
505
- }, [onLayout])
147
+ useLayoutEffect(() => {
148
+ syncPanelOrder()
149
+ }, [syncPanelOrder, panels])
506
150
 
507
- // レイアウト通知のためにパネルサイズ一覧をメモ化
508
151
  const panelSizes = useMemo(() => panels.map((panel) => panel.size), [panels])
152
+ const hasMeasuredContainer = layout.containerSize > 0
509
153
 
510
- // 初期化前の未確定サイズ (登録直後のパーセンテージパネルは 0) を onLayout や自動保存へ流さないためのゲート。
511
- // 一度確定したら以降はゼロを含むレイアウト (スナップ等) も正しい状態として通知する
512
- const hasSettledLayoutRef = useRef(false)
513
-
514
- // 初期化後にレイアウトリスナーへ寸法を通知
515
154
  useLayoutEffect(() => {
516
- if (panels.length === 0) {
517
- // 全パネルが登録解除されたら次の構成のために未確定状態へ戻す
518
- hasSettledLayoutRef.current = false
519
- return
520
- }
521
- if (!hasSettledLayoutRef.current && (isInitialized || panels.every((panel) => panel.collapsed || panel.size > 0))) {
522
- hasSettledLayoutRef.current = true
523
- }
524
- if (!hasSettledLayoutRef.current) {
155
+ // 未計測の 0 配列を通知しない。配分はコンテナ計測後に一度で確定するため、通知は常に確定値になる
156
+ if (!hasMeasuredContainer || panelSizes.length === 0) {
525
157
  return
526
158
  }
527
- if (layoutCallbackRef.current) {
528
- layoutCallbackRef.current(panelSizes)
529
- }
530
- }, [panelSizes, panels, isInitialized])
159
+ layoutCallbackRef.current?.(panelSizes)
160
+ }, [panelSizes, hasMeasuredContainer])
531
161
 
532
- // 自動保存を遅延実行して書き込み回数を抑制
162
+ const restoredKeyRef = useRef<string | null>(null)
533
163
  useEffect(() => {
534
- // 未確定レイアウト (初期化前のゼロ) を保存すると復元時に壊れた配置になるため確定後のみ保存する
535
- if (autoSaveId && panelSizes.length > 0 && hasSettledLayoutRef.current) {
536
- // デバウンス処理: 200ms 後に自動保存を実行
537
- const timeoutId = setTimeout(() => {
538
- saveLayout(autoSaveId, panelSizes)
539
- }, 200)
540
-
541
- return () => clearTimeout(timeoutId)
164
+ // 保存済みレイアウトは識別子ごとに一度だけ適用する
165
+ // (識別子が変われば、その識別子で保存された配置を読み直す)
166
+ if (!autoSaveId || restoredKeyRef.current === autoSaveId || !hasMeasuredContainer || panels.length === 0) {
167
+ return
168
+ }
169
+ restoredKeyRef.current = autoSaveId
170
+ const entries = loadLayout(autoSaveId)
171
+ if (entries) {
172
+ dispatch({ type: "RESTORE_LAYOUT", entries })
542
173
  }
543
- }, [autoSaveId, panelSizes])
174
+ }, [autoSaveId, hasMeasuredContainer, panels.length])
544
175
 
545
- // 保存済みレイアウトがあれば初回のみ読み込む
176
+ const pendingSaveRef = useRef<{ id: string; entries: PersistedPanelSize[] } | null>(null)
546
177
  useEffect(() => {
547
- if (!autoSaveId || panels.length === 0 || initializedRef.current) {
178
+ if (!(autoSaveId && restoredKeyRef.current === autoSaveId && hasMeasuredContainer) || panels.length === 0) {
548
179
  return
549
180
  }
550
-
551
- const axisSize = direction === "horizontal" ? containerSize.width : containerSize.height
552
- if (axisSize <= 0) {
553
- return
181
+ const entries = panels.map((panel) => ({ id: panel.id, size: getPanelPreferenceInPixels(panel, layout.containerSize), collapsed: panel.collapsed }))
182
+ pendingSaveRef.current = { id: autoSaveId, entries }
183
+ const timer = setTimeout(() => {
184
+ pendingSaveRef.current = null
185
+ saveLayout(autoSaveId, entries)
186
+ }, AUTO_SAVE_DELAY_MS)
187
+ // ここで保存まで行うと、ドラッグやリサイズの 1 フレームごとに同期 I/O が走り、待機時間が無意味になる
188
+ return () => {
189
+ clearTimeout(timer)
554
190
  }
191
+ }, [autoSaveId, panels, layout.containerSize, hasMeasuredContainer])
555
192
 
556
- const savedSizes = loadLayout(autoSaveId)
557
- if (savedSizes && savedSizes.length === panels.length) {
558
- const updatedPanels = panels.map((panel, index) => {
559
- const minPixels = getConstraintInPixels(panel.minSize, 0, axisSize)
560
- const maxPixels = getConstraintInPixels(panel.maxSize, axisSize, axisSize)
561
- const clampedSize = clamp(savedSizes[index], minPixels, maxPixels)
562
- const nextPercentage = toRoundedPercentage(clampedSize, axisSize)
563
- const nextPreferredPixelSize = panel.sizeUnit === "pixels" || panel.originalPixelSize !== undefined ? clampedSize : panel.preferredPixelSize
564
- return {
565
- ...panel,
566
- size: clampedSize,
567
- percentageSize: nextPercentage,
568
- preferredPercentageSize: nextPercentage > 0 ? nextPercentage : panel.preferredPercentageSize,
569
- preferredPixelSize: nextPreferredPixelSize,
570
- }
571
- })
572
- dispatch({ type: "SET_PANELS", panels: updatedPanels })
573
- initializedRef.current = true
193
+ useEffect(() => {
194
+ // 書き出し前に画面を離れた場合でも最後の配置を残す (遷移直前に動かした幅が消えないように)
195
+ return () => {
196
+ const pending = pendingSaveRef.current
197
+ if (pending) {
198
+ pendingSaveRef.current = null
199
+ saveLayout(pending.id, pending.entries)
200
+ }
574
201
  }
575
- }, [autoSaveId, panels, containerSize.width, containerSize.height, direction])
576
-
577
- const registerPanel = useCallback((panel: PanelLayoutData) => {
578
- const containerInfo = containerInfoRef.current
579
- const registry = panelRegistrationSnapshotRef.current
580
- const registrationSignature = JSON.stringify({
581
- id: panel.id,
582
- size: panel.size ?? null,
583
- percentageSize: panel.percentageSize ?? null,
584
- preferredPercentageSize: panel.preferredPercentageSize ?? null,
585
- preferredPixelSize: panel.preferredPixelSize ?? null,
586
- sizeUnit: panel.sizeUnit,
587
- originalPixelSize: panel.originalPixelSize ?? null,
588
- minSize: panel.minSize ?? null,
589
- maxSize: panel.maxSize ?? null,
590
- collapseFromStart: panel.collapseFromStart,
591
- collapseFromEnd: panel.collapseFromEnd,
592
- collapsed: panel.collapsed ?? false,
593
- collapsedByDirection: panel.collapsedByDirection ?? null,
594
- sizeBeforeCollapse: panel.sizeBeforeCollapse ?? null,
595
- percentageSizeBeforeCollapse: panel.percentageSizeBeforeCollapse ?? null,
596
- preferredPercentageSizeBeforeCollapse: panel.preferredPercentageSizeBeforeCollapse ?? null,
597
- preferredPixelSizeBeforeCollapse: panel.preferredPixelSizeBeforeCollapse ?? null,
598
- pixelAdjustPriority: panel.pixelAdjustPriority ?? null,
599
- flexAdjustPriority: panel.flexAdjustPriority ?? null,
600
- autoMinSize: panel.autoMinSize ?? null,
601
- })
202
+ }, [])
602
203
 
603
- if (registry[panel.id] === registrationSignature) {
604
- logger.debug("useResizablePanels.registerPanel skipped", {
605
- panelId: panel.id,
606
- reason: "duplicate-registration",
607
- })
204
+ const registerPanel = useCallback((panel: PanelRegistration) => {
205
+ // 同一内容の再登録は状態を触らない。props が変わっていないのに希望値を配り直すと、
206
+ // 直前のドラッグ結果を巻き戻してしまう
207
+ const signature = JSON.stringify(panel)
208
+ if (registrationSignaturesRef.current[panel.id] === signature) {
608
209
  return
609
210
  }
610
-
611
- registry[panel.id] = registrationSignature
612
- logger.debug("useResizablePanels.registerPanel", {
613
- panelId: panel.id,
614
- containerReady: isContainerReadyRef.current,
615
- violationReason: violationReasonRef.current,
616
- containerSize: {
617
- width: containerInfo.width,
618
- height: containerInfo.height,
619
- },
620
- })
621
- // パネル登録アクションをディスパッチ
211
+ registrationSignaturesRef.current[panel.id] = signature
622
212
  dispatch({ type: "REGISTER_PANEL", panel })
623
213
  }, [])
624
214
 
625
215
  const unregisterPanel = useCallback((id: string) => {
626
- const containerInfo = containerInfoRef.current
627
- logger.debug("useResizablePanels.unregisterPanel", {
628
- panelId: id,
629
- containerReady: isContainerReadyRef.current,
630
- violationReason: violationReasonRef.current,
631
- containerSize: {
632
- width: containerInfo.width,
633
- height: containerInfo.height,
634
- },
635
- })
636
- // パネル登録解除アクションをディスパッチし、測定情報もクリア
216
+ delete registrationSignaturesRef.current[id]
637
217
  dispatch({ type: "UNREGISTER_PANEL", id })
638
- delete panelRegistrationSnapshotRef.current[id]
639
- // 保留中の最終整合タイマーも破棄してゴーストエントリの再挿入を防ぐ
640
- if (panelSyncTimersRef.current[id]) {
641
- clearTimeout(panelSyncTimersRef.current[id])
642
- delete panelSyncTimersRef.current[id]
643
- }
644
- delete lastRawPanelMeasurementsRef.current[id]
645
218
  setPanelMeasurements((previous) => {
646
219
  if (!(id in previous)) {
647
220
  return previous
@@ -652,201 +225,41 @@ export const useResizablePanels = ({ direction, style, onLayout, autoSaveId, sho
652
225
  })
653
226
  }, [])
654
227
 
655
- /**
656
- * Apply constraint-aware pixel adjustments while resizing adjacent panels.
657
- * 隣接パネルのリサイズ時に制約を考慮したピクセル調整を適用。
658
- */
659
- const resizePanels = useCallback(
660
- (leftId: string, rightId: string, leftSize: number, rightSize: number) => {
661
- // フック側は「制約を大きく外さない初期値」を作る役割、リデューサー側が「最終確定」の役割を担う
662
- // フック側はドラッグ中の左右ペアに即座に制約を適用して「安全な候補サイズ」を算出するだけで、他パネルとの整合や割合正規化までは扱わない
663
- // ここではリデューサーの最終判定前にドラッグ中のペアに即応する安全な暫定寸法だけを算出する
664
-
665
- const currentContainerSize = direction === "horizontal" ? containerSize.width : containerSize.height
666
-
667
- const leftPanel = panels.find((panel) => panel.id === leftId)
668
- const rightPanel = panels.find((panel) => panel.id === rightId)
669
-
670
- // パネル情報が未取得でも従来ロジックで更新を継続
671
- if (!(leftPanel && rightPanel)) {
672
- dispatch({ type: "RESIZE_PANELS", leftId, rightId, leftSize, rightSize, containerSize: currentContainerSize })
673
- return
674
- }
675
-
676
- // いずれかのパネルが折りたたまれている場合はリサイズを中断
677
- if (leftPanel.collapsed || rightPanel.collapsed) {
678
- return
679
- }
680
-
681
- const leftMinSize = getConstraintInPixels(leftPanel.minSize, 0, currentContainerSize)
682
- const leftMaxSize = getConstraintInPixels(leftPanel.maxSize, currentContainerSize, currentContainerSize)
683
- const rightMinSize = getConstraintInPixels(rightPanel.minSize, 0, currentContainerSize)
684
- const rightMaxSize = getConstraintInPixels(rightPanel.maxSize, currentContainerSize, currentContainerSize)
685
-
686
- let constrainedLeftSize = clamp(leftSize, leftMinSize, leftMaxSize)
687
- let constrainedRightSize = clamp(rightSize, rightMinSize, rightMaxSize)
688
-
689
- const targetTotalSize = constrainedLeftSize + constrainedRightSize
690
- const originalTotalSize = leftSize + rightSize
691
-
692
- // 制約適用後のサイズ合計と元のサイズ合計が異なる場合は追加の調整を実施
693
- if (Math.abs(targetTotalSize - originalTotalSize) > 1e-9) {
694
- const sizeDifference = originalTotalSize - targetTotalSize
695
-
696
- if (sizeDifference > 0) {
697
- // 差分が正の場合は両パネルの拡張可能量を按分して補填
698
- const leftExpansionCapacity = leftMaxSize - constrainedLeftSize
699
- const rightExpansionCapacity = rightMaxSize - constrainedRightSize
700
- const totalExpansionCapacity = leftExpansionCapacity + rightExpansionCapacity
701
-
702
- if (totalExpansionCapacity > 0) {
703
- const leftExpansion = Math.min(sizeDifference * (leftExpansionCapacity / totalExpansionCapacity), leftExpansionCapacity)
704
- const rightExpansion = Math.min(sizeDifference - leftExpansion, rightExpansionCapacity)
705
-
706
- constrainedLeftSize += leftExpansion
707
- constrainedRightSize += rightExpansion
708
- }
709
- } else if (sizeDifference < 0) {
710
- // 差分が負の場合は両パネルの縮小可能量を按分して削減
711
- const absoluteDifference = Math.abs(sizeDifference)
712
- const leftReductionCapacity = constrainedLeftSize - leftMinSize
713
- const rightReductionCapacity = constrainedRightSize - rightMinSize
714
- const totalReductionCapacity = leftReductionCapacity + rightReductionCapacity
715
-
716
- if (totalReductionCapacity > 0) {
717
- const leftReduction = Math.min(absoluteDifference * (leftReductionCapacity / totalReductionCapacity), leftReductionCapacity)
718
- const rightReduction = Math.min(absoluteDifference - leftReduction, rightReductionCapacity)
719
-
720
- constrainedLeftSize -= leftReduction
721
- constrainedRightSize -= rightReduction
722
- }
723
- }
724
- }
725
-
726
- // 最終的に制約範囲内に再クランプしてリサイズアクションを発行
727
- constrainedLeftSize = clamp(constrainedLeftSize, leftMinSize, leftMaxSize)
728
- constrainedRightSize = clamp(constrainedRightSize, rightMinSize, rightMaxSize)
729
-
730
- if (Math.abs(constrainedLeftSize + constrainedRightSize - originalTotalSize) > 1e-9) {
731
- logger.error("Total size after constraints should be close to the original total size.", { constrainedLeftSize, constrainedRightSize, originalTotalSize })
732
- }
733
-
734
- dispatch({ type: "RESIZE_PANELS", leftId, rightId, leftSize: constrainedLeftSize, rightSize: constrainedRightSize, containerSize: currentContainerSize })
735
- },
736
- [direction, containerSize.width, containerSize.height, panels],
737
- )
228
+ const resizePanels = useCallback((startId: string, endId: string, startSize: number) => {
229
+ dispatch({ type: "RESIZE_PANELS", startId, endId, startSize })
230
+ }, [])
738
231
 
739
- /**
740
- * Collapse a panel while passing the current container size to the reducer.
741
- * 現在のコンテナ寸法を渡しつつパネルの折り畳みを指示。
742
- */
743
- const collapsePanel = useCallback(
744
- (id: string, from: PanelCollapseDirection) => {
745
- // 現在のコンテナサイズを渡してパネル折りたたみアクションを発行
746
- const currentContainerSize = direction === "horizontal" ? containerSize.width : containerSize.height
747
- dispatch({ type: "COLLAPSE_PANEL", id, from, containerSize: currentContainerSize })
748
- },
749
- [direction, containerSize.width, containerSize.height],
750
- )
232
+ const collapsePanel = useCallback((id: string, from: PanelCollapseDirection) => {
233
+ dispatch({ type: "COLLAPSE_PANEL", id, from })
234
+ }, [])
751
235
 
752
- /**
753
- * Expand a panel with an optional target size in pixels.
754
- * 任意のピクセル指定を考慮したパネル展開の指示。
755
- */
756
- const expandPanel = useCallback(
757
- (id: string, from: PanelCollapseDirection, targetSize?: number) => {
758
- // 現在のコンテナサイズを渡してパネル展開アクションを発行
759
- const currentContainerSize = direction === "horizontal" ? containerSize.width : containerSize.height
760
- dispatch({ type: "EXPAND_PANEL", id, from, containerSize: currentContainerSize, targetSize })
761
- },
762
- [direction, containerSize.width, containerSize.height],
763
- )
236
+ const expandPanel = useCallback((id: string, from: PanelCollapseDirection, targetSize?: number) => {
237
+ dispatch({ type: "EXPAND_PANEL", id, from, targetSize })
238
+ }, [])
764
239
 
765
- /**
766
- * Retrieve current layout data for the target panel.
767
- * 対象パネルの最新レイアウトデータの取得。
768
- */
769
- const getPanel = useCallback(
770
- (id: string) => {
771
- // 指定 ID のパネルデータを返す
772
- return panels.find((panel) => panel.id === id)
773
- },
774
- [panels],
775
- )
240
+ const getPanel = useCallback((id: string) => panels.find((panel) => panel.id === id), [panels])
776
241
 
777
- /**
778
- * Synchronize reported panel measurements with local measurement state.
779
- * パネルから報告された測定情報の状態同期。
780
- */
781
242
  const reportPanelMeasurement = useCallback((panelId: string, measurement: PanelMeasurement | null) => {
782
- if (measurement === null) {
783
- // 保留中の最終整合タイマーと raw 測定値を破棄する
784
- // (残したままだとパネル登録解除後にタイマーが発火してゴーストエントリを再挿入する)
785
- if (panelSyncTimersRef.current[panelId]) {
786
- clearTimeout(panelSyncTimersRef.current[panelId])
787
- delete panelSyncTimersRef.current[panelId]
788
- }
789
- delete lastRawPanelMeasurementsRef.current[panelId]
790
-
791
- // 測定結果が null の場合はエントリを削除
792
- setPanelMeasurements((previous) => {
243
+ setPanelMeasurements((previous) => {
244
+ if (measurement === null) {
793
245
  if (!(panelId in previous)) {
794
246
  return previous
795
247
  }
796
248
  const next = { ...previous }
797
249
  delete next[panelId]
798
250
  return next
799
- })
800
- return
801
- }
802
-
803
- setPanelMeasurements((previous) => {
251
+ }
804
252
  const current = previous[panelId]
805
- // 測定値が前回と実質的に同一であれば即時更新はスキップ (0.1px 未満の差異はノイズ)
806
- const isPixelStable = current && Math.abs(current.pixelSize - measurement.pixelSize) < 0.1
807
- const isPercentageStable = current && Math.abs(current.percentageSize - measurement.percentageSize) < 0.01
808
-
809
- if (isPixelStable && isPercentageStable) {
810
- // 静止後の最終整合のためにタイマーを再設定
811
- if (panelSyncTimersRef.current[panelId]) {
812
- clearTimeout(panelSyncTimersRef.current[panelId])
813
- }
814
- lastRawPanelMeasurementsRef.current[panelId] = measurement
815
- panelSyncTimersRef.current[panelId] = setTimeout(() => {
816
- const finalMeasurement = lastRawPanelMeasurementsRef.current[panelId]
817
- if (finalMeasurement) {
818
- setPanelMeasurements((prev) => ({
819
- ...prev,
820
- [panelId]: finalMeasurement,
821
- }))
822
- }
823
- delete panelSyncTimersRef.current[panelId]
824
- }, 200)
825
-
253
+ if (current && current.pixelSize === measurement.pixelSize && current.percentageSize === measurement.percentageSize) {
826
254
  return previous
827
255
  }
828
-
829
- // 大きな変化があった場合は待機中のタイマーを解除
830
- if (panelSyncTimersRef.current[panelId]) {
831
- clearTimeout(panelSyncTimersRef.current[panelId])
832
- delete panelSyncTimersRef.current[panelId]
833
- }
834
-
835
- return {
836
- ...previous,
837
- [panelId]: measurement,
838
- }
256
+ return { ...previous, [panelId]: measurement }
839
257
  })
840
258
  }, [])
841
259
 
842
- /**
843
- * Synchronize resize-handle measurements while ignoring negligible differences.
844
- * リサイズハンドル測定値の同期と僅少差分の無視。
845
- */
846
260
  const reportHandleMeasurement = useCallback((handleId: string, measurement: ResizeHandleLayoutData | null) => {
847
261
  setHandleMeasurements((previous) => {
848
262
  if (measurement === null) {
849
- // 測定結果が null の場合はエントリを削除
850
263
  if (!(handleId in previous)) {
851
264
  return previous
852
265
  }
@@ -854,24 +267,11 @@ export const useResizablePanels = ({ direction, style, onLayout, autoSaveId, sho
854
267
  delete next[handleId]
855
268
  return next
856
269
  }
857
-
858
270
  const current = previous[handleId]
859
- // 測定値が前回と実質的に同一であればスキップ
860
- if (
861
- current &&
862
- Math.abs(current.thickness - measurement.thickness) <= 0.25 &&
863
- current.visible === measurement.visible &&
864
- current.direction === measurement.direction &&
865
- current.startPanelId === measurement.startPanelId &&
866
- current.endPanelId === measurement.endPanelId
867
- ) {
271
+ if (current && current.thickness === measurement.thickness && current.visible === measurement.visible && current.direction === measurement.direction && current.startPanelId === measurement.startPanelId && current.endPanelId === measurement.endPanelId) {
868
272
  return previous
869
273
  }
870
-
871
- return {
872
- ...previous,
873
- [handleId]: measurement,
874
- }
274
+ return { ...previous, [handleId]: measurement }
875
275
  })
876
276
  }, [])
877
277
 
@@ -879,8 +279,8 @@ export const useResizablePanels = ({ direction, style, onLayout, autoSaveId, sho
879
279
  () => ({
880
280
  direction,
881
281
  panels,
882
- containerSize,
883
- isContainerReady,
282
+ containerSize: layout.containerSize,
283
+ layoutOrderKey,
884
284
  showDebugInfo,
885
285
  registerPanel,
886
286
  unregisterPanel,
@@ -892,37 +292,45 @@ export const useResizablePanels = ({ direction, style, onLayout, autoSaveId, sho
892
292
  panelMeasurements,
893
293
  reportHandleMeasurement,
894
294
  handleMeasurements,
895
- layoutConstraintViolation,
295
+ layoutConstraintViolation: layout.violation,
896
296
  }),
897
- [direction, panels, containerSize, isContainerReady, showDebugInfo, registerPanel, unregisterPanel, resizePanels, collapsePanel, expandPanel, getPanel, reportPanelMeasurement, panelMeasurements, reportHandleMeasurement, handleMeasurements, layoutConstraintViolation],
297
+ [direction, panels, layout.containerSize, layoutOrderKey, layout.violation, showDebugInfo, registerPanel, unregisterPanel, resizePanels, collapsePanel, expandPanel, getPanel, reportPanelMeasurement, panelMeasurements, reportHandleMeasurement, handleMeasurements],
898
298
  )
899
299
 
900
300
  const groupStyle: CSSProperties = useMemo(
901
301
  () => ({
902
- display: "flex",
903
- flexDirection: direction === "horizontal" ? "row" : "column",
904
- alignItems: "stretch",
302
+ // 見た目に関わる既定値は利用者が上書きできる
905
303
  width: "100%",
906
304
  height: "100%",
907
305
  minWidth: "0px",
908
306
  minHeight: "0px",
909
- position: "relative",
910
- overflow: "hidden", // リサイズハンドルのはみ出しでスクロールバーを表示させない
307
+ // ハンドルは境界をまたいで描かれるため、その張り出しでスクロールバーを出さない
308
+ overflow: "hidden",
911
309
  ...style,
310
+ // ここから下はレイアウトの前提そのもの。利用者のスタイルやクラスで変わると
311
+ // 配分結果と描画が一致しなくなるため、最後に適用して上書きを受け付けない:
312
+ // gap はパネルの合計に含まれない隙間を作り、flex-direction の反転は DOM 順と視覚順をずらし、
313
+ // writing-mode は主軸の向きそのものを変えてしまう
314
+ display: "flex",
315
+ flexDirection: direction === "horizontal" ? "row" : "column",
316
+ alignItems: "stretch",
317
+ gap: "0px",
318
+ position: "relative",
319
+ boxSizing: "border-box",
320
+ writingMode: "horizontal-tb",
912
321
  }),
913
322
  [direction, style],
914
323
  )
915
324
 
916
- return {
917
- groupRef,
918
- contextValue,
919
- groupStyle,
920
- }
325
+ return { groupRef, contextValue, groupStyle }
921
326
  }
922
327
 
923
328
  /**
924
- * Provide imperative helpers for toggling a specific panel while preserving stored sizing.
925
- * 特定パネルの保存済みサイズを維持しつつ開閉を制御する命令型ユーティリティを提供するカスタムフック。
329
+ * Provide imperative helpers for toggling a specific panel while preserving its stored preference.
330
+ * 特定パネルの希望値を保ったまま開閉を制御する命令型ユーティリティを提供するカスタムフック。
331
+ *
332
+ * @param panelId - Identifier of the panel to control / 操作対象パネルの識別子
333
+ * @returns Panel state and collapse/expand/toggle helpers / パネル状態と開閉ヘルパー
926
334
  */
927
335
  export const usePanelControls = (panelId: string) => {
928
336
  const { getPanel, collapsePanel, expandPanel } = usePanelGroup()