@aiquants/resize-panels 1.8.1 → 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 (81) hide show
  1. package/README.md +213 -108
  2. package/dist/GlobalDebugOverlay-Cf3GOn2A.cjs +1 -0
  3. package/dist/{GlobalDebugOverlay-DYCR0FIu.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/{PanelResizeHandle.d.ts → src/PanelResizeHandle.d.ts} +1 -1
  18. package/dist/src/PanelResizeHandle.d.ts.map +1 -0
  19. package/dist/src/allocateLayout.d.ts +60 -0
  20. package/dist/src/allocateLayout.d.ts.map +1 -0
  21. package/dist/src/context.d.ts.map +1 -0
  22. package/dist/{debugOverlayStore.d.ts → src/debugOverlayStore.d.ts} +10 -2
  23. package/dist/src/debugOverlayStore.d.ts.map +1 -0
  24. package/dist/src/hooks.d.ts +48 -0
  25. package/dist/src/hooks.d.ts.map +1 -0
  26. package/dist/src/index.d.ts +32 -0
  27. package/dist/src/index.d.ts.map +1 -0
  28. package/dist/src/reducer.d.ts +49 -0
  29. package/dist/src/reducer.d.ts.map +1 -0
  30. package/dist/src/roundHalfToEven.d.ts.map +1 -0
  31. package/dist/src/types.d.ts +481 -0
  32. package/dist/src/types.d.ts.map +1 -0
  33. package/dist/src/utils/simple-logger.d.ts.map +1 -0
  34. package/dist/src/utils.d.ts +164 -0
  35. package/dist/src/utils.d.ts.map +1 -0
  36. package/dist/styles/resize-panels.standalone.css +1 -1
  37. package/dist/tests/support/dom-harness.d.ts +89 -0
  38. package/dist/tests/support/dom-harness.d.ts.map +1 -0
  39. package/package.json +3 -3
  40. package/src/GlobalDebugOverlay.tsx +3 -3
  41. package/src/Panel.tsx +203 -541
  42. package/src/PanelDebugInfo.tsx +52 -106
  43. package/src/PanelGroup.tsx +27 -42
  44. package/src/PanelResizeHandle.tsx +544 -504
  45. package/src/allocateLayout.ts +412 -0
  46. package/src/debugOverlayStore.ts +19 -15
  47. package/src/hooks.ts +187 -779
  48. package/src/index.ts +37 -6
  49. package/src/reducer.ts +364 -1126
  50. package/src/types.ts +282 -65
  51. package/src/utils.ts +205 -164
  52. package/dist/GlobalDebugOverlay-afs0vF-Q.cjs +0 -1
  53. package/dist/GlobalDebugOverlay.d.ts.map +0 -1
  54. package/dist/Panel.d.ts +0 -7
  55. package/dist/Panel.d.ts.map +0 -1
  56. package/dist/PanelDebugInfo.d.ts +0 -26
  57. package/dist/PanelDebugInfo.d.ts.map +0 -1
  58. package/dist/PanelGroup.d.ts.map +0 -1
  59. package/dist/PanelResizeHandle.d.ts.map +0 -1
  60. package/dist/context.d.ts.map +0 -1
  61. package/dist/debugOverlayStore-Cntyxboe.js +0 -141
  62. package/dist/debugOverlayStore-Dkl-fHoa.cjs +0 -1
  63. package/dist/debugOverlayStore.d.ts.map +0 -1
  64. package/dist/hooks.d.ts +0 -45
  65. package/dist/hooks.d.ts.map +0 -1
  66. package/dist/index-BzgId8aQ.js +0 -1972
  67. package/dist/index-xTV-c0PN.cjs +0 -2
  68. package/dist/index.d.ts.map +0 -1
  69. package/dist/reducer.d.ts +0 -39
  70. package/dist/reducer.d.ts.map +0 -1
  71. package/dist/roundHalfToEven.d.ts.map +0 -1
  72. package/dist/types.d.ts +0 -253
  73. package/dist/types.d.ts.map +0 -1
  74. package/dist/utils/simple-logger.d.ts.map +0 -1
  75. package/dist/utils.d.ts +0 -61
  76. package/dist/utils.d.ts.map +0 -1
  77. /package/dist/{GlobalDebugOverlay.d.ts → src/GlobalDebugOverlay.d.ts} +0 -0
  78. /package/dist/{PanelGroup.d.ts → src/PanelGroup.d.ts} +0 -0
  79. /package/dist/{context.d.ts → src/context.d.ts} +0 -0
  80. /package/dist/{roundHalfToEven.d.ts → src/roundHalfToEven.d.ts} +0 -0
  81. /package/dist/{utils → src/utils}/simple-logger.d.ts +0 -0
package/src/Panel.tsx CHANGED
@@ -3,609 +3,272 @@
3
3
  * カスタムリサイズ可能パネルのための Panel コンポーネント
4
4
  */
5
5
 
6
- import { type CSSProperties, type MutableRefObject, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
6
+ import { type CSSProperties, memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef } from "react"
7
7
  import { twMerge } from "tailwind-merge"
8
+ import { resolvePanelBounds } from "./allocateLayout"
8
9
  import { usePanelGroup } from "./context"
9
10
  import { PanelDebugInfo } from "./PanelDebugInfo"
10
- import type { CollapsibleConfig, PanelCollapseDirection, PanelProps } from "./types"
11
- import { calculateSnapThreshold, getContainerSize, normalizeSizeConfig } from "./utils"
11
+ import { type CollapsibleConfig, PANEL_ALLOCATION_EPSILON, type PanelLayoutData, type PanelProps, type PanelRegistration, type SizeConfig } from "./types"
12
+ import { getContainerSize, normalizeSizeConfig, toRoundedPercentage } from "./utils"
12
13
  import { Logger, LogLevel } from "./utils/simple-logger"
13
14
 
14
15
  const logger = new Logger(LogLevel.INFO, "[resize-panels]")
15
16
 
16
- type NormalizedCollapsibleConfig = {
17
+ /**
18
+ * Directional collapse flags derived from the collapsible configuration.
19
+ * 折りたたみ設定から導出した方向フラグ。
20
+ */
21
+ type CollapseFlags = {
17
22
  collapseFromStart: boolean
18
23
  collapseFromEnd: boolean
19
24
  }
20
25
 
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
26
+ const COLLAPSE_FLAGS: Record<CollapsibleConfig["from"], CollapseFlags> = {
27
+ both: { collapseFromStart: true, collapseFromEnd: true },
28
+ start: { collapseFromStart: true, collapseFromEnd: false },
29
+ end: { collapseFromStart: false, collapseFromEnd: true },
30
+ }
35
31
 
36
32
  /**
37
33
  * Normalize collapsible configuration into directional flags.
38
- * 折りたたみ設定を方向フラグへ正規化。
34
+ * 折りたたみ設定を方向フラグへ正規化する処理。
35
+ *
36
+ * @param config - Collapsible configuration from props / props で指定された折りたたみ設定
37
+ * @returns Directional collapse flags / 方向ごとの折りたたみ可否
38
+ * @throws When the configuration names an unsupported direction / 未対応の方向が指定された場合
39
39
  */
40
- const normalizeCollapsibleConfig = (config: CollapsibleConfig | undefined): NormalizedCollapsibleConfig => {
40
+ const normalizeCollapsibleConfig = (config: CollapsibleConfig | undefined): CollapseFlags => {
41
41
  if (!config) {
42
- return {
43
- collapseFromStart: false,
44
- collapseFromEnd: false,
45
- }
42
+ return { collapseFromStart: false, collapseFromEnd: false }
46
43
  }
47
- const result = collapsibleMatrix[config.from]
48
- if (!result) {
44
+ const flags = COLLAPSE_FLAGS[config.from]
45
+ if (!flags) {
49
46
  throw new Error("Invalid collapsible configuration: unsupported 'from' value")
50
47
  }
51
- return result
48
+ return flags
52
49
  }
53
50
 
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>
51
+ /**
52
+ * Split a normalized default size into the preference fields of the layout state.
53
+ * 正規化済みの既定サイズを、レイアウト状態の希望値フィールドへ分解する処理。
54
+ *
55
+ * Exactly one of the two preferences carries meaning, selected by the unit, so the state never holds the
56
+ * same intent twice.
57
+ * 意味を持つ希望値は単位によって選ばれる 1 つだけであり、状態が同じ意図を二重に持つことはない。
58
+ *
59
+ * @param defaultSize - Normalized default size / 正規化済みの既定サイズ
60
+ * @returns Size unit and both preference fields / サイズ単位と 2 つの希望値フィールド
61
+ */
62
+ const toPreference = (defaultSize: SizeConfig): Pick<PanelRegistration, "sizeUnit" | "preferredPercentageSize" | "preferredPixelSize"> => {
63
+ const value = Math.max(0, defaultSize.value)
64
+ return defaultSize.unit === "pixels" ? { sizeUnit: "pixels", preferredPixelSize: value, preferredPercentageSize: 0 } : { sizeUnit: "percentage", preferredPercentageSize: value, preferredPixelSize: 0 }
60
65
  }
61
66
 
62
67
  /**
63
- * Create a setter that commits numeric size while preventing redundant updates.
64
- * 冗長な更新を避けつつ数値サイズをコミットするセッターを生成。
68
+ * Flex triple a panel renders: the allocated length plus the weights that absorb a container change.
69
+ * パネルが描画する flex の三つ組: 配分された長さと、コンテナ変化を吸収する重み。
65
70
  */
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
- }
71
+ type PanelFlexStyle = Required<Pick<CSSProperties, "flexBasis" | "flexGrow" | "flexShrink">>
72
+
73
+ type ResolvePanelFlexArgs = {
74
+ size: number
75
+ panel: PanelLayoutData | undefined
76
+ registration: PanelRegistration
77
+ containerSize: number
78
+ isCollapsed: boolean
79
+ }
80
+
81
+ /**
82
+ * Resolve the flex triple that renders a panel's current state.
83
+ * パネルの現在の状態を描画する flex の三つ組を解決する処理。
84
+ *
85
+ * The basis is always the allocated length, so the rendered geometry is the allocator's answer and nothing
86
+ * else. The weights are the first-order continuation of that answer: a panel grows with its ratio weight
87
+ * until it reaches its maximum and shrinks only while it is strictly between its bounds, which is exactly
88
+ * how the allocator would move it for a small container change.
89
+ * basis は常に配分された長さであり、描画される幾何は配分器の答えそのものとなる。重みはその答えの
90
+ * 一次近似であり、上限に達するまでは比率の重みで伸び、上下限の内側にいる間だけ縮む。これは小さな
91
+ * コンテナ変化に対して配分器が動かす量と一致する。
92
+ *
93
+ * @param args - Allocated size, panel state, registration and container length / 配分サイズ・パネル状態・登録情報・コンテナ長
94
+ * @returns Flex basis, grow and shrink to render / 描画する flex basis・grow・shrink
95
+ */
96
+ export const resolvePanelFlex = ({ size, panel, registration, containerSize, isCollapsed }: ResolvePanelFlexArgs): PanelFlexStyle => {
97
+ if (isCollapsed) {
98
+ return { flexBasis: "0px", flexGrow: 0, flexShrink: 0 }
99
+ }
87
100
 
88
- // 閾値を超える変化があった場合は待機中のタイマーを解除し、即座に反映
89
- if (timerRef?.current) {
90
- clearTimeout(timerRef.current)
91
- timerRef.current = null
101
+ if (!panel || containerSize <= 0) {
102
+ // まだ計測できていない間は宣言された既定サイズで描く。サーバー描画や初回フレームで
103
+ // 幅ゼロのパネルが一瞬見えるのを防ぎ、計測後の配分結果と連続した見た目になる
104
+ const isPixelRegistration = registration.sizeUnit === "pixels"
105
+ return {
106
+ flexBasis: isPixelRegistration ? `${registration.preferredPixelSize}px` : `${registration.preferredPercentageSize}%`,
107
+ flexGrow: isPixelRegistration ? 0 : registration.preferredPercentageSize,
108
+ flexShrink: isPixelRegistration ? 0 : 1,
92
109
  }
110
+ }
93
111
 
94
- ref.current = sanitized
95
- logger.debug(`commit ${label}`, {
96
- panelId: panelIdRef.current,
97
- [label]: sanitized,
98
- })
99
- setState(sanitized)
112
+ if (panel.sizeUnit === "pixels") {
113
+ // 固定長パネルはコンテナ変化を引き受けない。引き受けると配分器の答えと描画がずれる
114
+ return { flexBasis: `${size}px`, flexGrow: 0, flexShrink: 0 }
100
115
  }
116
+
117
+ // 内側にいるパネルの縮小重みが一律 1 で正しいのは、CSS の縮小配分が「重み × basis」であり
118
+ // basis が配分結果 (= λ × 重み) だからで、結果として重みの比で縮むため
119
+ const bounds = resolvePanelBounds(panel, containerSize)
120
+ const canGrow = size < bounds.max - PANEL_ALLOCATION_EPSILON
121
+ const isInterior = canGrow && size > bounds.min + PANEL_ALLOCATION_EPSILON
122
+ return { flexBasis: `${size}px`, flexGrow: canGrow ? panel.preferredPercentageSize : 0, flexShrink: isInterior ? 1 : 0 }
101
123
  }
102
124
 
103
125
  /**
104
- * Render a resizable panel within a managed group, keeping DOM measurements in sync with reducer state.
105
- * 管理対象グループ内で DOM 計測とリデューサー状態を同期させながらリサイズ可能パネルを描画するコンポーネント。
126
+ * Render a resizable panel whose size is decided by the group's allocator.
127
+ * グループの配分器が決めたサイズで描画されるリサイズ可能パネル。
128
+ *
129
+ * The panel publishes what it wants (its preference and constraints) and renders what it was given. Its
130
+ * flex basis is the allocated length itself, while `flex-grow` / `flex-shrink` describe how it should
131
+ * absorb a container change during the frames before the allocator runs again — a growth weight that
132
+ * vanishes at the panel's maximum and a shrink weight that vanishes at either bound, which is exactly how
133
+ * the allocator would move this panel for a small change.
134
+ * パネルは希望と制約を公開し、与えられたサイズで描画する。flex basis は配分結果そのもので、
135
+ * `flex-grow` / `flex-shrink` は配分器が再実行されるまでの数フレームでコンテナ変化をどう吸収するかを表す
136
+ * (上限で消える成長重みと、上下限のいずれかで消える縮小重み)。これは小さな変化に対して配分器が動かす量と一致する。
106
137
  */
107
138
  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()
139
+ ({ id: providedId, defaultSize = { value: 50, unit: "percentage" }, minSize, maxSize, className, style, children, collapsible, defaultCollapsed = false, pixelAdjustPriority, flexAdjustPriority, contentOverflow = "auto", ...elementProps }: PanelProps) => {
140
+ const { direction, registerPanel, unregisterPanel, getPanel, containerSize, showDebugInfo, reportPanelMeasurement } = usePanelGroup()
110
141
  const panelRef = useRef<HTMLDivElement>(null)
111
142
  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)
143
+ const panelIdRef = useRef(providedId || `panel-${generatedId}`)
144
+ const panelId = panelIdRef.current
186
145
 
187
146
  useEffect(() => {
188
- return () => {
189
- if (pixelSyncTimerRef.current) clearTimeout(pixelSyncTimerRef.current)
190
- if (percentageSyncTimerRef.current) clearTimeout(percentageSyncTimerRef.current)
147
+ // id はマウント後に変更できない (登録・DOM 属性・計測が初期 id に固定されるため)
148
+ if (providedId && providedId !== panelId) {
149
+ logger.warn("Panel id prop change after mount is ignored.", { currentId: panelId, attemptedId: providedId })
191
150
  }
192
- }, [])
151
+ }, [providedId, panelId])
193
152
 
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
- }, [])
153
+ const collapseFlags = useMemo(() => normalizeCollapsibleConfig(collapsible), [collapsible])
154
+ const isCollapsible = collapseFlags.collapseFromStart || collapseFlags.collapseFromEnd
240
155
 
241
156
  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
157
  if (defaultCollapsed && !isCollapsible) {
269
- logger.warn("Panel defaultCollapsed is ignored because collapsible configuration is not provided.")
158
+ logger.warn("Panel defaultCollapsed is ignored because collapsible configuration is not provided.", { panelId })
270
159
  }
160
+ }, [defaultCollapsed, isCollapsible, panelId])
271
161
 
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,
162
+ const registration = useMemo<PanelRegistration>(() => {
163
+ const normalized = normalizeSizeConfig(defaultSize) ?? { value: 50, unit: "percentage" as const }
164
+ return {
165
+ id: panelId,
166
+ ...toPreference(normalized),
287
167
  minSize,
288
168
  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,
169
+ ...collapseFlags,
170
+ collapsed: isCollapsible && defaultCollapsed,
297
171
  pixelAdjustPriority,
298
172
  flexAdjustPriority,
299
- autoMinSize,
300
173
  }
174
+ }, [panelId, defaultSize, minSize, maxSize, collapseFlags, isCollapsible, defaultCollapsed, pixelAdjustPriority, flexAdjustPriority])
301
175
 
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
- ])
176
+ useLayoutEffect(() => {
177
+ registerPanel(registration)
178
+ }, [registerPanel, registration])
321
179
 
