@aiquants/resize-panels 1.7.2 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/reducer.ts ADDED
@@ -0,0 +1,1234 @@
1
+ /**
2
+ * @file Reducer for managing panel state in PanelGroup
3
+ * PanelGroup のパネル状態を管理するための Reducer
4
+ */
5
+ import { roundHalfToEven } from "./roundHalfToEven"
6
+ import type { PanelCollapseDirection, PanelLayoutData } from "./types"
7
+ import { adjustPixelPanelsByPriority, clamp, getConstraintInPixels, PERCENTAGE_DIGITS, percentageToPixels, toRoundedPercentage } from "./utils"
8
+ import { Logger, LogLevel } from "./utils/simple-logger"
9
+
10
+ const logger = new Logger(LogLevel.INFO, "[resize-panels]")
11
+
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]
39
+ }
40
+
41
+ const panelCanCollapse = (panel: PanelLayoutData | undefined | null): boolean => {
42
+ if (!panel) {
43
+ return false
44
+ }
45
+ return Boolean(panel.collapseFromStart || panel.collapseFromEnd)
46
+ }
47
+
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[] = []
67
+
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
77
+ }
78
+
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
88
+
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)
96
+ }
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
+ }
110
+
111
+ if (descriptors.length === 0) {
112
+ return nextPanels
113
+ }
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
120
+ }
121
+
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
136
+ }
137
+
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
+ }
239
+ }
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)
287
+ }
288
+
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
+ }
313
+ }
314
+
315
+ return normalizePanelPercentages(nextPanels)
316
+ }
317
+
318
+ /**
319
+ * Normalize panel percentage snapshots so active panels sum to 100%.
320
+ * アクティブなパネルの割合が 100% になるようにスナップショットを正規化する処理。
321
+ */
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)
329
+ }
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
355
+ }
356
+
357
+ // パネル配列に変更が発生したかを示すフラグ
358
+ let mutated = false
359
+
360
+ const normalizedPanels = panels.map((panel) => {
361
+ // 折りたたまれたパネルの割合はゼロに固定
362
+ 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
+ return panel
372
+ }
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)) {
395
+ return panel
396
+ }
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
+ }
408
+ })
409
+
410
+ return mutated ? normalizedPanels : panels
411
+ }
412
+
413
+ type ActivePanelCandidate = {
414
+ index: number
415
+ minSize: number
416
+ maxSize: number
417
+ isFlexible: boolean
418
+ }
419
+
420
+ /**
421
+ * Reconcile active panel sizes so their total matches the container dimensions.
422
+ * コンテナ寸法に整合するようアクティブパネルのサイズ合計を補正する処理。
423
+ */
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
525
+ }
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
+ }
596
+ }
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
676
+ }
677
+
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
+ /**
688
+ * Assign priority for pixel adjustment if the panel is pixel-based and priority is undefined.
689
+ * ピクセルベースのパネルで優先度が未定義の場合に調整優先度を自動割り当てする関数。
690
+ *
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 / 優先度が割り当てられたパネル
695
+ */
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)
709
+
710
+ // 既存の最大優先度 + 1 を新しい優先度として割り当て (既存がない場合は 1)
711
+ const nextPriority = existingPriorities.length > 0 ? Math.max(...existingPriorities) + 1 : 1
712
+ return {
713
+ ...panel,
714
+ pixelAdjustPriority: nextPriority,
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Handle panel registration and size updates for the panel group.
720
+ * パネルグループ内の登録・サイズ更新を処理するリデューサー。
721
+ */
722
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Reducer logic
723
+ export const panelReducer = (state: PanelLayoutData[], action: PanelAction): PanelLayoutData[] => {
724
+ 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
776
+ }
777
+
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]
787
+ }
788
+ 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
+ })
801
+ }
802
+ return normalized
803
+ }
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
+
815
+ const newState = [...state]
816
+ const leftPanel = newState[leftIndex]
817
+ const rightPanel = newState[rightIndex]
818
+
819
+ // 折りたたまれたパネルがある場合はリサイズを中断
820
+ if (leftPanel.collapsed || rightPanel.collapsed) {
821
+ return state
822
+ }
823
+
824
+ // サイズ変化が無い場合はスキップして不要な再レンダリングを防止
825
+ if (action.leftSize === leftPanel.size && action.rightSize === rightPanel.size) {
826
+ return state
827
+ }
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) {
889
+ return state
890
+ }
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)
921
+ }
922
+
923
+ // 誤差範囲を超えて合計が変化する場合は状態を維持
924
+ const pairTotalAfter = finalLeftSize + finalRightSize
925
+ if (Math.abs(targetPairTotal - pairTotalAfter) > SIZE_EPSILON) {
926
+ return state
927
+ }
928
+
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
+ }
972
+ }
973
+
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
+ }
1056
+ }
1057
+
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
+ }
1068
+ return state
1069
+ }
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
+ }
1093
+ return state
1094
+ }
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
1126
+ }
1127
+ case "COLLAPSE_PANEL": {
1128
+ logger.debug("COLLAPSE_PANEL", action.id)
1129
+ const panelIndex = state.findIndex((panel) => panel.id === action.id)
1130
+ if (panelIndex === -1) {
1131
+ return state
1132
+ }
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) {
1139
+ return state
1140
+ }
1141
+
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,
1149
+ collapsed: true,
1150
+ 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
+ }
1170
+ return recalculated
1171
+ }
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
+
1181
+ const targetPanel = state[panelIndex]
1182
+ // 折りたたみ不可または既に展開されている場合はスキップ
1183
+ const directionAllowed = action.from === "start" ? targetPanel.collapseFromStart : targetPanel.collapseFromEnd
1184
+ if (!(panelCanCollapse(targetPanel) && directionAllowed && targetPanel.collapsed)) {
1185
+ return state
1186
+ }
1187
+ if (targetPanel.collapsedByDirection && targetPanel.collapsedByDirection !== action.from) {
1188
+ return state
1189
+ }
1190
+
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,
1206
+ collapsed: false,
1207
+ 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,
1219
+ }
1220
+
1221
+ // 新しい状態配列を用意して対象パネルを差し替え
1222
+ const newState = [...state]
1223
+ newState[panelIndex] = updatedPanel
1224
+
1225
+ const recalculated = recalculateFlexiblePanels(newState, containerSize)
1226
+ if (containerSize > 0 && reconcilePanelSizesWithContainer(recalculated, containerSize)) {
1227
+ return normalizePanelPercentages(recalculated)
1228
+ }
1229
+ return recalculated
1230
+ }
1231
+ default:
1232
+ return state
1233
+ }
1234
+ }