@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/reducer.ts CHANGED
@@ -1,1234 +1,472 @@
1
1
  /**
2
- * @file Reducer for managing panel state in PanelGroup
3
- * PanelGroup のパネル状態を管理するための Reducer
2
+ * @file Panel group reducer: the only writer of panel sizes.
3
+ * パネルグループのリデューサー: パネルサイズを書き込む唯一の主体。
4
+ *
5
+ * Every action edits configuration or preferences and then runs the same allocator, so the first paint, a
6
+ * container resize, a drag, a collapse and a restored layout all produce sizes from one function of
7
+ * (preferences, constraints, container length).
8
+ * すべてのアクションは構成か希望値を編集したうえで同一の配分器を実行する。したがって初回描画・コンテナリサイズ・
9
+ * ドラッグ・折りたたみ・レイアウト復元のいずれも、(希望値, 制約, コンテナ長) の同一関数からサイズを得る。
4
10
  */
5
- import { roundHalfToEven } from "./roundHalfToEven"
6
- import type { PanelCollapseDirection, PanelLayoutData } from "./types"
7
- import { adjustPixelPanelsByPriority, clamp, getConstraintInPixels, PERCENTAGE_DIGITS, percentageToPixels, toRoundedPercentage } from "./utils"
11
+
12
+ import { allocatePanelSizes, isPixelPanel, resolvePanelBounds } from "./allocateLayout"
13
+ import { type LayoutConstraintViolation, PANEL_ALLOCATION_EPSILON, type PanelAction, type PanelCollapseDirection, type PanelGroupLayoutState, type PanelLayoutData, type PanelRegistration } from "./types"
14
+ import { calculateSnapThreshold, clamp } from "./utils"
8
15
  import { Logger, LogLevel } from "./utils/simple-logger"
9
16
 
10
17
  const logger = new Logger(LogLevel.INFO, "[resize-panels]")
11
18
 