322
- useEffect(() => {
323
- // アンマウント時にパネル登録と測定結果をクリア
180
+ useLayoutEffect(() => {
181
+ // 登録と同じフェーズで解除する。パッシブ効果だと、同一コミット内で同じ id が
182
+ // 貼り替わったとき「新しい登録 → 古い解除」の順になり、パネルが状態から消えたまま戻らない
324
183
  return () => {
325
- unregisterPanel(panelId.current)
326
- reportPanelMeasurement(panelId.current, null)
184
+ unregisterPanel(panelId)
327
185
  }
328
- }, [unregisterPanel, reportPanelMeasurement])
186
+ }, [unregisterPanel, panelId])
329
187
 
330
- const currentSize = currentPanel?.size ?? 0
188
+ const panel = getPanel(panelId)
189
+ const size = panel?.size ?? 0
190
+ const isCollapsed = panel?.collapsed ?? false
331
191
 
332
192
  /**
333
- * Measure the DOM and sync local size caches with the latest reducer state.
334
- * DOM を実測し、最新のリデューサー状態とローカルサイズキャッシュを同期する。
335
- * observer から毎フレーム呼ばれるため、リデューサー状態は ref 経由で読み依存を安定させる。
193
+ * Measure the rendered length and report it for the debug overlay.
194
+ * 描画後の実寸を計測し、デバッグオーバーレイへ報告する処理。
336
195
  */
