@aiquants/resize-panels 1.7.2 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/styles/resize-panels.css +1 -1
- package/dist/styles/resize-panels.standalone.css +3 -0
- package/package.json +10 -7
- package/src/GlobalDebugOverlay.tsx +284 -0
- package/src/Panel.tsx +635 -0
- package/src/PanelDebugInfo.tsx +148 -0
- package/src/PanelGroup.tsx +260 -0
- package/src/PanelResizeHandle.tsx +718 -0
- package/src/context.ts +25 -0
- package/src/debugOverlayStore.ts +351 -0
- package/src/hooks.ts +968 -0
- package/src/index.ts +62 -0
- package/src/reducer.ts +1234 -0
- package/src/roundHalfToEven.ts +40 -0
- package/src/styles/components.entry.css +8 -0
- package/src/styles/resize-panels.css +3 -0
- package/src/styles/standalone.entry.css +12 -0
- package/src/types.ts +288 -0
- package/src/utils/simple-logger.ts +186 -0
- package/src/utils.ts +279 -0
- package/dist/resize-panels.css +0 -1
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Custom hooks for resizable panels logic
|
|
3
|
+
* リサイズ可能パネルのロジックのためのカスタムフック
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from "react"
|
|
7
|
+
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"
|
|
11
|
+
import { Logger, LogLevel } from "./utils/simple-logger"
|
|
12
|
+
|
|
13
|
+
const logger = new Logger(LogLevel.INFO, "[resize-panels]")
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Orchestrate registration, constraint resolution, measurement sync, and persistence for a panel group.
|
|
17
|
+
* パネルグループにおける登録処理・制約判定・計測同期・永続化の統括。
|
|
18
|
+
*/
|
|
19
|
+
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)
|
|
25
|
+
const [panelMeasurements, setPanelMeasurements] = useState<Record<string, PanelMeasurement>>({})
|
|
26
|
+
const [handleMeasurements, setHandleMeasurements] = useState<Record<string, ResizeHandleLayoutData>>({})
|
|
27
|
+
const groupRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
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])
|
|
59
|
+
|
|
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) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
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
|
+
|
|
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 })
|
|
95
|
+
}
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
previousViolationRef.current = layoutConstraintViolation
|
|
99
|
+
}, [layoutConstraintViolation, clonePanels])
|
|
100
|
+
|
|
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
|
+
}
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
resizeObserver.observe(groupRef.current)
|
|
150
|
+
return () => {
|
|
151
|
+
resizeObserver.disconnect()
|
|
152
|
+
if (containerSyncTimerRef.current) {
|
|
153
|
+
clearTimeout(containerSyncTimerRef.current)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}, [isContainerReady])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
// アンマウント時に全てのパネル同期タイマーをクリア
|
|
160
|
+
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 })
|
|
177
|
+
}
|
|
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) {
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 未初期化パネルや折り畳み復元待ちパネルが残っているか判定
|
|
218
|
+
const needsInitialization = panels.length > 0 && (panels.length !== initializedPanelCountRef.current || !isInitialized || hasUnresolvedPanels)
|
|
219
|
+
|
|
220
|
+
if (!needsInitialization) {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
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)
|
|
370
|
+
}
|
|
371
|
+
}
|
|
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 })
|
|
380
|
+
}
|
|
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
|
+
}
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
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
|
+
}
|
|
449
|
+
return
|
|
450
|
+
}
|
|
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
|
+
})
|
|
478
|
+
return
|
|
479
|
+
}
|
|
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))
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}, [panels, direction, containerSize.width, containerSize.height, layoutConstraintViolation])
|
|
496
|
+
|
|
497
|
+
// 初期化漏れを防止するために必要時にリソルブ処理を呼び出す
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
initializePanelsIfNeeded()
|
|
500
|
+
}, [initializePanelsIfNeeded])
|
|
501
|
+
|
|
502
|
+
// 最新のレイアウト通知コールバックを参照に保持
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
layoutCallbackRef.current = onLayout
|
|
505
|
+
}, [onLayout])
|
|
506
|
+
|
|
507
|
+
// レイアウト通知のためにパネルサイズ一覧をメモ化
|
|
508
|
+
const panelSizes = useMemo(() => panels.map((panel) => panel.size), [panels])
|
|
509
|
+
|
|
510
|
+
// 初期化前の未確定サイズ (登録直後のパーセンテージパネルは 0) を onLayout や自動保存へ流さないためのゲート。
|
|
511
|
+
// 一度確定したら以降はゼロを含むレイアウト (スナップ等) も正しい状態として通知する
|
|
512
|
+
const hasSettledLayoutRef = useRef(false)
|
|
513
|
+
|
|
514
|
+
// 初期化後にレイアウトリスナーへ寸法を通知
|
|
515
|
+
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) {
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
if (layoutCallbackRef.current) {
|
|
528
|
+
layoutCallbackRef.current(panelSizes)
|
|
529
|
+
}
|
|
530
|
+
}, [panelSizes, panels, isInitialized])
|
|
531
|
+
|
|
532
|
+
// 自動保存を遅延実行して書き込み回数を抑制
|
|
533
|
+
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)
|
|
542
|
+
}
|
|
543
|
+
}, [autoSaveId, panelSizes])
|
|
544
|
+
|
|
545
|
+
// 保存済みレイアウトがあれば初回のみ読み込む
|
|
546
|
+
useEffect(() => {
|
|
547
|
+
if (!autoSaveId || panels.length === 0 || initializedRef.current) {
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const axisSize = direction === "horizontal" ? containerSize.width : containerSize.height
|
|
552
|
+
if (axisSize <= 0) {
|
|
553
|
+
return
|
|
554
|
+
}
|
|
555
|
+
|
|
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
|
|
574
|
+
}
|
|
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
|
+
})
|
|
602
|
+
|
|
603
|
+
if (registry[panel.id] === registrationSignature) {
|
|
604
|
+
logger.debug("useResizablePanels.registerPanel skipped", {
|
|
605
|
+
panelId: panel.id,
|
|
606
|
+
reason: "duplicate-registration",
|
|
607
|
+
})
|
|
608
|
+
return
|
|
609
|
+
}
|
|
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
|
+
// パネル登録アクションをディスパッチ
|
|
622
|
+
dispatch({ type: "REGISTER_PANEL", panel })
|
|
623
|
+
}, [])
|
|
624
|
+
|
|
625
|
+
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
|
+
// パネル登録解除アクションをディスパッチし、測定情報もクリア
|
|
637
|
+
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
|
+
setPanelMeasurements((previous) => {
|
|
646
|
+
if (!(id in previous)) {
|
|
647
|
+
return previous
|
|
648
|
+
}
|
|
649
|
+
const next = { ...previous }
|
|
650
|
+
delete next[id]
|
|
651
|
+
return next
|
|
652
|
+
})
|
|
653
|
+
}, [])
|
|
654
|
+
|
|
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
|
+
)
|
|
738
|
+
|
|
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
|
+
)
|
|
751
|
+
|
|
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
|
+
)
|
|
764
|
+
|
|
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
|
+
)
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Synchronize reported panel measurements with local measurement state.
|
|
779
|
+
* パネルから報告された測定情報の状態同期。
|
|
780
|
+
*/
|
|
781
|
+
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) => {
|
|
793
|
+
if (!(panelId in previous)) {
|
|
794
|
+
return previous
|
|
795
|
+
}
|
|
796
|
+
const next = { ...previous }
|
|
797
|
+
delete next[panelId]
|
|
798
|
+
return next
|
|
799
|
+
})
|
|
800
|
+
return
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
setPanelMeasurements((previous) => {
|
|
804
|
+
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
|
+
|
|
826
|
+
return previous
|
|
827
|
+
}
|
|
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
|
+
}
|
|
839
|
+
})
|
|
840
|
+
}, [])
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Synchronize resize-handle measurements while ignoring negligible differences.
|
|
844
|
+
* リサイズハンドル測定値の同期と僅少差分の無視。
|
|
845
|
+
*/
|
|
846
|
+
const reportHandleMeasurement = useCallback((handleId: string, measurement: ResizeHandleLayoutData | null) => {
|
|
847
|
+
setHandleMeasurements((previous) => {
|
|
848
|
+
if (measurement === null) {
|
|
849
|
+
// 測定結果が null の場合はエントリを削除
|
|
850
|
+
if (!(handleId in previous)) {
|
|
851
|
+
return previous
|
|
852
|
+
}
|
|
853
|
+
const next = { ...previous }
|
|
854
|
+
delete next[handleId]
|
|
855
|
+
return next
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
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
|
+
) {
|
|
868
|
+
return previous
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return {
|
|
872
|
+
...previous,
|
|
873
|
+
[handleId]: measurement,
|
|
874
|
+
}
|
|
875
|
+
})
|
|
876
|
+
}, [])
|
|
877
|
+
|
|
878
|
+
const contextValue = useMemo(
|
|
879
|
+
() => ({
|
|
880
|
+
direction,
|
|
881
|
+
panels,
|
|
882
|
+
containerSize,
|
|
883
|
+
isContainerReady,
|
|
884
|
+
showDebugInfo,
|
|
885
|
+
registerPanel,
|
|
886
|
+
unregisterPanel,
|
|
887
|
+
resizePanels,
|
|
888
|
+
collapsePanel,
|
|
889
|
+
expandPanel,
|
|
890
|
+
getPanel,
|
|
891
|
+
reportPanelMeasurement,
|
|
892
|
+
panelMeasurements,
|
|
893
|
+
reportHandleMeasurement,
|
|
894
|
+
handleMeasurements,
|
|
895
|
+
layoutConstraintViolation,
|
|
896
|
+
}),
|
|
897
|
+
[direction, panels, containerSize, isContainerReady, showDebugInfo, registerPanel, unregisterPanel, resizePanels, collapsePanel, expandPanel, getPanel, reportPanelMeasurement, panelMeasurements, reportHandleMeasurement, handleMeasurements, layoutConstraintViolation],
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
const groupStyle: CSSProperties = useMemo(
|
|
901
|
+
() => ({
|
|
902
|
+
display: "flex",
|
|
903
|
+
flexDirection: direction === "horizontal" ? "row" : "column",
|
|
904
|
+
alignItems: "stretch",
|
|
905
|
+
width: "100%",
|
|
906
|
+
height: "100%",
|
|
907
|
+
minWidth: "0px",
|
|
908
|
+
minHeight: "0px",
|
|
909
|
+
position: "relative",
|
|
910
|
+
overflow: "hidden", // リサイズハンドルのはみ出しでスクロールバーを表示させない
|
|
911
|
+
...style,
|
|
912
|
+
}),
|
|
913
|
+
[direction, style],
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
return {
|
|
917
|
+
groupRef,
|
|
918
|
+
contextValue,
|
|
919
|
+
groupStyle,
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Provide imperative helpers for toggling a specific panel while preserving stored sizing.
|
|
925
|
+
* 特定パネルの保存済みサイズを維持しつつ開閉を制御する命令型ユーティリティを提供するカスタムフック。
|
|
926
|
+
*/
|
|
927
|
+
export const usePanelControls = (panelId: string) => {
|
|
928
|
+
const { getPanel, collapsePanel, expandPanel } = usePanelGroup()
|
|
929
|
+
const panel = getPanel(panelId)
|
|
930
|
+
|
|
931
|
+
const collapse = useCallback(
|
|
932
|
+
(from: PanelCollapseDirection) => {
|
|
933
|
+
collapsePanel(panelId, from)
|
|
934
|
+
},
|
|
935
|
+
[collapsePanel, panelId],
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
const expand = useCallback(
|
|
939
|
+
(from: PanelCollapseDirection, targetSize?: number) => {
|
|
940
|
+
expandPanel(panelId, from, targetSize)
|
|
941
|
+
},
|
|
942
|
+
[expandPanel, panelId],
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
const toggle = useCallback(
|
|
946
|
+
(from: PanelCollapseDirection, targetSize?: number) => {
|
|
947
|
+
if (panel?.collapsed) {
|
|
948
|
+
expand(from, targetSize)
|
|
949
|
+
} else {
|
|
950
|
+
collapse(from)
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
[panel?.collapsed, collapse, expand],
|
|
954
|
+
)
|
|
955
|
+
|
|
956
|
+
return useMemo(
|
|
957
|
+
() => ({
|
|
958
|
+
panel,
|
|
959
|
+
isCollapsed: panel?.collapsed ?? false,
|
|
960
|
+
canCollapseFromStart: panel?.collapseFromStart ?? false,
|
|
961
|
+
canCollapseFromEnd: panel?.collapseFromEnd ?? false,
|
|
962
|
+
collapse,
|
|
963
|
+
expand,
|
|
964
|
+
toggle,
|
|
965
|
+
}),
|
|
966
|
+
[panel, collapse, expand, toggle],
|
|
967
|
+
)
|
|
968
|
+
}
|