@aiquants/resize-panels 1.7.3 → 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 +7 -4
- 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/Panel.tsx
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Panel component for custom resizable panels
|
|
3
|
+
* カスタムリサイズ可能パネルのための Panel コンポーネント
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type CSSProperties, type MutableRefObject, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
7
|
+
import { twMerge } from "tailwind-merge"
|
|
8
|
+
import { usePanelGroup } from "./context"
|
|
9
|
+
import { PanelDebugInfo } from "./PanelDebugInfo"
|
|
10
|
+
import type { CollapsibleConfig, PanelCollapseDirection, PanelProps } from "./types"
|
|
11
|
+
import { calculateSnapThreshold, getContainerSize, normalizeSizeConfig } from "./utils"
|
|
12
|
+
import { Logger, LogLevel } from "./utils/simple-logger"
|
|
13
|
+
|
|
14
|
+
const logger = new Logger(LogLevel.INFO, "[resize-panels]")
|
|
15
|
+
|
|
16
|
+
type NormalizedCollapsibleConfig = {
|
|
17
|
+
collapseFromStart: boolean
|
|
18
|
+
collapseFromEnd: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const collapsibleMatrix = {
|
|
22
|
+
both: {
|
|
23
|
+
collapseFromStart: true,
|
|
24
|
+
collapseFromEnd: true,
|
|
25
|
+
},
|
|
26
|
+
start: {
|
|
27
|
+
collapseFromStart: true,
|
|
28
|
+
collapseFromEnd: false,
|
|
29
|
+
},
|
|
30
|
+
end: {
|
|
31
|
+
collapseFromStart: false,
|
|
32
|
+
collapseFromEnd: true,
|
|
33
|
+
},
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Normalize collapsible configuration into directional flags.
|
|
38
|
+
* 折りたたみ設定を方向フラグへ正規化。
|
|
39
|
+
*/
|
|
40
|
+
const normalizeCollapsibleConfig = (config: CollapsibleConfig | undefined): NormalizedCollapsibleConfig => {
|
|
41
|
+
if (!config) {
|
|
42
|
+
return {
|
|
43
|
+
collapseFromStart: false,
|
|
44
|
+
collapseFromEnd: false,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const result = collapsibleMatrix[config.from]
|
|
48
|
+
if (!result) {
|
|
49
|
+
throw new Error("Invalid collapsible configuration: unsupported 'from' value")
|
|
50
|
+
}
|
|
51
|
+
return result
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type CommittedSetterParams = {
|
|
55
|
+
ref: MutableRefObject<number>
|
|
56
|
+
setState: (value: number) => void
|
|
57
|
+
panelIdRef: React.RefObject<string>
|
|
58
|
+
label: "pixel" | "percentage"
|
|
59
|
+
timerRef?: React.RefObject<ReturnType<typeof setTimeout> | null>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Create a setter that commits numeric size while preventing redundant updates.
|
|
64
|
+
* 冗長な更新を避けつつ数値サイズをコミットするセッターを生成。
|
|
65
|
+
*/
|
|
66
|
+
const createCommittedSetter = ({ ref, setState, panelIdRef, label, timerRef }: CommittedSetterParams) => {
|
|
67
|
+
return (nextValue: number) => {
|
|
68
|
+
if (!Number.isFinite(nextValue)) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
const sanitized = nextValue < 0 ? 0 : nextValue
|
|
72
|
+
const threshold = label === "pixel" ? 0.1 : 0.01
|
|
73
|
+
if (Math.abs(ref.current - sanitized) < threshold) {
|
|
74
|
+
// 閾値未満の変化は即時反映せず、静止後の最終同期をスケジュール
|
|
75
|
+
if (timerRef) {
|
|
76
|
+
if (timerRef.current) {
|
|
77
|
+
clearTimeout(timerRef.current)
|
|
78
|
+
}
|
|
79
|
+
timerRef.current = setTimeout(() => {
|
|
80
|
+
ref.current = sanitized
|
|
81
|
+
setState(sanitized)
|
|
82
|
+
timerRef.current = null
|
|
83
|
+
}, 200)
|
|
84
|
+
}
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 閾値を超える変化があった場合は待機中のタイマーを解除し、即座に反映
|
|
89
|
+
if (timerRef?.current) {
|
|
90
|
+
clearTimeout(timerRef.current)
|
|
91
|
+
timerRef.current = null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ref.current = sanitized
|
|
95
|
+
logger.debug(`commit ${label}`, {
|
|
96
|
+
panelId: panelIdRef.current,
|
|
97
|
+
[label]: sanitized,
|
|
98
|
+
})
|
|
99
|
+
setState(sanitized)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Render a resizable panel within a managed group, keeping DOM measurements in sync with reducer state.
|
|
105
|
+
* 管理対象グループ内で DOM 計測とリデューサー状態を同期させながらリサイズ可能パネルを描画するコンポーネント。
|
|
106
|
+
*/
|
|
107
|
+
export const Panel = memo(
|
|
108
|
+
({ id: providedId, defaultSize = { value: 50, unit: "percentage" }, minSize, maxSize, autoMinSize, className, style, children, order = 0, collapsible, defaultCollapsed = false, pixelAdjustPriority, flexAdjustPriority, contentOverflow = "auto" }: PanelProps) => {
|
|
109
|
+
const { direction, registerPanel, unregisterPanel, getPanel, showDebugInfo: shouldShowDebugInfo, isContainerReady, reportPanelMeasurement, containerSize } = usePanelGroup()
|
|
110
|
+
const panelRef = useRef<HTMLDivElement>(null)
|
|
111
|
+
const generatedId = useId()
|
|
112
|
+
const panelId = useRef(providedId || `panel-${generatedId}`)
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
// id prop はマウント後に変更できない (登録・DOM 属性・測定が初期 id に固定されるため)
|
|
116
|
+
// 黙殺すると気付けないため開発者向けに警告を出す
|
|
117
|
+
if (providedId && providedId !== panelId.current) {
|
|
118
|
+
logger.warn("Panel id prop change after mount is ignored.", {
|
|
119
|
+
currentId: panelId.current,
|
|
120
|
+
attemptedId: providedId,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}, [providedId])
|
|
124
|
+
|
|
125
|
+
const collapsibleConfig = useMemo(() => normalizeCollapsibleConfig(collapsible), [collapsible])
|
|
126
|
+
const isCollapsible = collapsibleConfig.collapseFromStart || collapsibleConfig.collapseFromEnd
|
|
127
|
+
const preferredCollapsedDirection: PanelCollapseDirection | null = useMemo(() => {
|
|
128
|
+
if (!isCollapsible) {
|
|
129
|
+
return null
|
|
130
|
+
}
|
|
131
|
+
if (collapsibleConfig.collapseFromStart) {
|
|
132
|
+
return "start"
|
|
133
|
+
}
|
|
134
|
+
if (collapsibleConfig.collapseFromEnd) {
|
|
135
|
+
return "end"
|
|
136
|
+
}
|
|
137
|
+
return null
|
|
138
|
+
}, [isCollapsible, collapsibleConfig.collapseFromStart, collapsibleConfig.collapseFromEnd])
|
|
139
|
+
const shouldStartCollapsed = isCollapsible && defaultCollapsed
|
|
140
|
+
const defaultSizeConfig = useMemo(() => normalizeSizeConfig(defaultSize), [defaultSize])
|
|
141
|
+
const isPixelUnit = defaultSizeConfig?.unit === "pixels"
|
|
142
|
+
const preferredPercentage = !isPixelUnit ? defaultSizeConfig?.value : undefined
|
|
143
|
+
|
|
144
|
+
// リデューサーが保持する最新パネル状態を取得
|
|
145
|
+
const currentPanel = getPanel(panelId.current)
|
|
146
|
+
// 折りたたみフラグが明示的に有効か判定
|
|
147
|
+
const isExplicitlyCollapsed = Boolean(currentPanel?.collapsed)
|
|
148
|
+
// サイズがゼロ以下で見かけ上折りたたまれているか判定
|
|
149
|
+
const hasZeroState = (currentPanel?.size ?? 0) <= 0
|
|
150
|
+
// 明示折りたたみかゼロ状態かを統合した折りたたみ判定
|
|
151
|
+
const isEffectivelyCollapsed = isExplicitlyCollapsed || hasZeroState
|
|
152
|
+
|
|
153
|
+
// observer コールバックから最新状態を参照するための ref。
|
|
154
|
+
// effect の依存に currentPanel (リデューサー更新のたびに identity が変わる) を入れると
|
|
155
|
+
// ドラッグ毎フレーム ResizeObserver / MutationObserver が再生成されるため、ref 経由で渡す。
|
|
156
|
+
const currentPanelRef = useRef(currentPanel)
|
|
157
|
+
currentPanelRef.current = currentPanel
|
|
158
|
+
const isEffectivelyCollapsedRef = useRef(isEffectivelyCollapsed)
|
|
159
|
+
isEffectivelyCollapsedRef.current = isEffectivelyCollapsed
|
|
160
|
+
|
|
161
|
+
const [pixelSize, setPixelSize] = useState(() => {
|
|
162
|
+
// 初期ピクセルサイズを defaultSize もしくは最新のリデューサー値から取得
|
|
163
|
+
if (isPixelUnit && defaultSizeConfig) {
|
|
164
|
+
return defaultSizeConfig.value
|
|
165
|
+
}
|
|
166
|
+
if (currentPanel?.size !== undefined) {
|
|
167
|
+
return currentPanel.size
|
|
168
|
+
}
|
|
169
|
+
return 0
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
const [percentageSize, setPercentageSize] = useState(() => {
|
|
173
|
+
// 初期パーセンテージサイズを defaultSize もしくは最新のリデューサー値から取得
|
|
174
|
+
if (!isPixelUnit && defaultSizeConfig) {
|
|
175
|
+
return defaultSizeConfig.value
|
|
176
|
+
}
|
|
177
|
+
if (currentPanel?.percentageSize !== undefined) {
|
|
178
|
+
return currentPanel.percentageSize
|
|
179
|
+
}
|
|
180
|
+
return 0
|
|
181
|
+
})
|
|
182
|
+
const pixelSizeRef = useRef(pixelSize)
|
|
183
|
+
const percentageSizeRef = useRef(percentageSize)
|
|
184
|
+
const pixelSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
185
|
+
const percentageSyncTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
186
|
+
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
return () => {
|
|
189
|
+
if (pixelSyncTimerRef.current) clearTimeout(pixelSyncTimerRef.current)
|
|
190
|
+
if (percentageSyncTimerRef.current) clearTimeout(percentageSyncTimerRef.current)
|
|
191
|
+
}
|
|
192
|
+
}, [])
|
|
193
|
+
|
|
194
|
+
const commitPixelSize = useMemo(
|
|
195
|
+
() =>
|
|
196
|
+
createCommittedSetter({
|
|
197
|
+
ref: pixelSizeRef,
|
|
198
|
+
setState: setPixelSize,
|
|
199
|
+
panelIdRef: panelId,
|
|
200
|
+
label: "pixel",
|
|
201
|
+
timerRef: pixelSyncTimerRef,
|
|
202
|
+
}),
|
|
203
|
+
[],
|
|
204
|
+
)
|
|
205
|
+
const commitPercentageSize = useMemo(
|
|
206
|
+
() =>
|
|
207
|
+
createCommittedSetter({
|
|
208
|
+
ref: percentageSizeRef,
|
|
209
|
+
setState: setPercentageSize,
|
|
210
|
+
panelIdRef: panelId,
|
|
211
|
+
label: "percentage",
|
|
212
|
+
timerRef: percentageSyncTimerRef,
|
|
213
|
+
}),
|
|
214
|
+
[],
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Reset both pixel and percentage caches to zero without debug logging.
|
|
219
|
+
* デバッグ出力なしでピクセルと割合のキャッシュをゼロへリセット。
|
|
220
|
+
*/
|
|
221
|
+
const resetPanelSizes = useCallback(() => {
|
|
222
|
+
// 保留中の最終同期タイマーを破棄する (折りたたみ後に古い実測値が復活するのを防ぐ)
|
|
223
|
+
if (pixelSyncTimerRef.current) {
|
|
224
|
+
clearTimeout(pixelSyncTimerRef.current)
|
|
225
|
+
pixelSyncTimerRef.current = null
|
|
226
|
+
}
|
|
227
|
+
if (percentageSyncTimerRef.current) {
|
|
228
|
+
clearTimeout(percentageSyncTimerRef.current)
|
|
229
|
+
percentageSyncTimerRef.current = null
|
|
230
|
+
}
|
|
231
|
+
if (pixelSizeRef.current !== 0) {
|
|
232
|
+
pixelSizeRef.current = 0
|
|
233
|
+
setPixelSize(0)
|
|
234
|
+
}
|
|
235
|
+
if (percentageSizeRef.current !== 0) {
|
|
236
|
+
percentageSizeRef.current = 0
|
|
237
|
+
setPercentageSize(0)
|
|
238
|
+
}
|
|
239
|
+
}, [])
|
|
240
|
+
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
// ResizeObserver がコンテナサイズを取得するまでパネル登録を遅延
|
|
243
|
+
if (!isContainerReady) {
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ピクセル単位の場合は制約範囲内に収めた初期値を算出
|
|
248
|
+
let resolvedPixelSize = 0
|
|
249
|
+
if (isPixelUnit && defaultSizeConfig) {
|
|
250
|
+
const targetSize = defaultSizeConfig.value
|
|
251
|
+
|
|
252
|
+
// ピクセル制約があれば初期値へ反映
|
|
253
|
+
let constrainedSize = targetSize
|
|
254
|
+
if (minSize && typeof minSize === "object" && minSize.unit === "pixels") {
|
|
255
|
+
constrainedSize = Math.max(constrainedSize, minSize.value)
|
|
256
|
+
}
|
|
257
|
+
if (maxSize && typeof maxSize === "object" && maxSize.unit === "pixels") {
|
|
258
|
+
constrainedSize = Math.min(constrainedSize, maxSize.value)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 制約を適用した初期ピクセルサイズ
|
|
262
|
+
resolvedPixelSize = constrainedSize
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const isInitiallyCollapsed = shouldStartCollapsed
|
|
266
|
+
const initialCollapseDirection: PanelCollapseDirection | null = isInitiallyCollapsed ? preferredCollapsedDirection : null
|
|
267
|
+
|
|
268
|
+
if (defaultCollapsed && !isCollapsible) {
|
|
269
|
+
logger.warn("Panel defaultCollapsed is ignored because collapsible configuration is not provided.")
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 折りたたみ前サイズと割合の初期値を算出 (折りたたみ可能な場合のみ保持)
|
|
273
|
+
const initialSizeBeforeCollapse = isCollapsible && isInitiallyCollapsed ? (isPixelUnit ? resolvedPixelSize : undefined) : undefined
|
|
274
|
+
const initialPercentageBeforeCollapse = isCollapsible ? preferredPercentage : undefined
|
|
275
|
+
|
|
276
|
+
// パネルデータを構築してリデューサーへ登録
|
|
277
|
+
const preferredPixelSize = isPixelUnit ? resolvedPixelSize : undefined
|
|
278
|
+
|
|
279
|
+
const panel = {
|
|
280
|
+
id: panelId.current,
|
|
281
|
+
size: isInitiallyCollapsed ? 0 : resolvedPixelSize,
|
|
282
|
+
percentageSize: isInitiallyCollapsed ? 0 : preferredPercentage,
|
|
283
|
+
preferredPercentageSize: preferredPercentage,
|
|
284
|
+
preferredPixelSize,
|
|
285
|
+
sizeUnit: defaultSizeConfig?.unit || "percentage",
|
|
286
|
+
originalPixelSize: isPixelUnit ? resolvedPixelSize : undefined,
|
|
287
|
+
minSize,
|
|
288
|
+
maxSize,
|
|
289
|
+
collapseFromStart: collapsibleConfig.collapseFromStart,
|
|
290
|
+
collapseFromEnd: collapsibleConfig.collapseFromEnd,
|
|
291
|
+
collapsedByDirection: initialCollapseDirection,
|
|
292
|
+
collapsed: isInitiallyCollapsed,
|
|
293
|
+
sizeBeforeCollapse: initialSizeBeforeCollapse,
|
|
294
|
+
percentageSizeBeforeCollapse: initialPercentageBeforeCollapse,
|
|
295
|
+
preferredPercentageSizeBeforeCollapse: isCollapsible ? preferredPercentage : undefined,
|
|
296
|
+
preferredPixelSizeBeforeCollapse: isCollapsible ? preferredPixelSize : undefined,
|
|
297
|
+
pixelAdjustPriority,
|
|
298
|
+
flexAdjustPriority,
|
|
299
|
+
autoMinSize,
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
registerPanel(panel)
|
|
303
|
+
}, [
|
|
304
|
+
autoMinSize,
|
|
305
|
+
collapsibleConfig.collapseFromEnd,
|
|
306
|
+
collapsibleConfig.collapseFromStart,
|
|
307
|
+
defaultCollapsed,
|
|
308
|
+
defaultSizeConfig,
|
|
309
|
+
flexAdjustPriority,
|
|
310
|
+
isCollapsible,
|
|
311
|
+
isContainerReady,
|
|
312
|
+
isPixelUnit,
|
|
313
|
+
maxSize,
|
|
314
|
+
minSize,
|
|
315
|
+
pixelAdjustPriority,
|
|
316
|
+
preferredCollapsedDirection,
|
|
317
|
+
preferredPercentage,
|
|
318
|
+
registerPanel,
|
|
319
|
+
shouldStartCollapsed,
|
|
320
|
+
])
|
|
321
|
+
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
// アンマウント時にパネル登録と測定結果をクリア
|
|
324
|
+
return () => {
|
|
325
|
+
unregisterPanel(panelId.current)
|
|
326
|
+
reportPanelMeasurement(panelId.current, null)
|
|
327
|
+
}
|
|
328
|
+
}, [unregisterPanel, reportPanelMeasurement])
|
|
329
|
+
|
|
330
|
+
const currentSize = currentPanel?.size ?? 0
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Measure the DOM and sync local size caches with the latest reducer state.
|
|
334
|
+
* DOM を実測し、最新のリデューサー状態とローカルサイズキャッシュを同期する。
|
|
335
|
+
* observer から毎フレーム呼ばれるため、リデューサー状態は ref 経由で読み依存を安定させる。
|
|
336
|
+
*/
|
|
337
|
+
const updateSizes = useCallback(() => {
|
|
338
|
+
const panelElement = panelRef.current
|
|
339
|
+
if (!panelElement) return
|
|
340
|
+
|
|
341
|
+
const latestPanel = currentPanelRef.current
|
|
342
|
+
if (!latestPanel) {
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const parentElement = panelElement.parentElement
|
|
347
|
+
if (!parentElement) return
|
|
348
|
+
|
|
349
|
+
const rect = panelElement.getBoundingClientRect()
|
|
350
|
+
const parentHorizontal = getContainerSize(parentElement, "horizontal")
|
|
351
|
+
const parentVertical = getContainerSize(parentElement, "vertical")
|
|
352
|
+
|
|
353
|
+
const currentPixelSize = direction === "horizontal" ? rect.width : rect.height
|
|
354
|
+
const parentSize = direction === "horizontal" ? parentHorizontal.inner : parentVertical.inner
|
|
355
|
+
const snapThreshold = calculateSnapThreshold(parentSize)
|
|
356
|
+
|
|
357
|
+
// 折りたたみ時はサイズをゼロに固定
|
|
358
|
+
if (isEffectivelyCollapsedRef.current) {
|
|
359
|
+
resetPanelSizes()
|
|
360
|
+
return
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 実測値がノイズ範囲と判定される場合はスキップ
|
|
364
|
+
// snapThreshold から導いた閾値より大きなサイズをリデューサーが保持しているのに、ResizeObserver が一瞬だけ極端に小さい値を返すケースがある
|
|
365
|
+
// その揺らぎを反映すると表示がチラついたり、意図せず折りたたみ判定へ繋がるのでここで検出して破棄する
|
|
366
|
+
const expectedPanelSize = latestPanel.size ?? 0
|
|
367
|
+
const noiseThreshold = Math.max(2, snapThreshold * 0.5)
|
|
368
|
+
const looksLikeNoise = expectedPanelSize > snapThreshold && currentPixelSize < noiseThreshold
|
|
369
|
+
|
|
370
|
+
if (looksLikeNoise) {
|
|
371
|
+
logger.debug("skip noisy measurement", {
|
|
372
|
+
panelId: panelId.current,
|
|
373
|
+
expectedPanelSize,
|
|
374
|
+
measured: currentPixelSize,
|
|
375
|
+
noiseThreshold,
|
|
376
|
+
})
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (currentPixelSize > 0) {
|
|
381
|
+
// 実測ピクセル値でローカルキャッシュを更新
|
|
382
|
+
commitPixelSize(currentPixelSize)
|
|
383
|
+
|
|
384
|
+
if (parentSize > 0) {
|
|
385
|
+
// 親コンテナ比率から割合を再計算
|
|
386
|
+
commitPercentageSize((currentPixelSize / parentSize) * 100)
|
|
387
|
+
}
|
|
388
|
+
return
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (latestPanel.size !== undefined) {
|
|
392
|
+
commitPixelSize(latestPanel.size)
|
|
393
|
+
}
|
|
394
|
+
if (latestPanel.percentageSize !== undefined) {
|
|
395
|
+
// 状態が保持する割合をそのまま採用
|
|
396
|
+
commitPercentageSize(latestPanel.percentageSize)
|
|
397
|
+
} else if (latestPanel.sizeUnit === "pixels" && latestPanel.size !== undefined && parentSize > 0) {
|
|
398
|
+
// ピクセル単位パネルは親サイズから割合を求める
|
|
399
|
+
commitPercentageSize((latestPanel.size / parentSize) * 100)
|
|
400
|
+
}
|
|
401
|
+
}, [commitPercentageSize, commitPixelSize, direction, resetPanelSizes])
|
|
402
|
+
|
|
403
|
+
const hasInitialMeasurementRef = useRef(false)
|
|
404
|
+
useEffect(() => {
|
|
405
|
+
// 登録がリデューサーへ反映された直後に一度だけ DOM 実測を同期する
|
|
406
|
+
// (以降の追従は ResizeObserver / MutationObserver が担う)
|
|
407
|
+
if (!hasInitialMeasurementRef.current && currentPanel) {
|
|
408
|
+
hasInitialMeasurementRef.current = true
|
|
409
|
+
updateSizes()
|
|
410
|
+
}
|
|
411
|
+
}, [currentPanel, updateSizes])
|
|
412
|
+
|
|
413
|
+
useEffect(() => {
|
|
414
|
+
// ResizeObserver で DOM 実測を監視し状態と乖離時にローカルキャッシュを更新
|
|
415
|
+
const panelElement = panelRef.current
|
|
416
|
+
if (!panelElement) return
|
|
417
|
+
|
|
418
|
+
const resizeObserver = new ResizeObserver(() => {
|
|
419
|
+
updateSizes()
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
resizeObserver.observe(panelElement)
|
|
423
|
+
|
|
424
|
+
updateSizes()
|
|
425
|
+
|
|
426
|
+
// 初期計測失敗時の再試行タイマー
|
|
427
|
+
const timeoutIds: Array<ReturnType<typeof setTimeout>> = []
|
|
428
|
+
|
|
429
|
+
// PANEL_RESIZE_RETRY_INTERVALS.forEach((delay) => {
|
|
430
|
+
// const timeoutId = setTimeout(() => {
|
|
431
|
+
// const rect = panelElement.getBoundingClientRect()
|
|
432
|
+
// const currentPixelSize = direction === "horizontal" ? rect.width : rect.height
|
|
433
|
+
|
|
434
|
+
// // 初期計測失敗や割合キャッシュ未設定を検出
|
|
435
|
+
// const shouldRetry =
|
|
436
|
+
// currentPixelSize === 0 || (currentPanel?.sizeUnit === "pixels" && percentageSizeRef.current === 0) || (currentPanel?.sizeUnit === "pixels" && currentPanel?.originalPixelSize !== undefined && percentageSizeRef.current === 0 && currentPixelSize > 0)
|
|
437
|
+
|
|
438
|
+
// if (shouldRetry) {
|
|
439
|
+
// // 計測のやり直しをスケジュール
|
|
440
|
+
// updateSizes()
|
|
441
|
+
// }
|
|
442
|
+
// }, delay)
|
|
443
|
+
// timeoutIds.push(timeoutId)
|
|
444
|
+
// })
|
|
445
|
+
|
|
446
|
+
const mutationObserver = new MutationObserver(() => {
|
|
447
|
+
// 親要素の属性や子要素変化でも遅延同期を実行してレイアウト変動へ追従
|
|
448
|
+
// ただし、測定値の更新による DOM 変更では再実行しない
|
|
449
|
+
const shouldUpdate = (() => {
|
|
450
|
+
if (!panelRef.current) return false
|
|
451
|
+
const rect = panelRef.current.getBoundingClientRect()
|
|
452
|
+
const measuredSize = direction === "horizontal" ? rect.width : rect.height
|
|
453
|
+
// 実測サイズがローカルキャッシュと一致していれば更新不要
|
|
454
|
+
if (measuredSize === pixelSizeRef.current) {
|
|
455
|
+
return false
|
|
456
|
+
}
|
|
457
|
+
return true
|
|
458
|
+
})()
|
|
459
|
+
|
|
460
|
+
if (shouldUpdate) {
|
|
461
|
+
setTimeout(updateSizes, 0)
|
|
462
|
+
}
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
const parentElement = panelElement.parentElement
|
|
466
|
+
if (parentElement) {
|
|
467
|
+
// 親 (グループコンテナ) 自身の属性変化と直下の子要素の増減のみを監視する。
|
|
468
|
+
// subtree 監視はパネル内容の DOM 変異のたびに全パネルで強制レイアウトを誘発するため使わない。
|
|
469
|
+
// パネル自身のサイズ変化は ResizeObserver が捕捉する。
|
|
470
|
+
mutationObserver.observe(parentElement, {
|
|
471
|
+
childList: true,
|
|
472
|
+
attributes: true,
|
|
473
|
+
attributeFilter: ["style", "class"],
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return () => {
|
|
478
|
+
resizeObserver.disconnect()
|
|
479
|
+
mutationObserver.disconnect()
|
|
480
|
+
for (const timeoutId of timeoutIds) {
|
|
481
|
+
clearTimeout(timeoutId)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}, [direction, updateSizes])
|
|
485
|
+
|
|
486
|
+
const currentPanelSize = currentPanel?.size
|
|
487
|
+
const currentPanelPercentageSize = currentPanel?.percentageSize
|
|
488
|
+
const currentPanelSizeUnit = currentPanel?.sizeUnit
|
|
489
|
+
|
|
490
|
+
useEffect(() => {
|
|
491
|
+
if (currentPanel) {
|
|
492
|
+
// リデューサーの最新サイズを反映し折りたたみ時はゼロへリセット
|
|
493
|
+
if (isEffectivelyCollapsed) {
|
|
494
|
+
resetPanelSizes()
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (currentPanelSize !== undefined) {
|
|
498
|
+
commitPixelSize(currentPanelSize)
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (currentPanelPercentageSize !== undefined) {
|
|
502
|
+
// リデューサーが保持する割合サイズが最新なのでそのまま適用
|
|
503
|
+
const targetPercentage = currentPanelPercentageSize
|
|
504
|
+
commitPercentageSize(targetPercentage)
|
|
505
|
+
} else if (currentPanelSizeUnit === "pixels" && currentPanelSize !== undefined) {
|
|
506
|
+
// DOM 実測値と親コンテナサイズから割合サイズへの換算を試行
|
|
507
|
+
const panelElement = panelRef.current
|
|
508
|
+
const parentElement = panelElement?.parentElement
|
|
509
|
+
if (panelElement && parentElement) {
|
|
510
|
+
const { width, height } = panelElement.getBoundingClientRect()
|
|
511
|
+
const parentWidth = parentElement.clientWidth
|
|
512
|
+
const parentHeight = parentElement.clientHeight
|
|
513
|
+
|
|
514
|
+
const currentPixelSize = direction === "horizontal" ? width : height
|
|
515
|
+
const parentSize = direction === "horizontal" ? parentWidth : parentHeight
|
|
516
|
+
|
|
517
|
+
// ピクセルと親サイズの双方が取得できた場合にのみ割合を更新
|
|
518
|
+
if (currentPixelSize > 0 && parentSize > 0) {
|
|
519
|
+
const calculatedPercentage = (currentPixelSize / parentSize) * 100
|
|
520
|
+
commitPercentageSize(calculatedPercentage)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}, [commitPercentageSize, commitPixelSize, currentPanel, currentPanelPercentageSize, currentPanelSize, currentPanelSizeUnit, direction, isEffectivelyCollapsed, resetPanelSizes])
|
|
526
|
+
|
|
527
|
+
useEffect(() => {
|
|
528
|
+
// 最新測定値をコンテキスト経由でグループへ報告しオーバーレイ等を更新
|
|
529
|
+
reportPanelMeasurement(panelId.current, {
|
|
530
|
+
pixelSize,
|
|
531
|
+
percentageSize,
|
|
532
|
+
})
|
|
533
|
+
}, [reportPanelMeasurement, pixelSize, percentageSize])
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Compute CSS flex-basis value based on panel size unit and collapse state.
|
|
537
|
+
* パネルサイズユニットと折りたたみ状態に基づいて CSS flex-basis 値を計算。
|
|
538
|
+
*/
|
|
539
|
+
const getFlexBasis = () => {
|
|
540
|
+
if (isEffectivelyCollapsed) {
|
|
541
|
+
return "0px"
|
|
542
|
+
}
|
|
543
|
+
if (currentPanel?.originalPixelSize !== undefined) {
|
|
544
|
+
return `${currentSize}px`
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (currentPanel?.percentageSize !== undefined) {
|
|
548
|
+
return `${currentPanel.percentageSize}%`
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return `${currentSize}px`
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const computedFlexShrink = currentPanel?.sizeUnit === "percentage" && !currentPanel?.collapsed ? 1 : 0
|
|
555
|
+
|
|
556
|
+
// パネル自身の Flex 振る舞いと最小サイズを制御するベーススタイル
|
|
557
|
+
const panelStyle: CSSProperties = {
|
|
558
|
+
flexBasis: getFlexBasis(),
|
|
559
|
+
flexGrow: 0,
|
|
560
|
+
flexShrink: computedFlexShrink,
|
|
561
|
+
overflow: "hidden",
|
|
562
|
+
position: "relative",
|
|
563
|
+
boxSizing: "border-box",
|
|
564
|
+
minHeight: "0px",
|
|
565
|
+
minWidth: "0px",
|
|
566
|
+
...style,
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (direction === "horizontal") {
|
|
570
|
+
panelStyle.height = "100%"
|
|
571
|
+
// 横並びではコンテンツに引きずられずに縮むため minWidth を確実に 0 にする
|
|
572
|
+
panelStyle.minWidth = 0
|
|
573
|
+
} else {
|
|
574
|
+
panelStyle.width = "100%"
|
|
575
|
+
// 縦並びでは高さ縮小時にコンテンツの min-height を無視するため 0 を設定
|
|
576
|
+
panelStyle.minHeight = 0
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (isEffectivelyCollapsed) {
|
|
580
|
+
// 折りたたみ時にパネルをレイアウトから外しユーザー操作も遮断するためのスタイル
|
|
581
|
+
if (direction === "horizontal") {
|
|
582
|
+
panelStyle.width = "0px"
|
|
583
|
+
panelStyle.minWidth = "0px"
|
|
584
|
+
} else {
|
|
585
|
+
panelStyle.height = "0px"
|
|
586
|
+
panelStyle.minHeight = "0px"
|
|
587
|
+
}
|
|
588
|
+
panelStyle.flexGrow = 0
|
|
589
|
+
panelStyle.flexShrink = 0
|
|
590
|
+
panelStyle.pointerEvents = "none"
|
|
591
|
+
panelStyle.visibility = "hidden"
|
|
592
|
+
if (isExplicitlyCollapsed) {
|
|
593
|
+
panelStyle.display = "none"
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const containerAxisSize = direction === "horizontal" ? containerSize.width : containerSize.height
|
|
598
|
+
|
|
599
|
+
// 実際の子要素をパネル内に固定しスクロールも許可するためのラッパースタイル
|
|
600
|
+
const contentWrapperStyle = useMemo<CSSProperties>(
|
|
601
|
+
() => ({
|
|
602
|
+
position: "absolute",
|
|
603
|
+
inset: "0px",
|
|
604
|
+
overflow: contentOverflow,
|
|
605
|
+
display: "flex",
|
|
606
|
+
flexDirection: "column",
|
|
607
|
+
width: "100%",
|
|
608
|
+
height: "100%",
|
|
609
|
+
minWidth: "0px",
|
|
610
|
+
minHeight: "0px",
|
|
611
|
+
}),
|
|
612
|
+
[contentOverflow],
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<div
|
|
617
|
+
ref={panelRef}
|
|
618
|
+
className={twMerge("panel", className)}
|
|
619
|
+
style={panelStyle}
|
|
620
|
+
data-panel-id={panelId.current}
|
|
621
|
+
data-panel-size={currentSize}
|
|
622
|
+
data-panel-size-unit={currentPanel?.sizeUnit || "percentage"}
|
|
623
|
+
data-panel-pixel-size={currentPanel?.sizeUnit === "pixels" ? currentPanel?.originalPixelSize : null}
|
|
624
|
+
data-panel-order={order}
|
|
625
|
+
data-panel-collapsible={isCollapsible}
|
|
626
|
+
data-panel-collapse-start={collapsibleConfig.collapseFromStart}
|
|
627
|
+
data-panel-collapse-end={collapsibleConfig.collapseFromEnd}
|
|
628
|
+
data-panel-collapsed={currentPanel?.collapsed}
|
|
629
|
+
aria-hidden={isEffectivelyCollapsed}>
|
|
630
|
+
<div style={contentWrapperStyle}>{children}</div>
|
|
631
|
+
{shouldShowDebugInfo && <PanelDebugInfo panel={currentPanel} measuredPixelSize={pixelSize} measuredPercentageSize={percentageSize} containerAxisSize={containerAxisSize} direction={direction} />}
|
|
632
|
+
</div>
|
|
633
|
+
)
|
|
634
|
+
},
|
|
635
|
+
)
|