337
- const updateSizes = useCallback(() => {
338
- const panelElement = panelRef.current
339
- if (!panelElement) return
340
-
341
- const latestPanel = currentPanelRef.current
342
- if (!latestPanel) {
196
+ const containerSizeRef = useRef(containerSize)
197
+ containerSizeRef.current = containerSize
198
+ const measure = useCallback(() => {
199
+ const element = panelRef.current
200
+ if (!element) {
343
201
  return
344
202
  }
203
+ const pixelSize = getContainerSize(element, direction).border
204
+ reportPanelMeasurement(panelId, { pixelSize, percentageSize: toRoundedPercentage(pixelSize, containerSizeRef.current) })
205
+ }, [direction, panelId, reportPanelMeasurement])
345
206
 
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
- })
207
+ useEffect(() => {
208
+ // 実測値はデバッグ表示専用であり、レイアウトへは戻さない。
209
+ // 表示していないときに監視すると、全パネルで無用な強制レイアウトを起こす
210
+ if (!showDebugInfo) {
211
+ reportPanelMeasurement(panelId, null)
377
212
  return
378
213
  }
379
-
380
- if (currentPixelSize > 0) {
381
- // 実測ピクセル値でローカルキャッシュを更新
382
- commitPixelSize(currentPixelSize)
383
-
384
- if (parentSize > 0) {
385
- // 親コンテナ比率から割合を再計算
386
- commitPercentageSize((currentPixelSize / parentSize) * 100)
387
- }
214
+ const element = panelRef.current
215
+ if (!element || typeof ResizeObserver === "undefined") {
216
+ measure()
388
217
  return
389
218
  }
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
-
219
+ const observer = new ResizeObserver(measure)
220
+ observer.observe(element)
221
+ measure()
477
222
  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
223
+ observer.disconnect()
224
+ reportPanelMeasurement(panelId, null)
225
+ }
226
+ }, [showDebugInfo, measure, panelId, reportPanelMeasurement])
227
+
228
+ const panelStyle = useMemo<CSSProperties>(() => {
229
+ // 見た目に関わる既定値は利用者が上書きできる。
230
+ // `isolation` はパネル内部の z-index がハンドルより手前に出ないようにするための既定値だが、
231
+ // パネルの外まで覆う overlay (position: fixed の最大化表示など) を置きたい利用者は
232
+ // `isolation: "auto"` で降りられる (その場合、内容がハンドルを覆いうることを引き受ける)
233
+ const presentation: CSSProperties = { overflow: "hidden", isolation: "isolate" }
234
+ const collapsed: CSSProperties = isCollapsed ? { ...(direction === "horizontal" ? { width: "0px" } : { height: "0px" }), pointerEvents: "none", visibility: "hidden", display: "none" } : {}
235
+
236
+ return {
237
+ ...presentation,
238
+ ...style,
239
+ // ここから下はレイアウトの前提そのもの。利用者のスタイルやクラスで変わると配分結果と
240
+ // 描画が一致しなくなるため、最後に適用して上書きを受け付けない:
241
+ // flex の三つ組は配分器が決めた長さそのもの、margin はパネルの合計に含まれない隙間を作り、
242
+ // order DOM 順と視覚順をずらす
243
+ ...resolvePanelFlex({ size, panel, registration, containerSize, isCollapsed }),
244
+ order: 0,
245
+ margin: 0,
246
+ // 内容を通常フローで内容ボックスいっぱいに伸ばすための入れ物 (パディングを効かせる唯一の形)
247
+ display: "flex",
248
+ flexDirection: "column",
249
+ position: "relative",
250
+ boxSizing: "border-box",
251
+ // 主軸の縮小をコンテンツの固有幅に妨げられないようにし、
252
+ // 上限側のクランプ (max-w-* のようなユーティリティクラスを含む) も受け付けない。
253
+ // 配分器が配った長さより短く描くと、状態と描画が食い違ったままハンドルが動かなくなる
254
+ minWidth: 0,
255
+ minHeight: 0,
256
+ maxWidth: "none",
257
+ maxHeight: "none",
258
+ ...(direction === "horizontal" ? { height: "100%" } : { width: "100%" }),
259
+ ...collapsed,
260
+ }
261
+ }, [size, direction, isCollapsed, panel, registration, containerSize, style])
598
262
 
599
- // 実際の子要素をパネル内に固定しスクロールも許可するためのラッパースタイル
600
263
  const contentWrapperStyle = useMemo<CSSProperties>(
601
264
  () => ({
602
- position: "absolute",
603
- inset: "0px",
265
+ // 内容は通常フローの伸縮子として置く。絶対配置にするとパディングボックスを基準にしてしまい、
266
+ // 利用者がパネルへ与えたパディングが何の効果も持たなくなる
267
+ flex: "1 1 auto",
268
+ position: "relative",
604
269
  overflow: contentOverflow,
605
270
  display: "flex",
606
271
  flexDirection: "column",
607
- width: "100%",
608
- height: "100%",
609
272
  minWidth: "0px",
610
273
  minHeight: "0px",
611
274
  }),
@@ -614,21 +277,20 @@ export const Panel = memo(
614
277
 
615
278
  return (
616
279
  <div
280
+ {...elementProps}
617
281
  ref={panelRef}
618
282
  className={twMerge("panel", className)}
619
283
  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}
284
+ data-panel-id={panelId}
285
+ data-panel-size={size}
286
+ data-panel-size-unit={panel?.sizeUnit ?? "percentage"}
625
287
  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}>
288
+ data-panel-collapse-start={collapseFlags.collapseFromStart}
289
+ data-panel-collapse-end={collapseFlags.collapseFromEnd}
290
+ data-panel-collapsed={isCollapsed}
291
+ aria-hidden={isCollapsed}>
630
292
  <div style={contentWrapperStyle}>{children}</div>
631
- {shouldShowDebugInfo && <PanelDebugInfo panel={currentPanel} measuredPixelSize={pixelSize} measuredPercentageSize={percentageSize} containerAxisSize={containerAxisSize} direction={direction} />}
293
+ {showDebugInfo && <PanelDebugInfo panel={panel} containerAxisSize={containerSize} direction={direction} />}
632
294
  </div>
633
295
  )
634
296
  },