12
- // px 値同士の比較で浮動小数点の桁落ち (数 ulp 程度) を許容する誤差。
13
- // 厳密比較だと T - (T - min) min 1ulp 下回っただけで境界へのリサイズが棄却され、
14
- // 小数コンテナ幅では最小サイズに到達できなくなる。
15
- const SIZE_EPSILON = 1e-9
16
-
17
- const snapshotPanelState = (panels: PanelLayoutData[]) =>
18
- panels.map((panel) => ({
19
- id: panel.id,
20
- size: panel.size,
21
- percentageSize: panel.percentageSize,
22
- preferredPercentageSize: panel.preferredPercentageSize,
23
- sizeBeforeCollapse: panel.sizeBeforeCollapse,
24
- percentageSizeBeforeCollapse: panel.percentageSizeBeforeCollapse,
25
- preferredPercentageSizeBeforeCollapse: panel.preferredPercentageSizeBeforeCollapse,
26
- measuredPercentageSize: panel.measuredPercentageSize,
27
- excludeFromAutoGrowth: panel.excludeFromAutoGrowth ?? false,
28
- collapsed: panel.collapsed ?? false,
29
- collapseFromStart: panel.collapseFromStart,
30
- collapseFromEnd: panel.collapseFromEnd,
31
- collapsedByDirection: panel.collapsedByDirection ?? null,
32
- }))
33
-
34
- const snapshotSinglePanel = (panel: PanelLayoutData | undefined | null) => {
35
- if (!panel) {
36
- return null
37
- }
38
- return snapshotPanelState([panel])[0]
19
+ /**
20
+ * Empty layout state used before the group has measured itself or registered any panel.
21
+ * グループが自身を計測する前・パネル登録前の空レイアウト状態。
22
+ */
23
+ export const initialPanelGroupLayoutState: PanelGroupLayoutState = {
24
+ containerSize: 0,
25
+ panels: [],
26
+ violation: null,
39
27
  }
40
28
 
41
- const panelCanCollapse = (panel: PanelLayoutData | undefined | null): boolean => {
42
- if (!panel) {
43
- return false
29
+ /**
30
+ * Report whether two violations describe the same situation.
31
+ * 2 つの違反が同じ状況を表すかどうかの判定。
32
+ */
33
+ const isSameViolation = (left: LayoutConstraintViolation | null, right: LayoutConstraintViolation | null): boolean => {
34
+ if (left === null || right === null) {
35
+ return left === right
44
36
  }
45
- return Boolean(panel.collapseFromStart || panel.collapseFromEnd)
37
+ return (
38
+ left.reason === right.reason &&
39
+ Math.abs(left.totalMinimumSize - right.totalMinimumSize) <= PANEL_ALLOCATION_EPSILON &&
40
+ Math.abs(left.totalMaximumSize - right.totalMaximumSize) <= PANEL_ALLOCATION_EPSILON &&
41
+ Math.abs(left.availableContainerSize - right.availableContainerSize) <= PANEL_ALLOCATION_EPSILON
42
+ )
46
43
  }
47
44
 
48
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Layout calculation logic
49
- const recalculateFlexiblePanels = (panels: PanelLayoutData[], containerSize: number): PanelLayoutData[] => {
50
- if (!Number.isFinite(containerSize) || containerSize <= 0) {
51
- return panels
52
- }
53
-
54
- type Descriptor = {
55
- index: number
56
- isPixelPanel: boolean
57
- minSize: number
58
- maxSize: number
59
- preferredSize: number
60
- priority: number
61
- flexPriority?: number
62
- excludeFromGrowth: boolean
63
- }
64
-
65
- const nextPanels = panels.map((panel) => ({ ...panel }))
66
- const descriptors: Descriptor[] = []
45
+ /**
46
+ * Run the allocator over the given panels and fold the result into a layout state.
47
+ * 与えられたパネルへ配分器を実行し、結果をレイアウト状態へ畳み込む処理。
48
+ *
49
+ * Panel objects and the panel array keep their identity when the allocation did not move them, so a transition
50
+ * that changes nothing produces no new state and no render at all. (When something does change, every panel of
51
+ * the group re-renders: they all read the same context value.)
52
+ * 配分でサイズが動かなかったパネルとパネル配列は同一性を保つため、何も変えない遷移は新しい状態を作らず再描画も
53
+ * 起こらない (何かが変われば、同じコンテキスト値を読むグループ内の全パネルが再描画される)。
54
+ *
55
+ * @param panels - Panels in DOM order with up-to-date preferences / 希望値が最新の DOM 順パネル一覧
56
+ * @param containerSize - Container content-box length in pixels / コンテナ内容領域長 (px)
57
+ * @returns Layout state whose sizes satisfy the allocator / 配分結果を反映したレイアウト状態
58
+ */
59
+ const allocate = (panels: PanelLayoutData[], containerSize: number): PanelGroupLayoutState => {
60
+ const { sizes, violation } = allocatePanelSizes(panels, containerSize)
67
61
 
68
- for (let index = 0; index < nextPanels.length; index += 1) {
69
- const panel = nextPanels[index]
70
- if (panel.collapsed) {
71
- nextPanels[index] = {
72
- ...panel,
73
- size: 0,
74
- percentageSize: 0,
75
- }
76
- continue
62
+ let changed = false
63
+ const nextPanels = panels.map((panel, index) => {
64
+ const size = panel.collapsed ? 0 : sizes[index]
65
+ if (Math.abs(panel.size - size) <= PANEL_ALLOCATION_EPSILON) {
66
+ return panel
77
67
  }
68
+ changed = true
69
+ return { ...panel, size }
70
+ })
78
71
 
79
- const minFromConstraint = Math.max(0, getConstraintInPixels(panel.minSize, 0, containerSize))
80
- const autoMinSize = Math.max(0, getConstraintInPixels(panel.autoMinSize, 0, containerSize))
81
- const minSize = Math.max(minFromConstraint, autoMinSize)
82
- const zeroThreshold = Math.max(1e-3, minSize)
83
- const rawMaxSize = getConstraintInPixels(panel.maxSize, containerSize, containerSize)
84
- const maxSize = Math.max(minSize, rawMaxSize)
85
- const isPixelPanel = panel.sizeUnit === "pixels" || panel.originalPixelSize !== undefined
86
- const currentSize = Math.max(0, panel.size)
87
- let preferredSize = minSize
72
+ const next = { containerSize, panels: changed ? nextPanels : panels, violation }
73
+ reportInvariantBreach(next)
74
+ return next
75
+ }
88
76
 
89
- if (isPixelPanel) {
90
- const preferredPixel = panel.preferredPixelSize ?? panel.size ?? panel.originalPixelSize ?? minSize
91
- preferredSize = clamp(preferredPixel, minSize, maxSize)
92
- } else {
93
- const fallbackPercentage = panel.preferredPercentageSize ?? panel.percentageSize ?? toRoundedPercentage(panel.size, containerSize)
94
- const preferredPixels = percentageToPixels(fallbackPercentage, containerSize)
95
- preferredSize = clamp(preferredPixels, minSize, maxSize)
77
+ /**
78
+ * Find the layout invariant breach in a state, if there is one.
79
+ * レイアウト状態に不変条件の違反があれば、その内容を返す処理。
80
+ *
81
+ * The condition is the one the whole package rests on: with a measured container and no reported constraint
82
+ * conflict, the active panels fill it exactly. The misaligned handle this package once shipped was a silent
83
+ * violation of exactly this — the sizes stopped filling the container and nothing noticed.
84
+ * ここで見る条件はパッケージ全体の土台である: コンテナが計測済みで制約矛盾も報告されていないなら、
85
+ * アクティブなパネルはコンテナをちょうど満たす。かつて出荷された「ハンドルがずれる」欠陥は、まさにこの条件が
86
+ * 無言で破れた状態だった。
87
+ *
88
+ * @param state - Layout state to check / 検査するレイアウト状態
89
+ * @returns Allocated total and container length when they disagree, otherwise null / 食い違う場合の合計と
90
+ * コンテナ長、問題なければ null
91
+ */
92
+ export const findLayoutInvariantBreach = (state: PanelGroupLayoutState): { allocatedTotal: number; containerSize: number } | null => {
93
+ if (state.containerSize <= 0 || state.violation !== null) {
94
+ return null
95
+ }
96
+ let total = 0
97
+ let activeCount = 0
98
+ for (const panel of state.panels) {
99
+ if (!panel.collapsed) {
100
+ total += panel.size
101
+ activeCount += 1
96
102
  }
97
-
98
- const excludeFromGrowth = !isPixelPanel && (panel.excludeFromAutoGrowth ?? false) && currentSize <= zeroThreshold
99
- descriptors.push({
100
- index,
101
- isPixelPanel,
102
- minSize,
103
- maxSize,
104
- preferredSize,
105
- priority: panel.pixelAdjustPriority ?? index + 1,
106
- flexPriority: !isPixelPanel ? panel.flexAdjustPriority : undefined,
107
- excludeFromGrowth,
108
- })
109
103
  }
110
-
111
- if (descriptors.length === 0) {
112
- return nextPanels
104
+ // 全パネルが折りたたまれたグループは「満たすべきパネルが無い」正当な状態
105
+ if (activeCount === 0) {
106
+ return null
113
107
  }
114
-
115
- const assignments = new Map<number, number>()
116
- let baseTotal = 0
117
- for (const descriptor of descriptors) {
118
- assignments.set(descriptor.index, descriptor.minSize)
119
- baseTotal += descriptor.minSize
108
+ const tolerance = PANEL_ALLOCATION_EPSILON * Math.max(1, state.containerSize)
109
+ if (!Number.isFinite(total) || Math.abs(total - state.containerSize) > tolerance) {
110
+ return { allocatedTotal: total, containerSize: state.containerSize }
120
111
  }
112
+ return null
113
+ }
121
114
 
122
- let remaining = containerSize - baseTotal
123
-
124
- const allocate = (descriptor: Descriptor, amount: number): number => {
125
- if (amount <= 0) {
126
- return 0
127
- }
128
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
129
- const capacity = Math.max(0, descriptor.maxSize - currentSize)
130
- if (capacity <= 0) {
131
- return 0
132
- }
133
- const applied = Math.min(amount, capacity)
134
- assignments.set(descriptor.index, currentSize + applied)
135
- return applied
115
+ /**
116
+ * Report a layout invariant breach, which can only come from a defect in this package.
117
+ * レイアウト不変条件の違反を報告する処理 (違反はこのパッケージの欠陥でしか起こり得ない)
118
+ *
119
+ * The check is O(panels) with no allocation, so it runs on every transition rather than behind a flag.
120
+ * 検査はパネル数に比例するだけで確保も伴わないため、フラグに隠さずすべての遷移で実行する。
121
+ *
122
+ * @param state - Layout state produced by the allocator / 配分器が生成したレイアウト状態
123
+ */
124
+ const reportInvariantBreach = (state: PanelGroupLayoutState): void => {
125
+ const breach = findLayoutInvariantBreach(state)
126
+ if (breach) {
127
+ logger.error("Allocated sizes do not fill the container.", {
128
+ ...breach,
129
+ panels: state.panels.map((panel) => ({ id: panel.id, size: panel.size, collapsed: panel.collapsed })),
130
+ })
136
131
  }
132
+ }
137
133
 
138
- if (remaining > 0) {
139
- const distributeFlexibleByRatio = (targetDescriptors: Descriptor[]) => {
140
- const epsilon = 1e-6
141
- while (remaining > epsilon) {
142
- let totalWeight = 0
143
- const weights: Array<{ descriptor: Descriptor; capacity: number }> = []
144
- for (const descriptor of targetDescriptors) {
145
- if (!descriptor.isPixelPanel && descriptor.excludeFromGrowth) {
146
- continue
147
- }
148
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
149
- const targetSize = Math.min(descriptor.preferredSize, descriptor.maxSize)
150
- const capacity = Math.max(0, targetSize - currentSize)
151
- if (capacity > epsilon) {
152
- totalWeight += capacity
153
- weights.push({ descriptor, capacity })
154
- }
155
- }
156
- if (weights.length === 0 || totalWeight <= epsilon) {
157
- break
158
- }
159
-
160
- let progress = 0
161
- const iterationRemaining = remaining
162
- for (const entry of weights) {
163
- if (remaining <= epsilon) {
164
- break
165
- }
166
- if (!entry.descriptor.isPixelPanel && entry.descriptor.excludeFromGrowth) {
167
- continue
168
- }
169
- const share = (iterationRemaining * entry.capacity) / totalWeight
170
- const amount = Math.min(entry.capacity, share)
171
- if (amount <= 0) {
172
- continue
173
- }
174
- const applied = allocate(entry.descriptor, amount)
175
- if (applied > 0) {
176
- remaining -= applied
177
- progress += applied
178
- }
179
- }
180
-
181
- if (progress <= epsilon) {
182
- break
183
- }
184
- }
185
- }
186
-
187
- const pixelDescriptors = descriptors.filter((descriptor) => descriptor.isPixelPanel)
188
- pixelDescriptors.sort((a, b) => b.priority - a.priority)
189
- for (const descriptor of pixelDescriptors) {
190
- if (remaining <= 0) {
191
- break
192
- }
193
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
194
- const targetSize = Math.min(descriptor.preferredSize, descriptor.maxSize)
195
- const needed = Math.max(0, targetSize - currentSize)
196
- if (needed <= 0) {
197
- continue
198
- }
199
- const applied = allocate(descriptor, Math.min(needed, remaining))
200
- if (applied > 0) {
201
- remaining -= applied
202
- }
203
- }
204
-
205
- if (remaining > 0) {
206
- const flexibleDescriptors = descriptors.filter((descriptor) => !descriptor.isPixelPanel)
207
- if (flexibleDescriptors.length > 0) {
208
- const prioritizedDescriptors = flexibleDescriptors.filter((descriptor) => descriptor.flexPriority !== undefined && !descriptor.excludeFromGrowth)
209
- if (prioritizedDescriptors.length > 0) {
210
- prioritizedDescriptors.sort((a, b) => (b.flexPriority ?? 0) - (a.flexPriority ?? 0))
211
- for (const descriptor of prioritizedDescriptors) {
212
- if (remaining <= 0) {
213
- break
214
- }
215
- if (descriptor.excludeFromGrowth) {
216
- continue
217
- }
218
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
219
- const targetSize = Math.min(descriptor.preferredSize, descriptor.maxSize)
220
- const needed = Math.max(0, targetSize - currentSize)
221
- if (needed <= 0) {
222
- continue
223
- }
224
- const applied = allocate(descriptor, Math.min(needed, remaining))
225
- if (applied > 0) {
226
- remaining -= applied
227
- }
228
- }
229
- }
230
-
231
- if (remaining > 0) {
232
- const ratioTargets = prioritizedDescriptors.length > 0 ? flexibleDescriptors.filter((descriptor) => descriptor.flexPriority === undefined && !descriptor.excludeFromGrowth) : flexibleDescriptors.filter((descriptor) => !descriptor.excludeFromGrowth)
233
- if (ratioTargets.length > 0) {
234
- distributeFlexibleByRatio(ratioTargets)
235
- }
236
- }
237
- }
238
- }
134
+ /**
135
+ * Fold a freshly allocated state into the previous one, preserving identity where nothing moved.
136
+ * 新たに配分した状態を直前の状態へ畳み込み、変化が無い部分の同一性を保つ処理。
137
+ */
138
+ const commit = (previous: PanelGroupLayoutState, next: PanelGroupLayoutState): PanelGroupLayoutState => {
139
+ if (previous.panels === next.panels && previous.containerSize === next.containerSize && isSameViolation(previous.violation, next.violation)) {
140
+ return previous
239
141
  }
240
-
241
- if (remaining > 0) {
242
- let previousRemaining = remaining
243
- for (;;) {
244
- let totalCapacity = 0
245
- const candidates: Descriptor[] = []
246
- for (const descriptor of descriptors) {
247
- if (!descriptor.isPixelPanel && descriptor.excludeFromGrowth) {
248
- continue
249
- }
250
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
251
- const descriptorMax = descriptor.isPixelPanel ? Math.min(descriptor.maxSize, descriptor.preferredSize) : descriptor.maxSize
252
- const capacity = Math.max(0, descriptorMax - currentSize)
253
- if (capacity > 0) {
254
- candidates.push(descriptor)
255
- totalCapacity += capacity
256
- }
257
- }
258
- if (totalCapacity <= 0) {
259
- break
260
- }
261
- const iterationRemaining = remaining
262
- for (const descriptor of candidates) {
263
- if (remaining <= 0) {
264
- break
265
- }
266
- if (!descriptor.isPixelPanel && descriptor.excludeFromGrowth) {
267
- continue
268
- }
269
- const currentSize = assignments.get(descriptor.index) ?? descriptor.minSize
270
- const descriptorMax = descriptor.isPixelPanel ? Math.min(descriptor.maxSize, descriptor.preferredSize) : descriptor.maxSize
271
- const capacity = Math.max(0, descriptorMax - currentSize)
272
- if (capacity <= 0) {
273
- continue
274
- }
275
- const share = (iterationRemaining * capacity) / totalCapacity
276
- const applied = allocate(descriptor, Math.min(capacity, share))
277
- if (applied > 0) {
278
- remaining -= applied
279
- }
280
- }
281
- if (remaining <= 1e-6 || Math.abs(previousRemaining - remaining) <= 1e-6) {
282
- break
283
- }
284
- previousRemaining = remaining
285
- }
286
- remaining = Math.max(0, remaining)
142
+ return {
143
+ containerSize: next.containerSize,
144
+ panels: next.panels,
145
+ violation: isSameViolation(previous.violation, next.violation) ? previous.violation : next.violation,
287
146
  }
147
+ }
288
148
 
289
- // remaining < 0 (最小サイズ合計がコンテナを超える不能構成) は意図的にここで縮小しない。
290
- // 配分は各パネルの minSize を下限として維持し、不能状態は hooks 側の layoutConstraintViolation
291
- // 検出・通知してハンドル無効化とプレースホルダー表示で対処する。
292
- // (かつて存在した縮小ループは、割当が常に minSize 以上・縮小下限も minSize のため一切動作しないデッドコードだった)
293
-
294
- for (const descriptor of descriptors) {
295
- const assignedSize = assignments.get(descriptor.index) ?? descriptor.minSize
296
- const panel = nextPanels[descriptor.index]
297
- const panelIsCollapsible = panelCanCollapse(panel)
298
- const nextSizeBeforeCollapse = panelIsCollapsible ? assignedSize : panel.sizeBeforeCollapse
299
- const nextMeasuredBeforeCollapse = panelIsCollapsible ? (panel.measuredPixelSizeBeforeCollapse ?? panel.measuredPixelSize ?? assignedSize) : panel.measuredPixelSizeBeforeCollapse
300
- const nextPercentage = containerSize > 0 ? toRoundedPercentage(assignedSize, containerSize) : 0
301
-
302
- nextPanels[descriptor.index] = {
303
- ...panel,
304
- size: assignedSize,
305
- percentageSize: nextPercentage,
306
- preferredPercentageSize: panel.preferredPercentageSize,
307
- preferredPixelSize: descriptor.isPixelPanel ? (panel.preferredPixelSize ?? assignedSize) : panel.preferredPixelSize,
308
- sizeBeforeCollapse: nextSizeBeforeCollapse,
309
- measuredPixelSizeBeforeCollapse: nextMeasuredBeforeCollapse,
310
- measuredPixelSize: undefined,
311
- measuredPercentageSize: undefined,
312
- }
149
+ /**
150
+ * Create the layout data of a newly registered panel.
151
+ * 新規登録パネルのレイアウトデータを生成する処理。
152
+ */
153
+ const createPanel = (registration: PanelRegistration, defaultCollapsedFrom: PanelCollapseDirection | null): PanelLayoutData => {
154
+ const preference = registration.sizeUnit === "pixels" ? registration.preferredPixelSize : registration.preferredPercentageSize
155
+ return {
156
+ ...registration,
157
+ size: 0,
158
+ collapsed: registration.collapsed,
159
+ collapsedByDirection: registration.collapsed ? defaultCollapsedFrom : null,
160
+ preferenceBeforeCollapse: preference,
161
+ authoredPreference: preference,
313
162
  }
314
-
315
- return normalizePanelPercentages(nextPanels)
316
163
  }
317
164
 
318
165
  /**
319
- * Normalize panel percentage snapshots so active panels sum to 100%.
320
- * アクティブなパネルの割合が 100% になるようにスナップショットを正規化する処理。
166
+ * Resolve the direction a panel collapses to when it starts collapsed.
167
+ * 初期状態で折りたたまれているパネルの折りたたみ方向を解決する処理。
321
168
  */
322
- const normalizePanelPercentages = (panels: PanelLayoutData[]): PanelLayoutData[] => {
323
- let totalActiveSize = 0
324
- for (const panel of panels) {
325
- if (panel.collapsed) {
326
- continue
327
- }
328
- totalActiveSize += Math.max(panel.size, 0)
169
+ const initialCollapseDirection = (registration: Pick<PanelRegistration, "collapseFromStart" | "collapseFromEnd">): PanelCollapseDirection | null => {
170
+ if (registration.collapseFromStart) {
171
+ return "start"
329
172
  }
330
-
331
- // アクティブなパネルサイズの合計がゼロの場合はすべての割合をゼロにリセット
332
- if (totalActiveSize <= 0) {
333
- let mutated = false
334
- const normalizedPanels = panels.map((panel) => {
335
- if ((panel.percentageSize ?? 0) !== 0) {
336
- mutated = true
337
- // 折りたたみ時は割合をゼロに初期化して整合性を保つ
338
- return {
339
- ...panel,
340
- percentageSize: 0,
341
- measuredPercentageSize: panel.measuredPercentageSize !== undefined ? 0 : panel.measuredPercentageSize,
342
- }
343
- }
344
- if (!panel.collapsed && panel.sizeBeforeCollapse !== panel.size) {
345
- mutated = true
346
- // 折りたたみ前のサイズがずれている場合は現在サイズで上書きする
347
- return {
348
- ...panel,
349
- sizeBeforeCollapse: panel.size,
350
- }
351
- }
352
- return panel
353
- })
354
- return mutated ? normalizedPanels : panels
173
+ if (registration.collapseFromEnd) {
174
+ return "end"
355
175
  }
176
+ return null
177
+ }
356
178
 
357
- // パネル配列に変更が発生したかを示すフラグ
358
- let mutated = false
359
-
360
- const normalizedPanels = panels.map((panel) => {
361
- // 折りたたまれたパネルの割合はゼロに固定
179
+ /**
180
+ * Rewrite every active panel's preference so that it reproduces the sizes it currently has.
181
+ * アクティブな各パネルの希望値を、現在のサイズを再現する値へ書き換える処理。
182
+ *
183
+ * A drag is a statement about proportions, so the sizes it produced become the new preferences. Because a
184
+ * flexible panel's weight is then exactly `size / C × 100`, re-running the allocator returns the very same
185
+ * sizes: the layout the user released the pointer on is a fixed point.
186
+ * ドラッグは比率に対する利用者の意思表示なので、その結果のサイズを新たな希望値とする。柔軟パネルの重みは
187
+ * `size / C × 100` そのものになるため、配分器を再実行しても同じサイズが返る (ドラッグ結果は不動点)。
188
+ */
189
+ const commitPreferences = (panels: PanelLayoutData[], containerSize: number): PanelLayoutData[] => {
190
+ if (containerSize <= 0) {
191
+ return panels
192
+ }
193
+ // 端まで引いて折りたたむ操作では、閉じる直前のフレームの寸法が復元用に残ると
194
+ // 展開しても潰れたままになる。復元用の希望値は「閉じたとみなす幅より広い間」だけ更新する
195
+ const snapThreshold = calculateSnapThreshold(containerSize)
196
+ return panels.map((panel) => {
362
197
  if (panel.collapsed) {
363
- if ((panel.percentageSize ?? 0) !== 0) {
364
- mutated = true
365
- return {
366
- ...panel,
367
- percentageSize: 0,
368
- measuredPercentageSize: panel.measuredPercentageSize !== undefined ? 0 : panel.measuredPercentageSize,
369
- }
370
- }
371
198
  return panel
372
199
  }
373
-
374
- // size から計算した割合
375
- const calculatedPercentage = toRoundedPercentage(panel.size, totalActiveSize)
376
-
377
- // 測定済み割合があり、size から計算した割合と近い場合は測定値を優先
378
- // 乖離が大きい場合は size から計算した値を使用(RESIZE_PANELS 直後など)
379
- const measuredPercentage = panel.measuredPercentageSize
380
- const percentageDivergence = measuredPercentage !== undefined ? Math.abs(measuredPercentage - calculatedPercentage) : Infinity
381
- const useMeasuredPercentage = percentageDivergence < 0.0001 // 0.01% 未満の乖離なら測定値を優先
382
-
383
- // 判定結果に基づき割合の基準値を確定する
384
- const basePercentage = useMeasuredPercentage && measuredPercentage !== undefined ? measuredPercentage : calculatedPercentage
385
- const nextPercentage = roundHalfToEven(basePercentage, PERCENTAGE_DIGITS)
386
- const percentageDiff = Math.abs((panel.percentageSize ?? 0) - nextPercentage)
387
- const shouldUpdatePercentage = percentageDiff > 0 || panel.percentageSize === undefined
388
- const sizeBeforeCollapseDiffers = panel.sizeBeforeCollapse === undefined || panel.sizeBeforeCollapse !== panel.size
389
- const panelIsCollapsible = panelCanCollapse(panel)
390
- const shouldUpdateSizeBeforeCollapse = panelIsCollapsible && sizeBeforeCollapseDiffers
391
- const measuredSnapshot = panel.measuredPixelSize ?? panel.size
392
- const measuredBeforeDiffers = panelIsCollapsible && (panel.measuredPixelSizeBeforeCollapse === undefined || panel.measuredPixelSizeBeforeCollapse !== measuredSnapshot)
393
-
394
- if (!(shouldUpdatePercentage || shouldUpdateSizeBeforeCollapse || measuredBeforeDiffers)) {
200
+ const preference = isPixelPanel(panel) ? panel.size : (panel.size / containerSize) * 100
201
+ const current = isPixelPanel(panel) ? panel.preferredPixelSize : panel.preferredPercentageSize
202
+ const preferenceBeforeCollapse = panel.size > snapThreshold ? preference : panel.preferenceBeforeCollapse
203
+ if (current === preference && preferenceBeforeCollapse === panel.preferenceBeforeCollapse) {
395
204
  return panel
396
205
  }
397
-
398
- mutated = true
399
- const nextSizeBeforeCollapse = shouldUpdateSizeBeforeCollapse ? panel.size : panel.sizeBeforeCollapse
400
- const nextMeasuredPixelBeforeCollapse = measuredBeforeDiffers ? measuredSnapshot : panel.measuredPixelSizeBeforeCollapse
401
- return {
402
- ...panel,
403
- percentageSize: nextPercentage,
404
- sizeBeforeCollapse: nextSizeBeforeCollapse,
405
- measuredPixelSizeBeforeCollapse: nextMeasuredPixelBeforeCollapse,
406
- collapsedByDirection: panel.collapsed ? (panel.collapsedByDirection ?? null) : null,
407
- }
206
+ return isPixelPanel(panel) ? { ...panel, preferredPixelSize: preference, preferenceBeforeCollapse } : { ...panel, preferredPercentageSize: preference, preferenceBeforeCollapse }
408
207
  })
409
-
410
- return mutated ? normalizedPanels : panels
411
- }
412
-
413
- type ActivePanelCandidate = {
414
- index: number
415
- minSize: number
416
- maxSize: number
417
- isFlexible: boolean
418
208
  }
419
209
 
420
210
  /**
421
- * Reconcile active panel sizes so their total matches the container dimensions.
422
- * コンテナ寸法に整合するようアクティブパネルのサイズ合計を補正する処理。
211
+ * Set a panel's preference from an absolute pixel length.
212
+ * 絶対ピクセル長からパネルの希望値を設定する処理。
213
+ *
214
+ * A flexible panel's weight is a share of the container, so an absolute length only lands where it was
215
+ * asked for when the active panels' weights are expressed on the same scale — which is why every caller
216
+ * that hands out absolute lengths rewrites the whole set.
217
+ * 柔軟パネルの重みはコンテナ比なので、絶対長がそのままの位置に収まるのはアクティブパネルの重みが同じ尺度で
218
+ * 表されているときだけである。絶対長を配る呼び出し側が常に集合全体を書き換えるのはこのため。
423
219
  */
424
- const reconcilePanelSizesWithContainer = (panels: PanelLayoutData[], containerSize: number): boolean => {
425
- if (containerSize <= 0) {
426
- return false
427
- }
428
-
429
- const activeIndices: number[] = []
430
- let activeTotal = 0
431
-
432
- // アクティブなパネルのインデックスとサイズ合計を収集
433
- for (let index = 0; index < panels.length; index += 1) {
434
- const panel = panels[index]
435
- // 折りたたまれたパネルは除外
436
- if (!panel || panel.collapsed) {
437
- continue
438
- }
439
- activeIndices.push(index)
440
- activeTotal += panel.size
441
- }
442
-
443
- // アクティブなパネルが存在しない場合は調整不要
444
- if (activeIndices.length === 0) {
445
- return false
446
- }
447
-
448
- // コンテナサイズとアクティブパネルの合計サイズの差分を計算
449
- let remaining = containerSize - activeTotal
450
- // 差分がゼロの場合は調整不要
451
- if (Math.abs(remaining) <= 0) {
452
- return false
453
- }
454
-
455
- // パネルに変更が発生したかを追跡するフラグ
456
- let mutated = false
457
-
458
- // パネルインデックスから調整候補情報を構築 (キャッシュ付き)
459
- const buildCandidate = (index: number): ActivePanelCandidate => {
460
- // // キャッシュから取得を試みる
461
- // const cached = candidateCache.get(index)
462
- // if (cached) {
463
- // return cached
464
- // }
465
- const panel = panels[index]
466
- // パネルの制約をピクセル値に変換
467
- const minFromConstraint = getConstraintInPixels(panel.minSize, 0, containerSize)
468
- const autoMinSize = getConstraintInPixels(panel.autoMinSize, 0, containerSize)
469
- const minSize = Math.max(minFromConstraint, autoMinSize)
470
- const maxSize = getConstraintInPixels(panel.maxSize, containerSize, containerSize)
471
- // パーセンテージ単位かつピクセル固定値を持たないパネルを柔軟とみなす
472
- const isFlexible = panel.sizeUnit === "percentage" && panel.originalPixelSize === undefined
473
- const candidate = {
474
- index,
475
- minSize,
476
- maxSize,
477
- isFlexible,
478
- }
479
- // // 構築した候補情報をキャッシュに保存
480
- // candidateCache.set(index, candidate)
481
- return candidate
482
- }
483
-
484
- // 指定パネルにサイズ差分を適用
485
- const applyDelta = (index: number, delta: number) => {
486
- // 差分が有限数でない、またはゼロ以下の場合は適用不要
487
- if (!Number.isFinite(delta) || Math.abs(delta) <= 0) {
488
- return
489
- }
490
- const target = panels[index]
491
- // 対象パネルが存在しない場合はスキップ
492
- if (!target) {
493
- return
494
- }
495
- // 差分を適用した新しいサイズを計算 (負値にならないよう保護)
496
- const nextSize = Math.max(0, target.size + delta)
497
- // サイズ変化が実質ゼロの場合は更新不要
498
- if (Math.abs(nextSize - target.size) <= 0) {
499
- return
500
- }
501
-
502
- // 新しいサイズに対応する割合を計算
503
- const nextPercentage = toRoundedPercentage(nextSize, containerSize)
504
- // 折りたたみ可能な場合は新サイズを復元用に保存、そうでない場合は既存値を維持
505
- const targetIsCollapsible = panelCanCollapse(target)
506
- const nextSizeBeforeCollapse = targetIsCollapsible ? nextSize : target.sizeBeforeCollapse
507
- // 折りたたみ可能な場合は測定ピクセル値を更新、そうでない場合は既存値を維持
508
- const nextMeasuredPixelBeforeCollapse = targetIsCollapsible ? (target.measuredPixelSizeBeforeCollapse ?? target.measuredPixelSize ?? nextSize) : target.measuredPixelSizeBeforeCollapse
509
-
510
- // パネルデータを更新し、測定値はリセットして再測定を促す
511
- panels[index] = {
512
- ...target,
513
- size: nextSize,
514
- percentageSize: nextPercentage,
515
- sizeBeforeCollapse: nextSizeBeforeCollapse,
516
- measuredPixelSizeBeforeCollapse: nextMeasuredPixelBeforeCollapse,
517
- measuredPixelSize: undefined,
518
- measuredPercentageSize: undefined,
519
- }
520
-
521
- // 残余スペースから適用した差分を減算
522
- remaining -= delta
523
- // 変更フラグを立てる
524
- mutated = true
220
+ const withPreferenceFromPixels = (panel: PanelLayoutData, pixels: number, containerSize: number): PanelLayoutData => {
221
+ if (isPixelPanel(panel)) {
222
+ const preferredPixelSize = Math.max(0, pixels)
223
+ return { ...panel, preferredPixelSize, preferenceBeforeCollapse: preferredPixelSize }
525
224
  }
526
-
527
- /**
528
- * Distribute remaining size among candidate panels to satisfy container constraints.
529
- * コンテナ制約に合わせるため候補パネルへ残余サイズを再配分する処理。
530
- */
531
- const adjustWithCandidates = (candidates: ActivePanelCandidate[]) => {
532
- for (const candidate of candidates) {
533
- // 残余スペースがゼロになったら調整完了
534
- if (Math.abs(remaining) <= 0) {
535
- break
536
- }
537
- const panel = panels[candidate.index]
538
- // パネルが存在しないか折りたたまれている場合はスキップ
539
- if (!panel || panel.collapsed) {
540
- continue
541
- }
542
- const currentSize = panel.size
543
-
544
- // 残余スペースが正の場合はパネルを拡張
545
- if (remaining > 0) {
546
- // 最大サイズまでの拡張可能量を計算
547
- const capacity = Math.max(0, candidate.maxSize - currentSize)
548
- // 拡張余地がない場合は次の候補へ
549
- if (capacity <= 0) {
550
- continue
551
- }
552
- // 拡張可能量と残余スペースの小さい方を適用量とする
553
- const applied = Math.min(capacity, remaining)
554
- // 適用量がゼロ以下の場合はスキップ
555
- if (applied <= 0) {
556
- continue
557
- }
558
- // 拡張差分を適用
559
- applyDelta(candidate.index, applied)
560
- } else {
561
- // 残余スペースが負の場合はパネルを縮小
562
- // 最小サイズまでの縮小可能量を計算
563
- const capacity = Math.max(0, currentSize - candidate.minSize)
564
- // 縮小余地がない場合は次の候補へ
565
- if (capacity <= 0) {
566
- continue
567
- }
568
- // 縮小可能量と残余スペースの絶対値の小さい方を適用量とする
569
- const applied = Math.min(capacity, Math.abs(remaining))
570
- // 適用量がゼロ以下の場合はスキップ
571
- if (applied <= 0) {
572
- continue
573
- }
574
- // 縮小差分を適用 (負の値)
575
- applyDelta(candidate.index, -applied)
576
- }
577
- }
578
- }
579
-
580
- // 柔軟パネル (パーセンテージ単位) の候補を抽出
581
- const flexibleCandidates = activeIndices.map((index) => buildCandidate(index)).filter((candidate) => candidate.isFlexible)
582
- // 柔軟パネルが存在する場合は優先的に調整
583
- if (flexibleCandidates.length > 0) {
584
- adjustWithCandidates(flexibleCandidates)
585
- }
586
-
587
- // まだ残余スペースが残っている場合はピクセルパネルを優先度順に調整
588
- if (Math.abs(remaining) > 0) {
589
- const previousRemaining = remaining
590
- // ピクセルパネルを優先度に基づいて調整
591
- remaining = adjustPixelPanelsByPriority(panels, remaining, containerSize, { enforceAutoMinSize: true })
592
- // 残余スペースが変化した場合は変更フラグを立てる
593
- if (Math.abs(remaining - previousRemaining) > 0) {
594
- mutated = true
595
- }
225
+ if (containerSize <= 0) {
226
+ return panel
596
227
  }
597
-
598
- // // 残余が負でかつ最小サイズ合計がコンテナを超える場合は比率縮小を実施
599
- // if (Math.abs(remaining) > 0 && remaining < 0 && totalMinSize - containerSize > 0) {
600
- // // 調整後のアクティブパネル合計サイズを再計算
601
- // const activeTotalAfter = activeIndices.reduce((sum, index) => {
602
- // const panel = panels[index]
603
- // // 折りたたまれたパネルは除外
604
- // if (!panel || panel.collapsed) {
605
- // return sum
606
- // }
607
- // return sum + Math.max(panel.size, 0)
608
- // }, 0)
609
-
610
- // // アクティブ合計がゼロより大きい場合は比率縮小を実行
611
- // if (activeTotalAfter > 0) {
612
- // // コンテナサイズに対する縮小比率を計算
613
- // const scale = containerSize > 0 ? containerSize / activeTotalAfter : 0
614
- // // 割り当て済みサイズの累積値
615
- // let allocated = 0
616
-
617
- // // 各アクティブパネルに対して比率縮小を適用
618
- // for (let i = 0; i < activeIndices.length; i += 1) {
619
- // const panelIndex = activeIndices[i]
620
- // const target = panels[panelIndex]
621
-
622
- // // 折りたたまれたパネルはスキップ
623
- // if (!target || target.collapsed) {
624
- // continue
625
- // }
626
-
627
- // // 現在サイズに縮小比率を掛けて新サイズを計算
628
- // let nextSize = containerSize <= 0 ? 0 : target.size * scale
629
-
630
- // // コンテナサイズがゼロまたはパネルが1つだけの場合はコンテナサイズ全体を割り当て
631
- // if (containerSize <= 0 || activeIndices.length === 1) {
632
- // nextSize = Math.max(0, containerSize)
633
- // allocated = nextSize
634
- // } else if (i === activeIndices.length - 1) {
635
- // // 最後のパネルには残りスペース全体を割り当てて丸め誤差を吸収
636
- // nextSize = Math.max(0, containerSize - allocated)
637
- // } else {
638
- // // 中間パネルは計算値をそのまま使用
639
- // nextSize = Math.max(0, nextSize)
640
- // allocated += nextSize
641
- // }
642
-
643
- // // 新しいサイズに対応する割合を計算
644
- // const nextPercentage = toRoundedPercentage(nextSize, containerSize)
645
- // // 折りたたみ可能な場合は新サイズを復元用に保存
646
- // const nextSizeBeforeCollapse = target.collapsible ? nextSize : target.sizeBeforeCollapse
647
- // // 折りたたみ可能な場合は測定ピクセル値を更新
648
- // const nextMeasuredPixelBeforeCollapse = target.collapsible ? (target.measuredPixelSizeBeforeCollapse ?? target.measuredPixelSize ?? nextSize) : target.measuredPixelSizeBeforeCollapse
649
-
650
- // // パネルデータを更新し、測定値はリセットして再測定を促す
651
- // panels[panelIndex] = {
652
- // ...target,
653
- // size: nextSize,
654
- // percentageSize: nextPercentage,
655
- // sizeBeforeCollapse: nextSizeBeforeCollapse,
656
- // measuredPixelSizeBeforeCollapse: nextMeasuredPixelBeforeCollapse,
657
- // measuredPixelSize: undefined,
658
- // measuredPercentageSize: undefined,
659
- // }
660
- // }
661
-
662
- // // 残余スペースをゼロにリセット
663
- // remaining = 0
664
- // // 変更フラグを立てる
665
- // mutated = true
666
- // }
667
- // }
668
-
669
- // // まだ残余が残っている場合は、すべてのアクティブパネルをフォールバック候補として調整
670
- // if (Math.abs(remaining) > 0) {
671
- // const fallbackCandidates = activeIndices.map((index) => buildCandidate(index))
672
- // adjustWithCandidates(fallbackCandidates)
673
- // }
674
-
675
- return mutated
228
+ const preferredPercentageSize = Math.max(0, (pixels / containerSize) * 100)
229
+ return { ...panel, preferredPercentageSize, preferenceBeforeCollapse: preferredPercentageSize }
676
230
  }
677
231
 
678
- export type PanelAction =
679
- | { type: "REGISTER_PANEL"; panel: PanelLayoutData }
680
- | { type: "UNREGISTER_PANEL"; id: string }
681
- | { type: "RESIZE_PANELS"; leftId: string; rightId: string; leftSize: number; rightSize: number; containerSize?: number }
682
- | { type: "SET_PANELS"; panels: PanelLayoutData[] }
683
- | { type: "CONTAINER_RESIZED"; containerSize: number; direction: "horizontal" | "vertical" }
684
- | { type: "COLLAPSE_PANEL"; id: string; containerSize: number; from: PanelCollapseDirection }
685
- | { type: "EXPAND_PANEL"; id: string; containerSize: number; from: PanelCollapseDirection; targetSize?: number }
686
-
687
232
  /**
688
- * Assign priority for pixel adjustment if the panel is pixel-based and priority is undefined.
689
- * ピクセルベースのパネルで優先度が未定義の場合に調整優先度を自動割り当てする関数。
233
+ * Express a panel's preference as a length in pixels for the given container.
234
+ * パネルの希望値を、指定コンテナに対するピクセル長として表す処理。
690
235
  *
691
- * @param panel - Target panel data / 対象のパネルデータ
692
- * @param panels - All current panels in the group / グループ内の全パネル
693
- * @param excludeIndex - Index to exclude from priority calculation (for updates) / 優先度計算から除外するインデックス (更新時用)
694
- * @returns Updated panel with assigned priority / 優先度が割り当てられたパネル
236
+ * This is the length the panel occupies when it is open, which is what a persisted layout has to carry: the
237
+ * allocated size of a collapsed panel is 0 and would restore as "closed and with nothing to open to".
238
+ * これは「開いているときに占める長さ」であり、保存レイアウトが持つべき値でもある。折りたたみ中のパネルの
239
+ * 配分結果は 0 であり、そのまま保存すると「閉じたまま、開く先も無い」状態として復元されてしまう。
240
+ *
241
+ * @param panel - Panel layout data / パネルレイアウトデータ
242
+ * @param containerSize - Container content-box length in pixels / コンテナ内容領域長 (px)
243
+ * @returns Preferred length in pixels / 希望する長さ (px)
695
244
  */
696
- const assignPixelAdjustPriority = (panel: PanelLayoutData, panels: PanelLayoutData[], excludeIndex?: number): PanelLayoutData => {
697
- // ピクセル単位パネルかどうかを判定
698
- const isPixelPanel = panel.sizeUnit === "pixels" || panel.originalPixelSize !== undefined
699
- // ピクセルパネルでない、または既に優先度が設定済みの場合はそのまま返す
700
- if (!isPixelPanel || panel.pixelAdjustPriority !== undefined) {
701
- return panel
702
- }
703
-
704
- // 既存のピクセルパネルから優先度を収集 (除外インデックスを考慮)
705
- const existingPriorities = panels
706
- .filter((candidate, index) => index !== excludeIndex && (candidate.sizeUnit === "pixels" || candidate.originalPixelSize !== undefined))
707
- .map((candidate) => candidate.pixelAdjustPriority)
708
- .filter((value): value is number => value !== undefined)
245
+ export const getPanelPreferenceInPixels = (panel: PanelLayoutData, containerSize: number): number => {
246
+ const preference = panel.collapsed ? panel.preferenceBeforeCollapse : isPixelPanel(panel) ? panel.preferredPixelSize : panel.preferredPercentageSize
247
+ return isPixelPanel(panel) ? preference : (preference / 100) * containerSize
248
+ }
709
249
 
710
- // 既存の最大優先度 + 1 を新しい優先度として割り当て (既存がない場合は 1)
711
- const nextPriority = existingPriorities.length > 0 ? Math.max(...existingPriorities) + 1 : 1
712
- return {
713
- ...panel,
714
- pixelAdjustPriority: nextPriority,
715
- }
250
+ /**
251
+ * Give one panel an absolute length and let the other active panels share what is left, keeping their ratio.
252
+ * 1 枚のパネルへ絶対長を与え、残りのアクティブパネルが現在の比のまま残余を分け合うよう希望値を揃える処理。
253
+ *
254
+ * @param panels - Panels in DOM order / DOM 順のパネル一覧
255
+ * @param targetId - Panel that receives the absolute length / 絶対長を受け取るパネル
256
+ * @param pixels - Absolute length in pixels / 絶対長 (px)
257
+ * @param containerSize - Container content-box length in pixels / コンテナ内容領域長 (px)
258
+ * @returns Panels whose preferences reproduce the requested layout / 要求どおりの配置を再現する希望値を持つパネル一覧
259
+ */
260
+ const withAbsoluteSize = (panels: PanelLayoutData[], targetId: string, pixels: number, containerSize: number): PanelLayoutData[] => {
261
+ const target = Math.max(0, Math.min(pixels, containerSize))
262
+ // ピクセルパネルの希望長は利用者が宣言した絶対値なので、他のパネルの都合で書き換えない。
263
+ // 残余を分け合うのは柔軟パネルだけ
264
+ const sharing = panels.filter((panel) => panel.id !== targetId && !panel.collapsed && !isPixelPanel(panel))
265
+ const reserved = panels.reduce((total, panel) => (panel.id !== targetId && !panel.collapsed && isPixelPanel(panel) ? total + panel.size : total), 0)
266
+ const sharingTotal = sharing.reduce((total, panel) => total + panel.size, 0)
267
+ const sharingBudget = Math.max(0, containerSize - target - reserved)
268
+
269
+ return panels.map((panel) => {
270
+ if (panel.id === targetId) {
271
+ return withPreferenceFromPixels(panel, target, containerSize)
272
+ }
273
+ if (panel.collapsed || isPixelPanel(panel) || sharing.length === 0) {
274
+ return panel
275
+ }
276
+ const share = sharingTotal > 0 ? (panel.size / sharingTotal) * sharingBudget : sharingBudget / sharing.length
277
+ return withPreferenceFromPixels(panel, share, containerSize)
278
+ })
716
279
  }
717
280
 
718
281
  /**
719
- * Handle panel registration and size updates for the panel group.
720
- * パネルグループ内の登録・サイズ更新を処理するリデューサー。
282
+ * Reduce the layout state of one panel group.
283
+ * パネルグループのレイアウト状態を遷移させるリデューサー。
284
+ *
285
+ * @param state - Current layout state / 現在のレイアウト状態
286
+ * @param action - Action describing the edit / 編集内容を表すアクション
287
+ * @returns Next layout state, identical by reference when nothing changed / 次のレイアウト状態 (無変化なら同一参照)
721
288
  */
722
- // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Reducer logic
723
- export const panelReducer = (state: PanelLayoutData[], action: PanelAction): PanelLayoutData[] => {
289
+ export const panelReducer = (state: PanelGroupLayoutState, action: PanelAction): PanelGroupLayoutState => {
724
290
  switch (action.type) {
725
- case "REGISTER_PANEL": {
726
- logger.debug("REGISTER_PANEL", action.panel)
727
- const existingIndex = state.findIndex((p) => p.id === action.panel.id)
728
-
729
- if (existingIndex >= 0) {
730
- // 既存パネルの場合はプロパティを更新
731
- const newState = [...state]
732
- const existingPanel = state[existingIndex]
733
- const updatedPanelBase: PanelLayoutData = {
734
- ...existingPanel,
735
- minSize: action.panel.minSize,
736
- maxSize: action.panel.maxSize,
737
- collapseFromStart: action.panel.collapseFromStart,
738
- collapseFromEnd: action.panel.collapseFromEnd,
739
- collapsedByDirection: action.panel.collapsedByDirection !== undefined ? action.panel.collapsedByDirection : existingPanel.collapsedByDirection,
740
- sizeUnit: action.panel.sizeUnit,
741
- originalPixelSize: action.panel.originalPixelSize,
742
- preferredPercentageSize: action.panel.preferredPercentageSize !== undefined ? action.panel.preferredPercentageSize : existingPanel.preferredPercentageSize,
743
- preferredPercentageSizeBeforeCollapse: action.panel.preferredPercentageSizeBeforeCollapse !== undefined ? action.panel.preferredPercentageSizeBeforeCollapse : existingPanel.preferredPercentageSizeBeforeCollapse,
744
- percentageSizeBeforeCollapse: action.panel.percentageSizeBeforeCollapse !== undefined ? action.panel.percentageSizeBeforeCollapse : existingPanel.percentageSizeBeforeCollapse,
745
- pixelAdjustPriority: action.panel.pixelAdjustPriority !== undefined ? action.panel.pixelAdjustPriority : existingPanel.pixelAdjustPriority,
746
- flexAdjustPriority: action.panel.flexAdjustPriority !== undefined ? action.panel.flexAdjustPriority : existingPanel.flexAdjustPriority,
747
- autoMinSize: action.panel.autoMinSize !== undefined ? action.panel.autoMinSize : existingPanel.autoMinSize,
748
- excludeFromAutoGrowth: action.panel.excludeFromAutoGrowth !== undefined ? action.panel.excludeFromAutoGrowth : existingPanel.excludeFromAutoGrowth,
749
- }
750
- // ピクセル単位パネルで優先度が未設定の場合は自動割り当て
751
- const updatedPanel = assignPixelAdjustPriority(updatedPanelBase, state, existingIndex)
752
- logger.debug("register panel (update)", {
753
- panelId: updatedPanel.id,
754
- incoming: snapshotSinglePanel(action.panel),
755
- previous: snapshotSinglePanel(existingPanel),
756
- next: snapshotSinglePanel(updatedPanel),
757
- })
758
-
759
- newState[existingIndex] = updatedPanel
760
-
761
- // minSize/maxSize の更新で現行サイズが制約違反になった場合はレイアウトへ即時再適用する
762
- // (従来は保存されるだけで、次のドラッグやコンテナリサイズまで反映されなかった)
763
- // 制約を満たしている間は現行レイアウトに触れず、ユーザー操作後の配置を維持する
764
- if (!updatedPanel.collapsed) {
765
- const estimatedContainerSize = newState.reduce((sum, panel) => (panel.collapsed ? sum : sum + panel.size), 0)
766
- if (estimatedContainerSize > 0) {
767
- const minPixels = getConstraintInPixels(updatedPanel.minSize, 0, estimatedContainerSize)
768
- const maxPixels = Math.max(minPixels, getConstraintInPixels(updatedPanel.maxSize, estimatedContainerSize, estimatedContainerSize))
769
- if (updatedPanel.size < minPixels - SIZE_EPSILON || updatedPanel.size > maxPixels + SIZE_EPSILON) {
770
- return recalculateFlexiblePanels(newState, estimatedContainerSize)
771
- }
772
- }
773
- }
774
-
775
- return newState
291
+ case "SET_CONTAINER_SIZE": {
292
+ if (!Number.isFinite(action.containerSize) || action.containerSize < 0 || action.containerSize === state.containerSize) {
293
+ return state
776
294
  }
295
+ return commit(state, allocate(state.panels, action.containerSize))
296
+ }
777
297
 
778
- // 新規パネルの場合は末尾に追加し、必要に応じて優先度を割り当て
779
- const newPanelBase: PanelLayoutData = { ...action.panel }
780
- const newPanel = assignPixelAdjustPriority(newPanelBase, state)
781
- logger.debug("register panel (insert)", {
782
- panelId: newPanel.id,
783
- incoming: snapshotSinglePanel(newPanel),
784
- previousLength: state.length,
785
- })
786
- return [...state, newPanel]
298
+ case "REGISTER_PANEL": {
299
+ const index = state.panels.findIndex((panel) => panel.id === action.panel.id)
300
+ if (index === -1) {
301
+ const panels = [...state.panels, createPanel(action.panel, initialCollapseDirection(action.panel))]
302
+ return commit(state, allocate(panels, state.containerSize))
303
+ }
304
+
305
+ const previous = state.panels[index]
306
+ const authored = action.panel.sizeUnit === "pixels" ? action.panel.preferredPixelSize : action.panel.preferredPercentageSize
307
+ // 利用者が指定した既定サイズが変わったときだけ希望値を採用する。
308
+ // 変わっていない再登録 (制約プロパティの変更など) でドラッグ結果を巻き戻さないための判定
309
+ const adoptsAuthoredPreference = authored !== previous.authoredPreference || action.panel.sizeUnit !== previous.sizeUnit
310
+ const collapseDirectionStillAllowed = previous.collapsedByDirection === "start" ? action.panel.collapseFromStart : previous.collapsedByDirection === "end" ? action.panel.collapseFromEnd : false
311
+ // 折りたたみ設定が変わったのに方向が残ると、どの方向でも展開できないパネルになる
312
+ const stillCollapsible = action.panel.collapseFromStart || action.panel.collapseFromEnd
313
+ const nextCollapsed = previous.collapsed && stillCollapsible
314
+ const updated: PanelLayoutData = {
315
+ ...previous,
316
+ ...action.panel,
317
+ collapsed: nextCollapsed,
318
+ collapsedByDirection: nextCollapsed ? (collapseDirectionStillAllowed ? previous.collapsedByDirection : initialCollapseDirection(action.panel)) : null,
319
+ preferredPercentageSize: adoptsAuthoredPreference ? action.panel.preferredPercentageSize : previous.preferredPercentageSize,
320
+ preferredPixelSize: adoptsAuthoredPreference ? action.panel.preferredPixelSize : previous.preferredPixelSize,
321
+ preferenceBeforeCollapse: adoptsAuthoredPreference ? authored : previous.preferenceBeforeCollapse,
322
+ authoredPreference: authored,
323
+ }
324
+
325
+ const panels = [...state.panels]
326
+ panels[index] = updated
327
+ return commit(state, allocate(panels, state.containerSize))
787
328
  }
329
+
788
330
  case "UNREGISTER_PANEL": {
789
- logger.debug("UNREGISTER_PANEL", action.id)
790
- // 指定 ID のパネルを削除し、残ったパネルの割合を正規化
791
- const filtered = state.filter((panel) => panel.id !== action.id)
792
- const normalized = normalizePanelPercentages(filtered)
793
- // スナップショット構築が高コストなため、debug 出力が有効な場合のみ組み立てる
794
- if (logger.enabledFor(LogLevel.DEBUG)) {
795
- logger.debug("unregister panel", {
796
- panelId: action.id,
797
- previous: snapshotPanelState(state),
798
- filtered: snapshotPanelState(filtered),
799
- next: snapshotPanelState(normalized),
800
- })
331
+ const panels = state.panels.filter((panel) => panel.id !== action.id)
332
+ if (panels.length === state.panels.length) {
333
+ return state
801
334
  }
802
- return normalized
335
+ return commit(state, allocate(panels, state.containerSize))
803
336
  }
804
- case "RESIZE_PANELS": {
805
- // PanelResizeHandle の onPointerMove/onPointerUp ハンドラから呼び出されるドラッグ更新ロジック
806
- // フック側は「制約を大きく外さない初期値」を作る役割、リデューサー側が「最終確定」の役割を担う
807
- // フック側はドラッグ中の左右ペアに即座に制約を適用して「安全な候補サイズ」を算出するだけで、他パネルとの整合や割合正規化までは扱わない
808
- // フック側で暫定計算した結果を受け取り、ここで唯一の正規ロジックとして全パネルに対する最終調整と割合正規化を確定させる
809
-
810
- logger.debug("RESIZE_PANELS", action.leftId, action.rightId, action.leftSize, action.rightSize)
811
- const leftIndex = state.findIndex((p) => p.id === action.leftId)
812
- const rightIndex = state.findIndex((p) => p.id === action.rightId)
813
- if (leftIndex === -1 || rightIndex === -1) return state
814
337
 
815
- const newState = [...state]
816
- const leftPanel = newState[leftIndex]
817
- const rightPanel = newState[rightIndex]
818
-
819
- // 折りたたまれたパネルがある場合はリサイズを中断
820
- if (leftPanel.collapsed || rightPanel.collapsed) {
338
+ case "REORDER_PANELS": {
339
+ if (action.ids.length !== state.panels.length) {
340
+ // DOM と登録状態が一時的にずれている間は並べ替えない (揃った時点で再度通知される)
821
341
  return state
822
342
  }
823
-
824
- // サイズ変化が無い場合はスキップして不要な再レンダリングを防止
825
- if (action.leftSize === leftPanel.size && action.rightSize === rightPanel.size) {
343
+ if (new Set(action.ids).size !== action.ids.length) {
344
+ // 同じ id が 2 度現れる並びは、片方のパネルを取り違えて落とすことになる
826
345
  return state
827
346
  }
828
-
829
- // コンテナサイズを推定 (指定がない場合は全パネルのサイズ合計を使用)
830
- const estimatedContainerSize = action.containerSize ?? state.reduce((sum, p) => sum + p.size, 0)
831
- // 左パネルの最小・最大サイズをピクセル値に変換
832
- const leftMinSize = getConstraintInPixels(leftPanel.minSize, 0, estimatedContainerSize)
833
- const leftMaxSize = getConstraintInPixels(leftPanel.maxSize, estimatedContainerSize, estimatedContainerSize)
834
- // 右パネルの最小・最大サイズをピクセル値に変換
835
- const rightMinSize = getConstraintInPixels(rightPanel.minSize, 0, estimatedContainerSize)
836
- const rightMaxSize = getConstraintInPixels(rightPanel.maxSize, estimatedContainerSize, estimatedContainerSize)
837
-
838
- // リサイズ対象ペアの元の合計サイズを記録
839
- const originalPairTotal = leftPanel.size + rightPanel.size
840
- // 全アクティブパネルの合計サイズを計算
841
- const activeTotalBefore = state.reduce((sum, panel) => (panel.collapsed ? sum : sum + panel.size), 0)
842
- // リサイズ対象ペア以外のアクティブパネルの合計サイズを算出
843
- const otherActiveTotal = activeTotalBefore - originalPairTotal
844
-
845
- // ペアの最小・最大合計サイズを算出
846
- const minPairTotal = leftMinSize + rightMinSize
847
- const maxPairTotal = leftMaxSize + rightMaxSize
848
-
849
- // リクエストされたペア合計を制約内にクランプ
850
- const requestedPairTotal = clamp(action.leftSize + action.rightSize, minPairTotal, maxPairTotal)
851
-
852
- // コンテナサイズとペア以外の合計から必要なペア合計を計算
853
- let requiredPairTotal = estimatedContainerSize > 0 ? estimatedContainerSize - otherActiveTotal : requestedPairTotal
854
- // 計算結果が無効な場合はリクエスト値を使用
855
- if (!Number.isFinite(requiredPairTotal)) {
856
- requiredPairTotal = requestedPairTotal
857
- }
858
- // リクエスト値と必要値の大きい方を選び、制約内にクランプして目標合計を決定
859
- const targetPairTotal = clamp(Math.max(requestedPairTotal, requiredPairTotal), minPairTotal, maxPairTotal)
860
-
861
- // 左パネルの有効範囲を計算 (右パネルの制約を考慮して決定)
862
- const computeLeftRange = (sum: number) => ({
863
- min: Math.max(leftMinSize, sum - rightMaxSize),
864
- max: Math.min(leftMaxSize, sum - rightMinSize),
865
- })
866
- // 右パネルの有効範囲を計算 (左パネルの制約を考慮して決定)
867
- const computeRightRange = (sum: number) => ({
868
- min: Math.max(rightMinSize, sum - leftMaxSize),
869
- max: Math.min(rightMaxSize, sum - leftMinSize),
870
- })
871
-
872
- // 左パネルのサイズ変化量を計算
873
- const leftInfluence = Math.abs(action.leftSize - leftPanel.size)
874
- // 右パネルのサイズ変化量を計算
875
- const rightInfluence = Math.abs(action.rightSize - rightPanel.size)
876
- // 変化量が大きい方のパネルを優先して調整
877
- const prioritizeLeft = leftInfluence >= rightInfluence
878
-
879
- // 最終的に適用するサイズを初期化
880
- let finalLeftSize = leftPanel.size
881
- let finalRightSize = rightPanel.size
882
-
883
- // 左パネルを優先する場合の調整
884
- if (prioritizeLeft) {
885
- // 左パネルの有効範囲を取得
886
- const leftRange = computeLeftRange(targetPairTotal)
887
- // 有効範囲が不正な場合は変更をキャンセル (桁落ちで境界が 1ulp 交差しただけなら単一点として扱う)
888
- if (leftRange.min > leftRange.max + SIZE_EPSILON) {
347
+ const byId = new Map(state.panels.map((panel) => [panel.id, panel]))
348
+ const panels: PanelLayoutData[] = []
349
+ for (const id of action.ids) {
350
+ const panel = byId.get(id)
351
+ if (!panel) {
889
352
  return state
890
353
  }
891
- // 左パネルの希望サイズを有効範囲内にクランプ
892
- const desiredLeft = clamp(action.leftSize, leftRange.min, leftRange.max)
893
- // 右パネルのサイズを合計から逆算
894
- const adjustedRight = targetPairTotal - desiredLeft
895
- // 右パネルのサイズが制約を満たさない場合は変更をキャンセル (桁落ち分は境界へ丸めて受理する)
896
- if (adjustedRight < rightMinSize - SIZE_EPSILON || adjustedRight > rightMaxSize + SIZE_EPSILON) {
897
- return state
898
- }
899
- // 計算結果を最終サイズとして確定
900
- finalLeftSize = desiredLeft
901
- finalRightSize = clamp(adjustedRight, rightMinSize, rightMaxSize)
902
- } else {
903
- // 右パネルを優先する場合の調整
904
- // 右パネルの有効範囲を取得
905
- const rightRange = computeRightRange(targetPairTotal)
906
- // 有効範囲が不正な場合は変更をキャンセル (桁落ちで境界が 1ulp 交差しただけなら単一点として扱う)
907
- if (rightRange.min > rightRange.max + SIZE_EPSILON) {
908
- return state
909
- }
910
- // 右パネルの希望サイズを有効範囲内にクランプ
911
- const desiredRight = clamp(action.rightSize, rightRange.min, rightRange.max)
912
- // 左パネルのサイズを合計から逆算
913
- const adjustedLeft = targetPairTotal - desiredRight
914
- // 左パネルのサイズが制約を満たさない場合は変更をキャンセル (桁落ち分は境界へ丸めて受理する)
915
- if (adjustedLeft < leftMinSize - SIZE_EPSILON || adjustedLeft > leftMaxSize + SIZE_EPSILON) {
916
- return state
917
- }
918
- // 計算結果を最終サイズとして確定
919
- finalRightSize = desiredRight
920
- finalLeftSize = clamp(adjustedLeft, leftMinSize, leftMaxSize)
354
+ panels.push(panel)
921
355
  }
922
-
923
- // 誤差範囲を超えて合計が変化する場合は状態を維持
924
- const pairTotalAfter = finalLeftSize + finalRightSize
925
- if (Math.abs(targetPairTotal - pairTotalAfter) > SIZE_EPSILON) {
356
+ if (panels.every((panel, index) => panel === state.panels[index])) {
926
357
  return state
927
358
  }
359
+ return commit(state, allocate(panels, state.containerSize))
360
+ }
928
361
 
929
- // リサイズ後のアクティブパネル合計サイズを計算
930
- const activeTotalAfter = activeTotalBefore - leftPanel.size - rightPanel.size + pairTotalAfter
931
-
932
- // // リサイズ前後でアクティブ合計が変化している場合は変更をキャンセル
933
- // if (activeTotalAfter !== activeTotalBefore) {
934
- // return state
935
- // }
936
-
937
- // // コンテナサイズが指定されているのにアクティブ合計が不足する場合は変更をキャンセル
938
- // if (estimatedContainerSize > 0 && activeTotalAfter < estimatedContainerSize) {
939
- // return state
940
- // }
941
-
942
- // 割合計算に使用するコンテナサイズを決定 (推定値がある場合はそれを使用、なければ合計値)
943
- const containerSizeForPercentage = estimatedContainerSize > 0 ? estimatedContainerSize : activeTotalAfter
944
- // 左パネルの新しい割合を計算
945
- const nextLeftPercentage = containerSizeForPercentage > 0 ? toRoundedPercentage(finalLeftSize, containerSizeForPercentage) : 0
946
- // 右パネルの新しい割合を計算
947
- const nextRightPercentage = containerSizeForPercentage > 0 ? toRoundedPercentage(finalRightSize, containerSizeForPercentage) : 0
948
- const nextLeftPreferredPixel = leftPanel.sizeUnit === "pixels" || leftPanel.originalPixelSize !== undefined ? finalLeftSize : leftPanel.preferredPixelSize
949
- const nextRightPreferredPixel = rightPanel.sizeUnit === "pixels" || rightPanel.originalPixelSize !== undefined ? finalRightSize : rightPanel.preferredPixelSize
950
-
951
- const leftAutoMin = getConstraintInPixels(leftPanel.autoMinSize, 0, estimatedContainerSize)
952
- const rightAutoMin = getConstraintInPixels(rightPanel.autoMinSize, 0, estimatedContainerSize)
953
- const leftZeroThreshold = Math.max(1e-3, leftAutoMin)
954
- const rightZeroThreshold = Math.max(1e-3, rightAutoMin)
955
- const manageLeftGrowth = leftPanel.sizeUnit === "percentage" && leftPanel.originalPixelSize === undefined
956
- const manageRightGrowth = rightPanel.sizeUnit === "percentage" && rightPanel.originalPixelSize === undefined
957
- let nextLeftExclude = leftPanel.excludeFromAutoGrowth
958
- let nextRightExclude = rightPanel.excludeFromAutoGrowth
959
- if (manageLeftGrowth) {
960
- if (finalLeftSize <= leftZeroThreshold) {
961
- nextLeftExclude = true
962
- } else if (finalLeftSize > leftZeroThreshold) {
963
- nextLeftExclude = false
964
- }
965
- }
966
- if (manageRightGrowth) {
967
- if (finalRightSize <= rightZeroThreshold) {
968
- nextRightExclude = true
969
- } else if (finalRightSize > rightZeroThreshold) {
970
- nextRightExclude = false
971
- }
362
+ case "RESIZE_PANELS": {
363
+ const startIndex = state.panels.findIndex((panel) => panel.id === action.startId)
364
+ const endIndex = state.panels.findIndex((panel) => panel.id === action.endId)
365
+ if (startIndex === -1 || endIndex === -1) {
366
+ return state
972
367
  }
973
368
 
974
- const leftIsCollapsible = panelCanCollapse(leftPanel)
975
- const rightIsCollapsible = panelCanCollapse(rightPanel)
976
-
977
- // 左パネルの状態を更新 (測定値はリセットして再測定を促す)
978
- newState[leftIndex] = {
979
- ...leftPanel,
980
- size: finalLeftSize,
981
- percentageSize: nextLeftPercentage,
982
- preferredPercentageSize: nextLeftPercentage > 0 ? nextLeftPercentage : leftPanel.preferredPercentageSize,
983
- preferredPixelSize: nextLeftPreferredPixel,
984
- sizeBeforeCollapse: leftIsCollapsible ? finalLeftSize : leftPanel.sizeBeforeCollapse,
985
- measuredPixelSize: undefined,
986
- measuredPercentageSize: undefined,
987
- excludeFromAutoGrowth: nextLeftExclude,
988
- }
989
- // 右パネルの状態を更新 (測定値はリセットして再測定を促す)
990
- newState[rightIndex] = {
991
- ...rightPanel,
992
- size: finalRightSize,
993
- percentageSize: nextRightPercentage,
994
- preferredPercentageSize: nextRightPercentage > 0 ? nextRightPercentage : rightPanel.preferredPercentageSize,
995
- preferredPixelSize: nextRightPreferredPixel,
996
- sizeBeforeCollapse: rightIsCollapsible ? finalRightSize : rightPanel.sizeBeforeCollapse,
997
- measuredPixelSize: undefined,
998
- measuredPercentageSize: undefined,
999
- excludeFromAutoGrowth: nextRightExclude,
1000
- }
1001
- // コンテナサイズが有効な場合は他のパネルとの整合性を確保
1002
- if (estimatedContainerSize > 0 && reconcilePanelSizesWithContainer(newState, estimatedContainerSize)) {
1003
- return normalizePanelPercentages(newState)
1004
- }
1005
- return newState
1006
- }
1007
- case "SET_PANELS": {
1008
- logger.debug("SET_PANELS", action.panels)
1009
- // スナップショット構築が高コストなため、debug 出力が有効な場合のみログ専用の組み立てを行う
1010
- const debugEnabled = logger.enabledFor(LogLevel.DEBUG)
1011
- // パネル配列全体を置き換えて割合を正規化
1012
- const enrichedPanels = action.panels.map((panel) => {
1013
- const isPixelPanel = panel.sizeUnit === "pixels" || panel.originalPixelSize !== undefined
1014
- const nextPreferredPercentage = panel.preferredPercentageSize !== undefined ? panel.preferredPercentageSize : panel.percentageSize
1015
- const nextPreferredPixelSize = panel.preferredPixelSize !== undefined ? panel.preferredPixelSize : isPixelPanel ? panel.size : panel.preferredPixelSize
1016
- return {
1017
- ...panel,
1018
- preferredPercentageSize: nextPreferredPercentage,
1019
- preferredPixelSize: nextPreferredPixelSize,
1020
- }
1021
- })
1022
- const previousSnapshot = snapshotPanelState(state)
1023
- // incomingSnapshot はログ専用のため debug 有効時のみ構築する
1024
- const incomingSnapshot = debugEnabled ? snapshotPanelState(enrichedPanels) : null
1025
- const normalized = normalizePanelPercentages(enrichedPanels)
1026
- const nextSnapshot = snapshotPanelState(normalized)
1027
- let isNoop = previousSnapshot.length === nextSnapshot.length
1028
- if (isNoop) {
1029
- for (let index = 0; index < nextSnapshot.length; index += 1) {
1030
- const panel = nextSnapshot[index]
1031
- const previous = previousSnapshot[index]
1032
- if (!previous) {
1033
- isNoop = false
1034
- break
1035
- }
1036
- // スナップショットの全フィールドを比較する (一部だけ比較すると実際の状態変化を no-op と誤判定して捨ててしまう)
1037
- const isSame =
1038
- previous.id === panel.id &&
1039
- previous.size === panel.size &&
1040
- previous.percentageSize === panel.percentageSize &&
1041
- previous.preferredPercentageSize === panel.preferredPercentageSize &&
1042
- previous.sizeBeforeCollapse === panel.sizeBeforeCollapse &&
1043
- previous.percentageSizeBeforeCollapse === panel.percentageSizeBeforeCollapse &&
1044
- previous.preferredPercentageSizeBeforeCollapse === panel.preferredPercentageSizeBeforeCollapse &&
1045
- previous.measuredPercentageSize === panel.measuredPercentageSize &&
1046
- previous.excludeFromAutoGrowth === panel.excludeFromAutoGrowth &&
1047
- previous.collapsed === panel.collapsed &&
1048
- previous.collapseFromStart === panel.collapseFromStart &&
1049
- previous.collapseFromEnd === panel.collapseFromEnd &&
1050
- previous.collapsedByDirection === panel.collapsedByDirection
1051
- if (!isSame) {
1052
- isNoop = false
1053
- break
1054
- }
1055
- }
369
+ const startPanel = state.panels[startIndex]
370
+ const endPanel = state.panels[endIndex]
371
+ if (startPanel.collapsed || endPanel.collapsed || state.containerSize <= 0 || !Number.isFinite(action.startSize)) {
372
+ return state
1056
373
  }
1057
374
 
1058
- if (isNoop) {
1059
- if (debugEnabled) {
1060
- logger.debug("set panels skipped (no diff)", {
1061
- previous: previousSnapshot,
1062
- incoming: incomingSnapshot,
1063
- next: nextSnapshot,
1064
- previousLength: previousSnapshot.length,
1065
- incomingLength: incomingSnapshot?.length ?? enrichedPanels.length,
1066
- })
1067
- }
375
+ // ドラッグはこの 2 枚の間で領域を移すだけの操作なので、合計は動かさない
376
+ const total = startPanel.size + endPanel.size
377
+ const startBounds = resolvePanelBounds(startPanel, state.containerSize)
378
+ const endBounds = resolvePanelBounds(endPanel, state.containerSize)
379
+ const lowerBound = Math.max(startBounds.min, total - endBounds.max)
380
+ const upperBound = Math.min(startBounds.max, total - endBounds.min)
381
+ if (lowerBound - upperBound > PANEL_ALLOCATION_EPSILON) {
1068
382
  return state
1069
383
  }
1070
- if (debugEnabled) {
1071
- logger.debug("set panels", {
1072
- previous: previousSnapshot,
1073
- incoming: incomingSnapshot,
1074
- next: nextSnapshot,
1075
- previousLength: previousSnapshot.length,
1076
- incomingLength: incomingSnapshot?.length ?? enrichedPanels.length,
1077
- })
1078
- }
1079
- return normalized
1080
- }
1081
- case "CONTAINER_RESIZED": {
1082
- logger.debug("CONTAINER_RESIZED", action.containerSize)
1083
- // リサイズ毎ティックに走るホットパスのため、抑制中はスナップショット構築ごと省略する
1084
- // (logger.debug は引数を先に評価するので、ガードなしでは全パネルのスナップショットが毎回作られる)
1085
- const debugEnabled = logger.enabledFor(LogLevel.DEBUG)
1086
- if (action.containerSize <= 0) {
1087
- if (debugEnabled) {
1088
- logger.debug("container resized skipped (non-positive size)", {
1089
- containerSize: action.containerSize,
1090
- panels: snapshotPanelState(state),
1091
- })
1092
- }
384
+
385
+ const startSize = clamp(action.startSize, lowerBound, Math.max(lowerBound, upperBound))
386
+ const endSize = total - startSize
387
+ if (Math.abs(startSize - startPanel.size) <= PANEL_ALLOCATION_EPSILON) {
1093
388
  return state
1094
389
  }
1095
- if (debugEnabled) {
1096
- logger.debug("container resized (before)", {
1097
- containerSize: action.containerSize,
1098
- panels: snapshotPanelState(state),
1099
- })
1100
- }
1101
- // コンテナサイズ変更時に柔軟パネルを再計算
1102
- const recalculated = recalculateFlexiblePanels(state, action.containerSize)
1103
- if (debugEnabled) {
1104
- logger.debug("container resized (after recalc)", {
1105
- containerSize: action.containerSize,
1106
- panels: snapshotPanelState(recalculated),
1107
- })
1108
- }
1109
- if (action.containerSize > 0 && reconcilePanelSizesWithContainer(recalculated, action.containerSize)) {
1110
- const normalized = normalizePanelPercentages(recalculated)
1111
- if (debugEnabled) {
1112
- logger.debug("container resized (after reconcile)", {
1113
- containerSize: action.containerSize,
1114
- panels: snapshotPanelState(normalized),
1115
- })
1116
- }
1117
- return normalized
1118
- }
1119
- if (debugEnabled) {
1120
- logger.debug("container resized (final)", {
1121
- containerSize: action.containerSize,
1122
- panels: snapshotPanelState(recalculated),
1123
- })
1124
- }
1125
- return recalculated
390
+
391
+ const resized = [...state.panels]
392
+ resized[startIndex] = { ...startPanel, size: startSize }
393
+ resized[endIndex] = { ...endPanel, size: endSize }
394
+ return commit(state, allocate(commitPreferences(resized, state.containerSize), state.containerSize))
1126
395
  }
396
+
1127
397
  case "COLLAPSE_PANEL": {
1128
- logger.debug("COLLAPSE_PANEL", action.id)
1129
- const panelIndex = state.findIndex((panel) => panel.id === action.id)
1130
- if (panelIndex === -1) {
398
+ const index = state.panels.findIndex((panel) => panel.id === action.id)
399
+ if (index === -1) {
1131
400
  return state
1132
401
  }
1133
-
1134
- const targetPanel = state[panelIndex]
1135
- const directionAllowed = action.from === "start" ? targetPanel.collapseFromStart : targetPanel.collapseFromEnd
1136
- const panelIsCollapsible = panelCanCollapse(targetPanel)
1137
- // 折りたたみ不可または既に折りたたまれている場合はスキップ
1138
- if (!(panelIsCollapsible && directionAllowed) || targetPanel.collapsed) {
402
+ const panel = state.panels[index]
403
+ const allowed = action.from === "start" ? panel.collapseFromStart : panel.collapseFromEnd
404
+ if (!allowed || panel.collapsed) {
1139
405
  return state
1140
406
  }
1141
407
 
1142
- // 折りたたみ前のサイズと測定値をピクセル基準で保存
1143
- const sizeBefore = targetPanel.size
1144
- const measuredPixelBefore = targetPanel.measuredPixelSize ?? sizeBefore
1145
- const percentageBefore = targetPanel.percentageSize ?? targetPanel.preferredPercentageSize ?? 0
1146
-
1147
- const updatedPanel: PanelLayoutData = {
1148
- ...targetPanel,
408
+ const panels = [...state.panels]
409
+ panels[index] = {
410
+ ...panel,
1149
411
  collapsed: true,
1150
412
  collapsedByDirection: action.from,
1151
- sizeBeforeCollapse: sizeBefore,
1152
- percentageSizeBeforeCollapse: percentageBefore,
1153
- preferredPercentageSizeBeforeCollapse: targetPanel.preferredPercentageSize ?? percentageBefore,
1154
- preferredPixelSizeBeforeCollapse: targetPanel.preferredPixelSize ?? sizeBefore,
1155
- measuredPixelSizeBeforeCollapse: measuredPixelBefore,
1156
- size: 0,
1157
- percentageSize: 0,
1158
- preferredPercentageSize: targetPanel.preferredPercentageSize,
1159
- measuredPixelSize: targetPanel.measuredPixelSize !== undefined ? 0 : targetPanel.measuredPixelSize,
1160
- measuredPercentageSize: targetPanel.measuredPercentageSize !== undefined ? 0 : targetPanel.measuredPercentageSize,
1161
- }
1162
-
1163
- const newState = [...state]
1164
- newState[panelIndex] = updatedPanel
1165
-
1166
- const recalculated = recalculateFlexiblePanels(newState, action.containerSize)
1167
- if (action.containerSize > 0 && reconcilePanelSizesWithContainer(recalculated, action.containerSize)) {
1168
- return normalizePanelPercentages(recalculated)
1169
413
  }
1170
- return recalculated
414
+ return commit(state, allocate(panels, state.containerSize))
1171
415
  }
1172
- case "EXPAND_PANEL": {
1173
- logger.debug("EXPAND_PANEL", action.id, action.targetSize)
1174
- const panelIndex = state.findIndex((panel) => panel.id === action.id)
1175
- if (panelIndex === -1) {
1176
- return state
1177
- }
1178
-
1179
- const containerSize = action.containerSize
1180
416
 
1181
- const targetPanel = state[panelIndex]
1182
- // 折りたたみ不可または既に展開されている場合はスキップ
1183
- const directionAllowed = action.from === "start" ? targetPanel.collapseFromStart : targetPanel.collapseFromEnd
1184
- if (!(panelCanCollapse(targetPanel) && directionAllowed && targetPanel.collapsed)) {
417
+ case "EXPAND_PANEL": {
418
+ const index = state.panels.findIndex((panel) => panel.id === action.id)
419
+ if (index === -1 || (action.targetSize !== undefined && state.containerSize <= 0)) {
1185
420
  return state
1186
421
  }
1187
- if (targetPanel.collapsedByDirection && targetPanel.collapsedByDirection !== action.from) {
422
+ const panel = state.panels[index]
423
+ const allowed = action.from === "start" ? panel.collapseFromStart : panel.collapseFromEnd
424
+ if (!(allowed && panel.collapsed) || (panel.collapsedByDirection !== null && panel.collapsedByDirection !== action.from)) {
1188
425
  return state
1189
426
  }
1190
427
 
1191
- // 復元サイズを決定 (優先順: targetSize → sizeBeforeCollapse → measuredPixelSizeBeforeCollapse → preferredPixelSizeBeforeCollapse → originalPixelSize)
1192
- const candidateSizes = [action.targetSize, targetPanel.sizeBeforeCollapse, targetPanel.measuredPixelSizeBeforeCollapse, targetPanel.preferredPixelSizeBeforeCollapse, targetPanel.originalPixelSize]
1193
- const proposedSize = candidateSizes.find((value) => value !== undefined) ?? 0
1194
- const minSize = getConstraintInPixels(targetPanel.minSize, 0, containerSize)
1195
- const maxSize = getConstraintInPixels(targetPanel.maxSize, containerSize, containerSize)
1196
- const restoredSize = clamp(proposedSize, minSize, maxSize)
1197
- const restoredPercentage = toRoundedPercentage(restoredSize, containerSize)
1198
- const restoredMeasuredPixel = targetPanel.measuredPixelSizeBeforeCollapse ?? restoredSize
1199
- const restoredMeasuredPercentage = toRoundedPercentage(restoredMeasuredPixel, containerSize)
1200
- const restoredPreferredPercentage = targetPanel.preferredPercentageSizeBeforeCollapse ?? targetPanel.percentageSizeBeforeCollapse ?? restoredPercentage
1201
- const restoredPreferredPixel = targetPanel.preferredPixelSizeBeforeCollapse ?? targetPanel.preferredPixelSize ?? restoredSize
1202
-
1203
- // 復元サイズを反映したパネル情報を構築し測定値も再設定
1204
- const updatedPanel: PanelLayoutData = {
1205
- ...targetPanel,
428
+ const restored: PanelLayoutData = {
429
+ ...panel,
1206
430
  collapsed: false,
1207
431
  collapsedByDirection: null,
1208
- size: restoredSize,
1209
- percentageSize: restoredPercentage,
1210
- preferredPercentageSize: restoredPreferredPercentage,
1211
- preferredPixelSize: restoredPreferredPixel,
1212
- sizeBeforeCollapse: restoredSize,
1213
- percentageSizeBeforeCollapse: undefined,
1214
- preferredPercentageSizeBeforeCollapse: undefined,
1215
- preferredPixelSizeBeforeCollapse: undefined,
1216
- measuredPixelSize: restoredSize,
1217
- measuredPixelSizeBeforeCollapse: restoredMeasuredPixel,
1218
- measuredPercentageSize: restoredMeasuredPercentage,
432
+ preferredPixelSize: isPixelPanel(panel) ? panel.preferenceBeforeCollapse : panel.preferredPixelSize,
433
+ preferredPercentageSize: isPixelPanel(panel) ? panel.preferredPercentageSize : panel.preferenceBeforeCollapse,
1219
434
  }
1220
435
 
1221
- // 新しい状態配列を用意して対象パネルを差し替え
1222
- const newState = [...state]
1223
- newState[panelIndex] = updatedPanel
436
+ const expanded = [...state.panels]
437
+ expanded[index] = restored
438
+ const panels = action.targetSize === undefined ? expanded : withAbsoluteSize(expanded, action.id, action.targetSize, state.containerSize)
439
+ return commit(state, allocate(panels, state.containerSize))
440
+ }
1224
441
 
1225
- const recalculated = recalculateFlexiblePanels(newState, containerSize)
1226
- if (containerSize > 0 && reconcilePanelSizesWithContainer(recalculated, containerSize)) {
1227
- return normalizePanelPercentages(recalculated)
442
+ case "RESTORE_LAYOUT": {
443
+ if (action.entries.length !== state.panels.length || state.containerSize <= 0) {
444
+ return state
445
+ }
446
+ const stored = new Map(action.entries.map((entry) => [entry.id, entry]))
447
+ if (state.panels.some((panel) => !stored.has(panel.id))) {
448
+ // 保存時と構成が違う。位置で当てはめると別のパネルへ他人の寸法を与えてしまうため適用しない
449
+ return state
1228
450
  }
1229
- return recalculated
451
+ const panels = state.panels.map((panel) => {
452
+ const entry = stored.get(panel.id)
453
+ if (!entry) {
454
+ return panel
455
+ }
456
+ // 保存されているのは「開いているときに占める長さ」なので、折りたたみ状態に関わらず希望値として適用する
457
+ const restored = withPreferenceFromPixels(panel, entry.size, state.containerSize)
458
+ const canCollapse = panel.collapseFromStart || panel.collapseFromEnd
459
+ if (entry.collapsed && canCollapse) {
460
+ return { ...restored, collapsed: true, collapsedByDirection: panel.collapsedByDirection ?? initialCollapseDirection(panel) }
461
+ }
462
+ return restored.collapsed ? { ...restored, collapsed: false, collapsedByDirection: null } : restored
463
+ })
464
+ logger.debug("restore layout", { containerSize: state.containerSize, entries: action.entries })
465
+ return commit(state, allocate(panels, state.containerSize))
1230
466
  }
1231
- default:
467
+
468
+ default: {
1232
469
  return state
470
+ }
1233
471
  }
1234
472
  }