@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.
- package/README.md +213 -108
- package/dist/GlobalDebugOverlay-Cf3GOn2A.cjs +1 -0
- package/dist/{GlobalDebugOverlay-DYCR0FIu.js → GlobalDebugOverlay-DQtUdNeF.js} +28 -28
- package/dist/debugOverlayStore-D7FT6zPE.js +135 -0
- package/dist/debugOverlayStore-DY09saU8.cjs +1 -0
- package/dist/index-DZ0itiBD.js +1299 -0
- package/dist/index-DqATP_BA.cjs +2 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +2 -29
- package/dist/index.js +25 -13
- package/dist/src/GlobalDebugOverlay.d.ts.map +1 -0
- package/dist/src/Panel.d.ts +46 -0
- package/dist/src/Panel.d.ts.map +1 -0
- package/dist/src/PanelDebugInfo.d.ts +34 -0
- package/dist/src/PanelDebugInfo.d.ts.map +1 -0
- package/dist/src/PanelGroup.d.ts.map +1 -0
- package/dist/{PanelResizeHandle.d.ts → src/PanelResizeHandle.d.ts} +1 -1
- package/dist/src/PanelResizeHandle.d.ts.map +1 -0
- package/dist/src/allocateLayout.d.ts +60 -0
- package/dist/src/allocateLayout.d.ts.map +1 -0
- package/dist/src/context.d.ts.map +1 -0
- package/dist/{debugOverlayStore.d.ts → src/debugOverlayStore.d.ts} +10 -2
- package/dist/src/debugOverlayStore.d.ts.map +1 -0
- package/dist/src/hooks.d.ts +48 -0
- package/dist/src/hooks.d.ts.map +1 -0
- package/dist/src/index.d.ts +32 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/reducer.d.ts +49 -0
- package/dist/src/reducer.d.ts.map +1 -0
- package/dist/src/roundHalfToEven.d.ts.map +1 -0
- package/dist/src/types.d.ts +481 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/utils/simple-logger.d.ts.map +1 -0
- package/dist/src/utils.d.ts +164 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/styles/resize-panels.standalone.css +1 -1
- package/dist/tests/support/dom-harness.d.ts +89 -0
- package/dist/tests/support/dom-harness.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/GlobalDebugOverlay.tsx +3 -3
- package/src/Panel.tsx +203 -541
- package/src/PanelDebugInfo.tsx +52 -106
- package/src/PanelGroup.tsx +27 -42
- package/src/PanelResizeHandle.tsx +544 -504
- package/src/allocateLayout.ts +412 -0
- package/src/debugOverlayStore.ts +19 -15
- package/src/hooks.ts +187 -779
- package/src/index.ts +37 -6
- package/src/reducer.ts +364 -1126
- package/src/types.ts +282 -65
- package/src/utils.ts +205 -164
- package/dist/GlobalDebugOverlay-afs0vF-Q.cjs +0 -1
- package/dist/GlobalDebugOverlay.d.ts.map +0 -1
- package/dist/Panel.d.ts +0 -7
- package/dist/Panel.d.ts.map +0 -1
- package/dist/PanelDebugInfo.d.ts +0 -26
- package/dist/PanelDebugInfo.d.ts.map +0 -1
- package/dist/PanelGroup.d.ts.map +0 -1
- package/dist/PanelResizeHandle.d.ts.map +0 -1
- package/dist/context.d.ts.map +0 -1
- package/dist/debugOverlayStore-Cntyxboe.js +0 -141
- package/dist/debugOverlayStore-Dkl-fHoa.cjs +0 -1
- package/dist/debugOverlayStore.d.ts.map +0 -1
- package/dist/hooks.d.ts +0 -45
- package/dist/hooks.d.ts.map +0 -1
- package/dist/index-BzgId8aQ.js +0 -1972
- package/dist/index-xTV-c0PN.cjs +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/reducer.d.ts +0 -39
- package/dist/reducer.d.ts.map +0 -1
- package/dist/roundHalfToEven.d.ts.map +0 -1
- package/dist/types.d.ts +0 -253
- package/dist/types.d.ts.map +0 -1
- package/dist/utils/simple-logger.d.ts.map +0 -1
- package/dist/utils.d.ts +0 -61
- package/dist/utils.d.ts.map +0 -1
- /package/dist/{GlobalDebugOverlay.d.ts → src/GlobalDebugOverlay.d.ts} +0 -0
- /package/dist/{PanelGroup.d.ts → src/PanelGroup.d.ts} +0 -0
- /package/dist/{context.d.ts → src/context.d.ts} +0 -0
- /package/dist/{roundHalfToEven.d.ts → src/roundHalfToEven.d.ts} +0 -0
- /package/dist/{utils → src/utils}/simple-logger.d.ts +0 -0
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @file PanelResizeHandle component for custom resizable panels
|
|
3
3
|
* カスタムリサイズ可能パネルのための PanelResizeHandle コンポーネント
|
|
4
|
+
*
|
|
5
|
+
* The handle does not compute where the boundary is. It renders a zero-length flex item — the anchor —
|
|
6
|
+
* between its two panels, so flex layout itself places it exactly on the boundary, and the interactive
|
|
7
|
+
* button is drawn symmetrically around that anchor. Whatever the panels do (a container resize between two
|
|
8
|
+
* React renders, padding on the group, right-to-left text, a vertical axis), the handle is on the boundary
|
|
9
|
+
* because it *is* the boundary.
|
|
10
|
+
* ハンドルは境界位置を計算しない。2 枚のパネルの間に長さ 0 のフレックスアイテム (アンカー) を描画するため、
|
|
11
|
+
* フレックスレイアウト自身がそれを境界へ配置し、操作用ボタンはそのアンカーを中心に対称に描かれる。
|
|
12
|
+
* コンテナのリサイズ・グループのパディング・RTL・垂直方向のいずれであっても、ハンドルは境界そのものなので必ず一致する。
|
|
4
13
|
*/
|
|
5
14
|
|
|
6
15
|
import { type CSSProperties, memo, useCallback, useEffect, useId, useMemo, useRef, useState } from "react"
|
|
7
16
|
import { twMerge } from "tailwind-merge"
|
|
17
|
+
import { resolvePanelBounds } from "./allocateLayout"
|
|
8
18
|
import { usePanelGroup } from "./context"
|
|
9
19
|
import {
|
|
20
|
+
PANEL_ALLOCATION_EPSILON,
|
|
10
21
|
PANEL_COLLAPSE_THRESHOLD_MAX,
|
|
11
22
|
PANEL_COLLAPSE_THRESHOLD_RATIO,
|
|
12
23
|
PANEL_EXPAND_THRESHOLD_MAX,
|
|
@@ -14,297 +25,343 @@ import {
|
|
|
14
25
|
PANEL_HANDLE_DRAG_THROTTLE_MS,
|
|
15
26
|
PANEL_HANDLE_THICKNESS,
|
|
16
27
|
PANEL_HANDLE_VISIBILITY_THRESHOLD,
|
|
28
|
+
PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL,
|
|
29
|
+
PANEL_INDICATOR_BAR_LENGTH_VERTICAL,
|
|
30
|
+
PANEL_INDICATOR_BAR_THICKNESS,
|
|
31
|
+
PANEL_INDICATOR_LENGTH_HORIZONTAL,
|
|
32
|
+
PANEL_INDICATOR_LENGTH_VERTICAL,
|
|
33
|
+
PANEL_INDICATOR_THICKNESS,
|
|
17
34
|
PANEL_KEYBOARD_RESIZE_STEP,
|
|
18
35
|
PANEL_VISIBLE_INDICATOR_SPACING,
|
|
36
|
+
type PanelLayoutData,
|
|
19
37
|
type PanelResizeHandleProps,
|
|
20
38
|
type ResizeHandleLayoutData,
|
|
21
39
|
} from "./types"
|
|
22
|
-
import { calculateSnapThreshold,
|
|
40
|
+
import { calculateSnapThreshold, findOwningGroup, getGroupLayoutElements, getVisualScale } from "./utils"
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Styles that remove the handle and its anchor from layout and interaction.
|
|
44
|
+
* ハンドルとアンカーをレイアウトと操作の両方から外すスタイル。
|
|
45
|
+
*/
|
|
46
|
+
const HIDDEN_HANDLE_STYLE: CSSProperties = {
|
|
47
|
+
display: "none",
|
|
48
|
+
pointerEvents: "none",
|
|
49
|
+
visibility: "hidden",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Style that collapses the visual indicator.
|
|
54
|
+
* 視覚インジケーターを非表示にするスタイル。
|
|
55
|
+
*/
|
|
56
|
+
const HIDDEN_INDICATOR_STYLE: CSSProperties = {
|
|
57
|
+
display: "none",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Sanitize an optional dimension to ensure a positive finite number.
|
|
62
|
+
* オプション寸法を正の有限数値へ正規化する処理。
|
|
63
|
+
*/
|
|
64
|
+
const sanitizeDimension = (value: unknown, defaultValue: number): number => {
|
|
65
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
66
|
+
return defaultValue
|
|
67
|
+
}
|
|
68
|
+
return value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Ensure a numeric spacing value is an even integer for symmetric pixel alignment.
|
|
73
|
+
* ピクセル配置の対称性を担保するため数値を偶数整数に整流する処理。
|
|
74
|
+
*/
|
|
75
|
+
const ensureEven = (value: number): number => {
|
|
76
|
+
const rounded = Math.round(value)
|
|
77
|
+
return rounded % 2 === 0 ? rounded : rounded + 1
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Report whether an element is laid out right-to-left at this moment.
|
|
82
|
+
* その時点で要素が右から左へレイアウトされているかの判定。
|
|
83
|
+
*
|
|
84
|
+
* The direction is read at the start of each gesture instead of being cached, so a language switch takes
|
|
85
|
+
* effect without remounting the group.
|
|
86
|
+
* 方向はキャッシュせずジェスチャー開始のたびに読む。言語切替がグループの再マウント無しで反映されるようにするため。
|
|
87
|
+
*/
|
|
88
|
+
const isRightToLeft = (element: HTMLElement | null): boolean => {
|
|
89
|
+
if (!element || typeof window === "undefined") {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
return window.getComputedStyle(element).direction === "rtl"
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Travel range of the boundary a handle sits on, in pixels from the start of the panel pair.
|
|
97
|
+
* ハンドルが載る境界の可動域 (パネル対の start からのピクセル距離)。
|
|
98
|
+
*/
|
|
99
|
+
type SeparatorBounds = {
|
|
100
|
+
/** Where the boundary sits now / 境界の現在位置 */
|
|
101
|
+
position: number
|
|
102
|
+
/** Lowest position this handle can reach, collapsing included / このハンドルが到達できる最小位置 (折りたたみを含む) */
|
|
103
|
+
lower: number
|
|
104
|
+
/** Highest position this handle can reach, collapsing included / このハンドルが到達できる最大位置 (折りたたみを含む) */
|
|
105
|
+
upper: number
|
|
106
|
+
/** Lowest position a plain resize can reach / 単なるリサイズで到達できる最小位置 */
|
|
107
|
+
resizeLower: number
|
|
108
|
+
/** Highest position a plain resize can reach / 単なるリサイズで到達できる最大位置 */
|
|
109
|
+
resizeUpper: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve where the boundary between two panels is and how far it can travel.
|
|
114
|
+
* 2 枚のパネルの境界が今どこにあり、どこまで動けるかを解決する処理。
|
|
115
|
+
*
|
|
116
|
+
* The range is what **this** handle can reach, not what the pair could reach through any means. Collapsing
|
|
117
|
+
* is a position the boundary can hold, so it widens the range — but only in the direction this handle is
|
|
118
|
+
* allowed to collapse, and a panel already collapsed can only be reopened from the side it was closed from
|
|
119
|
+
* (`EXPAND_PANEL` rejects any other direction). A handle that can reach nothing therefore reports a range of
|
|
120
|
+
* zero width instead of advertising travel it would swallow the key press for.
|
|
121
|
+
* 公開するのは **このハンドル** が到達できる範囲であり、対が何らかの手段で到達しうる範囲ではない。
|
|
122
|
+
* 折りたたみは境界が取りうる位置なので範囲を広げるが、広がるのはこのハンドルが折りたためる向きだけで、
|
|
123
|
+
* 既に折りたたまれたパネルは閉じたのと同じ向きからしか開けない (`EXPAND_PANEL` が他の向きを拒否する)。
|
|
124
|
+
* どこへも動けないハンドルは、幅 0 の範囲を報告する (動けると偽って キー入力だけ飲み込まないために)。
|
|
125
|
+
*
|
|
126
|
+
* @param startPanel - Panel before the boundary / 境界より手前のパネル
|
|
127
|
+
* @param endPanel - Panel after the boundary / 境界より奥のパネル
|
|
128
|
+
* @param containerSize - Container content-box length / コンテナ内容領域長
|
|
129
|
+
* @returns Boundary position and its travel range, or undefined when the pair has no length / 境界位置と可動域、対に長さが無ければ undefined
|
|
130
|
+
*/
|
|
131
|
+
const resolveSeparatorBounds = (startPanel: PanelLayoutData, endPanel: PanelLayoutData, containerSize: number): SeparatorBounds | undefined => {
|
|
132
|
+
const startSize = startPanel.collapsed ? 0 : startPanel.size
|
|
133
|
+
const endSize = endPanel.collapsed ? 0 : endPanel.size
|
|
134
|
+
const total = startSize + endSize
|
|
135
|
+
if (total <= 0) {
|
|
136
|
+
return undefined
|
|
137
|
+
}
|
|
138
|
+
const startBounds = resolvePanelBounds(startPanel, containerSize)
|
|
139
|
+
const endBounds = resolvePanelBounds(endPanel, containerSize)
|
|
140
|
+
// 単なるリサイズで届く範囲は、双方の制約が同時に効いた区間 (reducer の RESIZE_PANELS と同じ式)
|
|
141
|
+
const resizeLower = Math.max(startBounds.min, total - endBounds.max)
|
|
142
|
+
const resizeUpper = Math.max(resizeLower, Math.min(startBounds.max, total - endBounds.min))
|
|
143
|
+
|
|
144
|
+
if (startPanel.collapsed) {
|
|
145
|
+
// 閉じた向きと同じ向きからしか開けない。手前のパネルはこのハンドルから見て "end" 側で閉じている
|
|
146
|
+
const canReopen = startPanel.collapsedByDirection === "end"
|
|
147
|
+
return { position: 0, lower: 0, upper: canReopen ? Math.max(0, resizeUpper) : 0, resizeLower, resizeUpper }
|
|
148
|
+
}
|
|
149
|
+
if (endPanel.collapsed) {
|
|
150
|
+
const canReopen = endPanel.collapsedByDirection === "start"
|
|
151
|
+
return { position: total, lower: canReopen ? Math.min(total, resizeLower) : total, upper: total, resizeLower, resizeUpper }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 折りたたみは境界が対の端へ到達した状態そのものなので、許される向きだけ範囲を端まで広げる
|
|
155
|
+
const lower = Math.min(startSize, startPanel.collapseFromEnd ? 0 : resizeLower)
|
|
156
|
+
const upper = Math.max(startSize, lower, endPanel.collapseFromStart ? total : resizeUpper)
|
|
157
|
+
return { position: startSize, lower, upper, resizeLower, resizeUpper }
|
|
158
|
+
}
|
|
23
159
|
|
|
24
160
|
/**
|
|
25
161
|
* Render an interactive divider that manages adjacent panel resizing gestures.
|
|
26
162
|
* 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
|
|
27
163
|
*/
|
|
28
|
-
export const PanelResizeHandle = memo((
|
|
29
|
-
const {
|
|
164
|
+
export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
165
|
+
const { id, disabled = false, className, style, children, onDragging, thickness, indicator = true, indicatorThickness, indicatorLength, indicatorBarThickness, indicatorBarLength, title, onPointerDown, onKeyDown, ...elementProps } = props
|
|
166
|
+
const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, layoutOrderKey, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
|
|
167
|
+
|
|
30
168
|
const [isDragging, setIsDragging] = useState(false)
|
|
31
|
-
const
|
|
32
|
-
const containerRef = useRef<HTMLElement | null>(null)
|
|
169
|
+
const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ start: string | null; end: string | null }>({ start: null, end: null })
|
|
33
170
|
|
|
34
|
-
const
|
|
35
|
-
const
|
|
171
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
172
|
+
const handleRef = useRef<HTMLButtonElement>(null)
|
|
173
|
+
// ドラッグ中の move ハンドラーは開始時のクロージャで動き続けるため、最新の状態は ref 経由で読む。
|
|
174
|
+
// これが無いと、同じジェスチャーの中で折りたたんだパネルを引き戻しても展開できない
|
|
175
|
+
const layoutRef = useRef({ getPanel, containerSize })
|
|
176
|
+
layoutRef.current = { getPanel, containerSize }
|
|
177
|
+
const startPanelIdRef = useRef("")
|
|
178
|
+
const endPanelIdRef = useRef("")
|
|
36
179
|
// 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
|
|
37
180
|
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ left: string | null; right: string | null }>({ left: null, right: null })
|
|
181
|
+
const lastMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
|
|
182
|
+
|
|
41
183
|
const generatedId = useId()
|
|
42
184
|
const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
|
|
43
|
-
const lastHandleMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
|
|
44
185
|
const resolvedHandleId = id ?? handleIdRef.current
|
|
186
|
+
const isHorizontal = direction === "horizontal"
|
|
45
187
|
const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
|
|
46
188
|
const isHandleDisabled = disabled || hasLayoutConstraintViolation
|
|
47
189
|
|
|
48
190
|
/**
|
|
49
|
-
* Detect the panels
|
|
50
|
-
* DOM
|
|
191
|
+
* Detect the panels this handle sits between by scanning its siblings in the DOM.
|
|
192
|
+
* DOM の兄弟要素を走査して、このハンドルが挟んでいるパネルを検出する処理。
|
|
51
193
|
*/
|
|
52
194
|
const detectAdjacentPanels = useCallback(() => {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
containerRef.current = parent
|
|
60
|
-
if (typeof window !== "undefined") {
|
|
61
|
-
setIsRtl(window.getComputedStyle(parent).direction === "rtl")
|
|
195
|
+
const anchor = anchorRef.current
|
|
196
|
+
// 中間要素が挟まると親はグループではないため、所有グループを辿って解決する
|
|
197
|
+
const group = findOwningGroup(anchor)
|
|
198
|
+
if (!(anchor && group)) {
|
|
199
|
+
return
|
|
62
200
|
}
|
|
63
|
-
const childrenElements = Array.from(parent.children)
|
|
64
|
-
const index = childrenElements.indexOf(handleElement)
|
|
65
201
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
for (let
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
|
|
202
|
+
const elements = getGroupLayoutElements(group)
|
|
203
|
+
const index = elements.indexOf(anchor)
|
|
204
|
+
let startId = ""
|
|
205
|
+
let endId = ""
|
|
206
|
+
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
|
|
207
|
+
const candidate = elements[cursor].dataset.panelId
|
|
208
|
+
if (candidate !== undefined) {
|
|
209
|
+
startId = candidate
|
|
74
210
|
break
|
|
75
211
|
}
|
|
76
212
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (element.dataset.panelId) {
|
|
82
|
-
rightPanelElement = element
|
|
213
|
+
for (let cursor = index + 1; cursor < elements.length; cursor += 1) {
|
|
214
|
+
const candidate = elements[cursor].dataset.panelId
|
|
215
|
+
if (candidate !== undefined) {
|
|
216
|
+
endId = candidate
|
|
83
217
|
break
|
|
84
218
|
}
|
|
85
219
|
}
|
|
86
220
|
|
|
87
|
-
|
|
88
|
-
const nextRightId = rightPanelElement?.dataset.panelId || ""
|
|
89
|
-
|
|
90
|
-
// 検出結果が前回と同じなら state 更新を省略する
|
|
91
|
-
if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
|
|
221
|
+
if (startId === startPanelIdRef.current && endId === endPanelIdRef.current) {
|
|
92
222
|
return
|
|
93
223
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
setAdjacentPanelIds({
|
|
99
|
-
left: nextLeftId || null,
|
|
100
|
-
right: nextRightId || null,
|
|
101
|
-
})
|
|
224
|
+
startPanelIdRef.current = startId
|
|
225
|
+
endPanelIdRef.current = endId
|
|
226
|
+
setAdjacentPanelIds({ start: startId || null, end: endId || null })
|
|
102
227
|
}, [])
|
|
103
228
|
|
|
104
|
-
// パネル構成 (ID 列) の変化を検知するためのキー。ドラッグ中のサイズ更新では変化しない
|
|
105
|
-
const panelIdsKey = useMemo(() => panels.map((panel) => panel.id).join("|"), [panels])
|
|
106
|
-
|
|
107
229
|
useEffect(() => {
|
|
108
|
-
//
|
|
109
|
-
//
|
|
230
|
+
// マウント時と、グループの DOM 順が変わるたびに隣接パネルを再検出する。
|
|
231
|
+
// パネルの id 列だけを見ていると、ハンドル自身が別の境界へ移った並べ替えを取りこぼし、
|
|
232
|
+
// 見た目とは違う 2 枚を掴み続ける
|
|
110
233
|
detectAdjacentPanels()
|
|
111
|
-
}, [detectAdjacentPanels,
|
|
234
|
+
}, [detectAdjacentPanels, layoutOrderKey])
|
|
112
235
|
|
|
113
236
|
useEffect(() => {
|
|
114
|
-
// ハンドル ID が外部から変更された場合に、旧 ID の測定結果をクリーンアップする
|
|
115
|
-
if (resolvedHandleId === handleIdRef.current) {
|
|
116
|
-
return
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const previousId = handleIdRef.current
|
|
120
|
-
handleIdRef.current = resolvedHandleId
|
|
121
|
-
lastHandleMeasurementRef.current = null
|
|
122
|
-
|
|
123
|
-
if (previousId !== resolvedHandleId) {
|
|
124
|
-
reportHandleMeasurement(previousId, null)
|
|
125
|
-
}
|
|
126
|
-
}, [reportHandleMeasurement, resolvedHandleId])
|
|
127
|
-
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
// コンポーネントのアンマウント時に、このハンドルの測定結果を破棄する
|
|
130
|
-
const finalId = handleIdRef.current
|
|
131
|
-
return () => {
|
|
132
|
-
reportHandleMeasurement(finalId, null)
|
|
133
|
-
}
|
|
134
|
-
}, [reportHandleMeasurement])
|
|
135
|
-
|
|
136
|
-
useEffect(() => {
|
|
137
|
-
// ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
|
|
138
237
|
return () => {
|
|
238
|
+
// ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
|
|
139
239
|
dragCleanupRef.current?.()
|
|
140
240
|
}
|
|
141
241
|
}, [])
|
|
142
242
|
|
|
143
243
|
/**
|
|
144
|
-
*
|
|
244
|
+
* Start a resize drag when the handle receives a pointer down event.
|
|
145
245
|
* リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
|
|
146
246
|
*/
|
|
147
247
|
const handlePointerDown = useCallback(
|
|
148
248
|
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
149
|
-
if (isHandleDisabled
|
|
150
|
-
|
|
249
|
+
if (isHandleDisabled) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
151
252
|
// 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
|
|
152
|
-
if (dragCleanupRef.current)
|
|
253
|
+
if (dragCleanupRef.current) {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
// 主ボタン以外は掴まない。右ボタンで掴むと、コンテキストメニューを開きながらレイアウトが動く
|
|
257
|
+
// (タッチ・ペンの pointerdown も主ボタンとして 0 が届く)
|
|
258
|
+
if (event.button !== 0) {
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const startPanel = getPanel(startPanelIdRef.current)
|
|
263
|
+
const endPanel = getPanel(endPanelIdRef.current)
|
|
264
|
+
if (!(startPanel && endPanel)) {
|
|
265
|
+
return
|
|
266
|
+
}
|
|
153
267
|
|
|
154
268
|
event.preventDefault()
|
|
155
269
|
event.stopPropagation()
|
|
156
270
|
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
let lastUpdateTime = 0
|
|
170
|
-
const throttleMs = PANEL_HANDLE_DRAG_THROTTLE_MS
|
|
171
|
-
|
|
172
|
-
setIsDragging(true)
|
|
173
|
-
onDragging?.(true)
|
|
271
|
+
const startId = startPanelIdRef.current
|
|
272
|
+
const endId = endPanelIdRef.current
|
|
273
|
+
const initialStartSize = startPanel.size
|
|
274
|
+
const initialTotal = startPanel.size + endPanel.size
|
|
275
|
+
const origin = isHorizontal ? event.clientX : event.clientY
|
|
276
|
+
const group = findOwningGroup(anchorRef.current)
|
|
277
|
+
// RTL では DOM 順 (論理順) と視覚順が反転するため、水平ドラッグの変位符号を反転する
|
|
278
|
+
const directionSign = isHorizontal && isRightToLeft(group) ? -1 : 1
|
|
279
|
+
// ポインタ座標は視覚ピクセル、配分器が扱うのはレイアウトピクセル。
|
|
280
|
+
// 変形された祖先の下で換算を省くと、掴んだ点がカーソルから滑り出していく
|
|
281
|
+
const visualScale = group ? getVisualScale(group, direction) : 1
|
|
174
282
|
|
|
175
|
-
// このドラッグを開始したポインタだけを追跡する (2 本目のタッチ等の混入を防ぐ)
|
|
176
283
|
const dragPointerId = event.pointerId
|
|
177
284
|
const captureTarget = event.currentTarget
|
|
285
|
+
// 追従は document の pointermove / pointerup で行うため、キャプチャは無くても成立する。
|
|
286
|
+
// 実装しない環境 (jsdom など) で例外を投げると、利用者側のテストがドラッグを検証できなくなる
|
|
287
|
+
captureTarget.setPointerCapture?.(dragPointerId)
|
|
178
288
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// ポインタキャプチャを取得してドラッグ中のイベント配送を固定
|
|
183
|
-
captureTarget.setPointerCapture(dragPointerId)
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Handle pointer move events emitted during an active resize drag interaction.
|
|
187
|
-
* リサイズドラッグ操作中に発生するポインタ移動イベントを処理する関数。
|
|
188
|
-
*/
|
|
189
|
-
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
190
|
-
// 別ポインタのイベントはドラッグ計算に混入させない
|
|
191
|
-
if (moveEvent.pointerId !== dragPointerId) return
|
|
192
|
-
if (!handleRef.current) return
|
|
289
|
+
setIsDragging(true)
|
|
290
|
+
onDragging?.(true)
|
|
193
291
|
|
|
194
|
-
|
|
195
|
-
|
|
292
|
+
let lastUpdateTime = 0
|
|
293
|
+
let throttledMove: PointerEvent | null = null
|
|
196
294
|
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
295
|
+
const applyMove = (moveEvent: PointerEvent) => {
|
|
296
|
+
const delta = (((isHorizontal ? moveEvent.clientX : moveEvent.clientY) - origin) * directionSign) / visualScale
|
|
297
|
+
const nextStartSize = initialStartSize + delta
|
|
298
|
+
const nextEndSize = initialTotal - nextStartSize
|
|
201
299
|
|
|
202
|
-
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (pixelDelta === 0) {
|
|
300
|
+
const layout = layoutRef.current
|
|
301
|
+
const currentStart = layout.getPanel(startId)
|
|
302
|
+
const currentEnd = layout.getPanel(endId)
|
|
303
|
+
if (!(currentStart && currentEnd)) {
|
|
207
304
|
return
|
|
208
305
|
}
|
|
209
306
|
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
// 最新のパネル状態を取得して折りたたみ状態などの変化を反映
|
|
214
|
-
const updatedLeftPanel = getPanel(leftPanelId.current)
|
|
215
|
-
const updatedRightPanel = getPanel(rightPanelId.current)
|
|
216
|
-
|
|
217
|
-
if (updatedLeftPanel && updatedRightPanel && leftPanelId.current && rightPanelId.current) {
|
|
218
|
-
const leftMinSizePixels = getConstraintInPixels(updatedLeftPanel.minSize, 0, containerSize)
|
|
219
|
-
const rightMinSizePixels = getConstraintInPixels(updatedRightPanel.minSize, 0, containerSize)
|
|
220
|
-
const leftMinConstraintPixels = leftMinSizePixels
|
|
221
|
-
const rightMinConstraintPixels = rightMinSizePixels
|
|
222
|
-
|
|
223
|
-
// 折りたたみ・展開の閾値をコンテナサイズから算出
|
|
224
|
-
const collapseThresholdPixels = Math.min(containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
|
|
225
|
-
const expandThresholdPixels = Math.min(containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
|
|
226
|
-
|
|
227
|
-
const rightCanCollapseFromStart = updatedRightPanel.collapseFromStart
|
|
228
|
-
const rightCollapseDirection = updatedRightPanel.collapsedByDirection ?? "start"
|
|
229
|
-
// 右パネルが折りたたみ可能で、新しいサイズが閾値を下回る場合は折りたたむ
|
|
230
|
-
if (rightCanCollapseFromStart && !updatedRightPanel.collapsed && newRightSizeInPixels <= Math.max(rightMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
231
|
-
collapsePanel(rightPanelId.current, "start")
|
|
232
|
-
return
|
|
233
|
-
}
|
|
307
|
+
const collapseThreshold = Math.min(layout.containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
|
|
308
|
+
const expandThreshold = Math.min(layout.containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
|
|
234
309
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (leftCanCollapseFromEnd && !updatedLeftPanel.collapsed && newLeftSizeInPixels <= Math.max(leftMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
239
|
-
collapsePanel(leftPanelId.current, "end")
|
|
240
|
-
return
|
|
310
|
+
if (currentEnd.collapsed) {
|
|
311
|
+
if (nextEndSize > expandThreshold) {
|
|
312
|
+
expandPanel(endId, "start", nextEndSize)
|
|
241
313
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// 左パネルが折りたたまれており、新しいサイズが展開閾値を超えた場合は展開
|
|
250
|
-
if (updatedLeftPanel.collapsed && newLeftSizeInPixels > Math.max(leftMinSizePixels * 0.3, expandThresholdPixels)) {
|
|
251
|
-
expandPanel(leftPanelId.current, leftCollapseDirection, newLeftSizeInPixels)
|
|
252
|
-
return
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (currentStart.collapsed) {
|
|
317
|
+
if (nextStartSize > expandThreshold) {
|
|
318
|
+
expandPanel(startId, "end", nextStartSize)
|
|
253
319
|
}
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
if (currentEnd.collapseFromStart && nextEndSize <= collapseThreshold) {
|
|
323
|
+
collapsePanel(endId, "start")
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (currentStart.collapseFromEnd && nextStartSize <= collapseThreshold) {
|
|
327
|
+
collapsePanel(startId, "end")
|
|
328
|
+
return
|
|
329
|
+
}
|
|
254
330
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const totalPanelPixels = updatedLeftPanel.size + updatedRightPanel.size
|
|
258
|
-
// コンテナ比率と定数に基づきゼロスナップの閾値を算出
|
|
259
|
-
const snapThreshold = calculateSnapThreshold(containerSize)
|
|
260
|
-
let snapped = false
|
|
261
|
-
|
|
262
|
-
// 左パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
|
|
263
|
-
if (leftMinConstraintPixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
|
|
264
|
-
const snappedLeft = 0
|
|
265
|
-
const snappedRight = Math.max(0, totalPanelPixels - snappedLeft)
|
|
266
|
-
|
|
267
|
-
if (snappedRight >= rightMinConstraintPixels) {
|
|
268
|
-
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
269
|
-
snapped = true
|
|
270
|
-
}
|
|
271
|
-
} else if (rightMinConstraintPixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
|
|
272
|
-
// 右パネルがスナップ可能で、新しいサイズがスナップ閾値以下の場合はゼロへスナップ
|
|
273
|
-
const snappedRight = 0
|
|
274
|
-
const snappedLeft = Math.max(0, totalPanelPixels - snappedRight)
|
|
275
|
-
|
|
276
|
-
if (snappedLeft >= leftMinConstraintPixels) {
|
|
277
|
-
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
278
|
-
snapped = true
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (snapped) {
|
|
283
|
-
return
|
|
284
|
-
}
|
|
285
|
-
}
|
|
331
|
+
resizePanels(startId, endId, nextStartSize)
|
|
332
|
+
}
|
|
286
333
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
334
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
335
|
+
// 別ポインタのイベントはドラッグ計算に混入させない
|
|
336
|
+
if (moveEvent.pointerId !== dragPointerId) {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
moveEvent.preventDefault()
|
|
340
|
+
moveEvent.stopPropagation()
|
|
291
341
|
|
|
292
|
-
|
|
293
|
-
|
|
342
|
+
const now = performance.now()
|
|
343
|
+
if (now - lastUpdateTime < PANEL_HANDLE_DRAG_THROTTLE_MS) {
|
|
344
|
+
// 間引いた移動は捨てずに保持する。離上時に反映しないと、指を離した位置と確定した境界がずれる
|
|
345
|
+
throttledMove = moveEvent
|
|
346
|
+
return
|
|
294
347
|
}
|
|
348
|
+
lastUpdateTime = now
|
|
349
|
+
throttledMove = null
|
|
350
|
+
applyMove(moveEvent)
|
|
295
351
|
}
|
|
296
352
|
|
|
297
353
|
/**
|
|
298
354
|
* Tear down the active drag: remove listeners, release capture, and reset state.
|
|
299
|
-
*
|
|
355
|
+
* 進行中ドラッグの後始末 (リスナー除去・キャプチャ解放・状態リセット)。
|
|
300
356
|
* pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
|
|
301
357
|
*/
|
|
302
358
|
let dragActive = true
|
|
303
359
|
const stopDragging = () => {
|
|
304
|
-
if (!dragActive)
|
|
360
|
+
if (!dragActive) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
305
363
|
dragActive = false
|
|
306
364
|
dragCleanupRef.current = null
|
|
307
|
-
|
|
308
365
|
setIsDragging(false)
|
|
309
366
|
onDragging?.(false)
|
|
310
367
|
|
|
@@ -319,20 +376,27 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
|
|
|
319
376
|
|
|
320
377
|
const handlePointerUp = (upEvent: PointerEvent) => {
|
|
321
378
|
// 別ポインタの離上ではドラッグを終了しない
|
|
322
|
-
if (upEvent.pointerId !== dragPointerId)
|
|
379
|
+
if (upEvent.pointerId !== dragPointerId) {
|
|
380
|
+
return
|
|
381
|
+
}
|
|
382
|
+
if (throttledMove) {
|
|
383
|
+
const pending = throttledMove
|
|
384
|
+
throttledMove = null
|
|
385
|
+
applyMove(pending)
|
|
386
|
+
}
|
|
323
387
|
stopDragging()
|
|
324
388
|
}
|
|
325
|
-
|
|
326
389
|
const handlePointerCancel = (cancelEvent: PointerEvent) => {
|
|
327
390
|
// OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
|
|
328
|
-
if (cancelEvent.pointerId
|
|
329
|
-
|
|
391
|
+
if (cancelEvent.pointerId === dragPointerId) {
|
|
392
|
+
stopDragging()
|
|
393
|
+
}
|
|
330
394
|
}
|
|
331
|
-
|
|
332
395
|
const handleLostPointerCapture = (lostEvent: PointerEvent) => {
|
|
333
396
|
// キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
|
|
334
|
-
if (lostEvent.pointerId
|
|
335
|
-
|
|
397
|
+
if (lostEvent.pointerId === dragPointerId) {
|
|
398
|
+
stopDragging()
|
|
399
|
+
}
|
|
336
400
|
}
|
|
337
401
|
|
|
338
402
|
document.addEventListener("pointermove", handlePointerMove)
|
|
@@ -341,169 +405,103 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
|
|
|
341
405
|
captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
|
|
342
406
|
dragCleanupRef.current = stopDragging
|
|
343
407
|
},
|
|
344
|
-
[isHandleDisabled,
|
|
408
|
+
[isHandleDisabled, isHorizontal, direction, getPanel, resizePanels, collapsePanel, expandPanel, onDragging],
|
|
345
409
|
)
|
|
346
410
|
|
|
347
411
|
/**
|
|
348
412
|
* Resize adjacent panels with arrow keys for keyboard accessibility.
|
|
349
413
|
* キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
|
|
350
|
-
* Shift 併用で 5 倍のステップ幅を適用する。
|
|
351
414
|
*/
|
|
352
415
|
const handleKeyDown = useCallback(
|
|
353
416
|
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
354
|
-
if (isHandleDisabled)
|
|
417
|
+
if (isHandleDisabled) {
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
const decreaseKey = isHorizontal ? "ArrowLeft" : "ArrowUp"
|
|
421
|
+
const increaseKey = isHorizontal ? "ArrowRight" : "ArrowDown"
|
|
422
|
+
if (event.key !== decreaseKey && event.key !== increaseKey) {
|
|
423
|
+
return
|
|
424
|
+
}
|
|
355
425
|
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
426
|
+
const startId = startPanelIdRef.current
|
|
427
|
+
const endId = endPanelIdRef.current
|
|
428
|
+
const startPanel = getPanel(startId)
|
|
429
|
+
const endPanel = getPanel(endId)
|
|
430
|
+
if (!(startPanel && endPanel)) {
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
|
|
434
|
+
// どこへも動けないハンドルはキーを飲み込まない。飲み込むと、何も起きないまま
|
|
435
|
+
// ページのスクロールまで止まる「効かないキー」になる
|
|
436
|
+
if (!bounds || bounds.upper - bounds.lower <= PANEL_ALLOCATION_EPSILON) {
|
|
437
|
+
return
|
|
438
|
+
}
|
|
360
439
|
|
|
361
440
|
event.preventDefault()
|
|
362
441
|
event.stopPropagation()
|
|
363
442
|
|
|
364
|
-
|
|
365
|
-
// RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
[isHandleDisabled, direction, getPanel, resizePanels],
|
|
379
|
-
)
|
|
380
|
-
|
|
381
|
-
const isHorizontal = direction === "horizontal"
|
|
382
|
-
const cursor = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
|
|
383
|
-
|
|
384
|
-
const baseHandleClasses = twMerge(
|
|
385
|
-
"group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
|
|
386
|
-
cursor,
|
|
387
|
-
isHorizontal ? "h-full" : "w-full",
|
|
388
|
-
!isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
|
|
389
|
-
!isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
|
|
390
|
-
isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
|
|
391
|
-
isHandleDisabled && "cursor-not-allowed opacity-50",
|
|
392
|
-
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
|
|
393
|
-
className,
|
|
394
|
-
)
|
|
395
|
-
|
|
396
|
-
const adjacentIndices = useMemo(() => {
|
|
397
|
-
// パネル ID からパネル配列内のインデックスを探索するヘルパー関数
|
|
398
|
-
const findIndex = (panelId: string | null) => {
|
|
399
|
-
if (!panelId) {
|
|
400
|
-
return -1
|
|
443
|
+
const step = event.shiftKey ? PANEL_KEYBOARD_RESIZE_STEP * 5 : PANEL_KEYBOARD_RESIZE_STEP
|
|
444
|
+
// RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する
|
|
445
|
+
const directionSign = isHorizontal && isRightToLeft(findOwningGroup(anchorRef.current)) ? -1 : 1
|
|
446
|
+
const delta = (event.key === increaseKey ? step : -step) * directionSign
|
|
447
|
+
|
|
448
|
+
// 折りたたまれたパネルは、境界を自分の方へ押し返すキーで開く。
|
|
449
|
+
// 開けないと、キーボードだけを使う利用者にとって折りたたみは戻れない行き止まりになる
|
|
450
|
+
if (startPanel.collapsed) {
|
|
451
|
+
if (delta > 0) {
|
|
452
|
+
// 目標長を渡さずに開く: 折りたたむ前の長さへ戻すのが「開く」の意味であり、
|
|
453
|
+
// 1 ステップ分だけ開いても最小値へ切り上げられるだけで何も伝わらない
|
|
454
|
+
expandPanel(startId, "end")
|
|
455
|
+
}
|
|
456
|
+
return
|
|
401
457
|
}
|
|
402
|
-
|
|
403
|
-
if (
|
|
404
|
-
|
|
458
|
+
if (endPanel.collapsed) {
|
|
459
|
+
if (delta < 0) {
|
|
460
|
+
expandPanel(endId, "start")
|
|
405
461
|
}
|
|
462
|
+
return
|
|
406
463
|
}
|
|
407
|
-
return -1
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
return {
|
|
411
|
-
left: findIndex(adjacentPanelIds.left),
|
|
412
|
-
right: findIndex(adjacentPanelIds.right),
|
|
413
|
-
}
|
|
414
|
-
}, [adjacentPanelIds.left, adjacentPanelIds.right, panels])
|
|
415
464
|
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
return 0
|
|
465
|
+
// リサイズで届く端まで来たパネルをさらに押すキーは、ドラッグで端まで引いたときと同じく折りたたみになる。
|
|
466
|
+
// 判定に使うのはパネル自身の最小値ではなく対の可動域の端である。隣の上限が先に効く構成では、
|
|
467
|
+
// パネルは自分の最小値へ決して到達できず、キーボードからは永久に折りたためなくなる
|
|
468
|
+
if (delta < 0 && startPanel.collapseFromEnd && bounds.position <= bounds.resizeLower + PANEL_ALLOCATION_EPSILON) {
|
|
469
|
+
collapsePanel(startId, "end")
|
|
470
|
+
return
|
|
423
471
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
if (!panel) {
|
|
428
|
-
continue
|
|
429
|
-
}
|
|
430
|
-
const candidateSize = panel.size ?? 0
|
|
431
|
-
const sanitizedSize = Math.max(0, candidateSize)
|
|
432
|
-
total += panel.collapsed ? 0 : sanitizedSize
|
|
472
|
+
if (delta > 0 && endPanel.collapseFromStart && bounds.position >= bounds.resizeUpper - PANEL_ALLOCATION_EPSILON) {
|
|
473
|
+
collapsePanel(endId, "start")
|
|
474
|
+
return
|
|
433
475
|
}
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return safeSum(0, adjacentIndices.left)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
if (adjacentIndices.right > 0) {
|
|
442
|
-
return safeSum(0, adjacentIndices.right - 1)
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
return 0
|
|
446
|
-
}, [adjacentIndices.left, adjacentIndices.right, panels])
|
|
447
|
-
|
|
448
|
-
const clampedOffset = useMemo(() => {
|
|
449
|
-
// 境界位置が無効値やコンテナ範囲外に逸脱した場合に補正
|
|
450
|
-
if (!Number.isFinite(boundaryOffset) || boundaryOffset < 0) {
|
|
451
|
-
return 0
|
|
452
|
-
}
|
|
453
|
-
if (containerLength > 0 && boundaryOffset > containerLength) {
|
|
454
|
-
return containerLength
|
|
455
|
-
}
|
|
456
|
-
return boundaryOffset
|
|
457
|
-
}, [boundaryOffset, containerLength])
|
|
476
|
+
resizePanels(startId, endId, startPanel.size + delta)
|
|
477
|
+
},
|
|
478
|
+
[isHandleDisabled, isHorizontal, getPanel, resizePanels, collapsePanel, expandPanel, containerSize],
|
|
479
|
+
)
|
|
458
480
|
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
position: "absolute",
|
|
462
|
-
zIndex: 20,
|
|
463
|
-
touchAction: "none",
|
|
464
|
-
}
|
|
481
|
+
const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
|
|
482
|
+
const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
|
|
465
483
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
base.left = isRtl && containerLength > 0 ? `${containerLength - clampedOffset}px` : `${clampedOffset}px`
|
|
470
|
-
base.transform = "translateX(-50%)"
|
|
471
|
-
base.width = `${PANEL_HANDLE_THICKNESS}px`
|
|
472
|
-
base.height = "100%"
|
|
473
|
-
} else {
|
|
474
|
-
base.left = 0
|
|
475
|
-
base.top = `${clampedOffset}px`
|
|
476
|
-
base.transform = "translateY(-50%)"
|
|
477
|
-
base.height = `${PANEL_HANDLE_THICKNESS}px`
|
|
478
|
-
base.width = "100%"
|
|
479
|
-
}
|
|
484
|
+
const resolvedThickness = Math.max(1, sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS))
|
|
485
|
+
const defaultIndicatorLength = isHorizontal ? PANEL_INDICATOR_LENGTH_HORIZONTAL : PANEL_INDICATOR_LENGTH_VERTICAL
|
|
486
|
+
const defaultBarLength = isHorizontal ? PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL : PANEL_INDICATOR_BAR_LENGTH_VERTICAL
|
|
480
487
|
|
|
481
|
-
|
|
482
|
-
|
|
488
|
+
// 幾何学的内包不変条件: 内部バー寸法がインジケーター外枠およびハンドル操作領域を超えないようクランプ
|
|
489
|
+
const resolvedIndicatorThickness = Math.min(resolvedThickness, Math.max(1, sanitizeDimension(indicatorThickness ?? indicatorConfig?.thickness, PANEL_INDICATOR_THICKNESS)))
|
|
490
|
+
const resolvedIndicatorLength = Math.max(1, sanitizeDimension(indicatorLength ?? indicatorConfig?.length, defaultIndicatorLength))
|
|
491
|
+
const resolvedBarThickness = Math.min(resolvedIndicatorThickness, Math.max(1, sanitizeDimension(indicatorBarThickness ?? indicatorConfig?.barThickness, PANEL_INDICATOR_BAR_THICKNESS)))
|
|
492
|
+
const resolvedBarLength = Math.min(resolvedIndicatorLength, Math.max(1, sanitizeDimension(indicatorBarLength ?? indicatorConfig?.barLength, defaultBarLength)))
|
|
483
493
|
|
|
484
494
|
const indicatorGrouping = useMemo(() => {
|
|
485
|
-
|
|
486
|
-
|
|
495
|
+
// 隣接パネルが閉じていると複数のハンドルが同じ境界へ重なるため、その重なり具合を数える
|
|
496
|
+
const snapThreshold = calculateSnapThreshold(containerSize)
|
|
487
497
|
const isEffectivelyCollapsed = (panelIndex: number) => {
|
|
488
|
-
if (panelIndex < 0 || panelIndex >= panels.length) {
|
|
489
|
-
return false
|
|
490
|
-
}
|
|
491
498
|
const target = panels[panelIndex]
|
|
492
499
|
if (!target) {
|
|
493
500
|
return false
|
|
494
501
|
}
|
|
495
|
-
|
|
496
|
-
return true
|
|
497
|
-
}
|
|
498
|
-
return target.size <= snapThreshold
|
|
502
|
+
return target.collapsed || target.size <= snapThreshold
|
|
499
503
|
}
|
|
500
|
-
|
|
501
|
-
// 指定インデックスから連続する折りたたみパネルの数を数える
|
|
502
504
|
const countCollapsed = (startIndex: number, step: 1 | -1) => {
|
|
503
|
-
if (!isEffectivelyCollapsed(startIndex)) {
|
|
504
|
-
return 0
|
|
505
|
-
}
|
|
506
|
-
|
|
507
505
|
let count = 0
|
|
508
506
|
for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
|
|
509
507
|
if (!isEffectivelyCollapsed(index)) {
|
|
@@ -514,205 +512,247 @@ export const PanelResizeHandle = memo(({ id, disabled = false, className, style,
|
|
|
514
512
|
return count
|
|
515
513
|
}
|
|
516
514
|
|
|
517
|
-
const
|
|
518
|
-
const
|
|
519
|
-
const
|
|
520
|
-
const
|
|
521
|
-
const groupSize = leftCollapsedCount + rightCollapsedCount + 1
|
|
522
|
-
const overlapIndex = leftCollapsedCount
|
|
515
|
+
const startIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.start)
|
|
516
|
+
const endIndex = panels.findIndex((panel) => panel.id === adjacentPanelIds.end)
|
|
517
|
+
const startCollapsedCount = startIndex >= 0 ? countCollapsed(startIndex, -1) : 0
|
|
518
|
+
const endCollapsedCount = endIndex >= 0 ? countCollapsed(endIndex, 1) : 0
|
|
523
519
|
|
|
524
|
-
return {
|
|
525
|
-
|
|
526
|
-
overlapIndex,
|
|
527
|
-
}
|
|
528
|
-
}, [adjacentIndices.left, adjacentIndices.right, panels, containerLength])
|
|
520
|
+
return { groupSize: startCollapsedCount + endCollapsedCount + 1, overlapIndex: startCollapsedCount }
|
|
521
|
+
}, [adjacentPanelIds.start, adjacentPanelIds.end, panels, containerSize])
|
|
529
522
|
|
|
530
523
|
const shouldHideHandle = useMemo(() => {
|
|
531
524
|
if (hasLayoutConstraintViolation) {
|
|
532
525
|
return true
|
|
533
526
|
}
|
|
534
|
-
// グループサイズが 1 以下の場合は単独ハンドルなので非表示にしない
|
|
535
527
|
if (indicatorGrouping.groupSize <= 1) {
|
|
536
528
|
return false
|
|
537
529
|
}
|
|
538
|
-
//
|
|
539
|
-
|
|
540
|
-
return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
|
|
530
|
+
// 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
|
|
531
|
+
return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < indicatorGrouping.groupSize - 1
|
|
541
532
|
}, [indicatorGrouping, hasLayoutConstraintViolation])
|
|
542
533
|
|
|
543
534
|
const indicatorOffset = useMemo(() => {
|
|
544
|
-
// ハンドルが非表示の場合またはグループサイズが 1 以下の場合はオフセット不要
|
|
545
535
|
if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
|
|
546
536
|
return 0
|
|
547
537
|
}
|
|
538
|
+
// 重なった 2 つの端点インジケーターを等間隔で振り分ける (半ピクセル除算を避けるため偶数間隔)
|
|
539
|
+
const spacing = ensureEven(Math.max(PANEL_VISIBLE_INDICATOR_SPACING, resolvedIndicatorThickness + 4))
|
|
540
|
+
return (indicatorGrouping.overlapIndex === 0 ? -0.5 : 0.5) * spacing
|
|
541
|
+
}, [indicatorGrouping, shouldHideHandle, resolvedIndicatorThickness])
|
|
548
542
|
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
543
|
+
const anchorStyle = useMemo<CSSProperties>(() => {
|
|
544
|
+
const base: CSSProperties = {
|
|
545
|
+
// 長さ 0 のフレックスアイテム: フレックスレイアウトがこれを境界そのものへ配置する
|
|
546
|
+
position: "relative",
|
|
547
|
+
flex: "0 0 0px",
|
|
548
|
+
alignSelf: "stretch",
|
|
549
|
+
// DOM 順を配置順の真実として扱うため、クラス由来の order で並びが変わらないようにする
|
|
550
|
+
order: 0,
|
|
551
|
+
margin: 0,
|
|
552
|
+
overflow: "visible",
|
|
553
|
+
...(isHorizontal ? { width: 0, minWidth: 0 } : { height: 0, minHeight: 0 }),
|
|
554
|
+
}
|
|
555
|
+
return shouldHideHandle ? { ...base, ...HIDDEN_HANDLE_STYLE } : base
|
|
556
|
+
}, [isHorizontal, shouldHideHandle])
|
|
557
|
+
|
|
558
|
+
// 利用者が独自に装飾できるよう、解決済みの寸法を CSS 変数として公開する
|
|
559
|
+
const customProperties = useMemo<Record<string, string>>(
|
|
560
|
+
() => ({
|
|
561
|
+
"--rp-handle-thickness": `${resolvedThickness}px`,
|
|
562
|
+
"--rp-indicator-thickness": `${resolvedIndicatorThickness}px`,
|
|
563
|
+
"--rp-indicator-length": `${resolvedIndicatorLength}px`,
|
|
564
|
+
"--rp-indicator-bar-thickness": `${resolvedBarThickness}px`,
|
|
565
|
+
"--rp-indicator-bar-length": `${resolvedBarLength}px`,
|
|
566
|
+
}),
|
|
567
|
+
[resolvedThickness, resolvedIndicatorThickness, resolvedIndicatorLength, resolvedBarThickness, resolvedBarLength],
|
|
568
|
+
)
|
|
560
569
|
|
|
561
|
-
const
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
570
|
+
const handleStyle = useMemo<CSSProperties>(() => {
|
|
571
|
+
// アンカー原点が境界。半厚みだけ手前へずらすと操作領域の中心線が境界に重なる。
|
|
572
|
+
// 端数は描画時にピクセルへスナップされるため、ここで丸める必要はない
|
|
573
|
+
return {
|
|
574
|
+
...customProperties,
|
|
575
|
+
...style,
|
|
576
|
+
// ここから下は境界に載るという前提そのもの。利用者のスタイルやクラスで変わると
|
|
577
|
+
// 操作領域の中心が境界から外れ、touch-action が戻るとタッチではスクロールが優先されてしまう
|
|
578
|
+
position: "absolute",
|
|
579
|
+
zIndex: 20,
|
|
580
|
+
touchAction: "none",
|
|
581
|
+
// 位置は論理プロパティで与える。アンカーは寸法 0 なので RTL でも同じ点に重なるが、
|
|
582
|
+
// 「境界からの距離」という意図をそのまま表せる
|
|
583
|
+
...(isHorizontal ? { insetBlockStart: 0, insetInlineStart: `${-resolvedThickness / 2}px`, width: `${resolvedThickness}px`, height: "100%" } : { insetInlineStart: 0, insetBlockStart: `${-resolvedThickness / 2}px`, height: `${resolvedThickness}px`, width: "100%" }),
|
|
584
|
+
}
|
|
585
|
+
}, [isHorizontal, resolvedThickness, customProperties, style])
|
|
586
|
+
|
|
587
|
+
const indicatorWrapperStyle = useMemo<CSSProperties>(() => {
|
|
588
|
+
if (shouldHideHandle || isIndicatorExplicitlyHidden) {
|
|
589
|
+
return HIDDEN_INDICATOR_STYLE
|
|
590
|
+
}
|
|
591
|
+
const dynamicStyle: CSSProperties = {
|
|
592
|
+
width: `${isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength}px`,
|
|
593
|
+
height: `${isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness}px`,
|
|
594
|
+
flexShrink: 0,
|
|
595
|
+
}
|
|
596
|
+
if (indicatorOffset !== 0) {
|
|
597
|
+
// 論理方向のオフセットなので RTL では自動的に鏡像になる (符号を分岐させない)
|
|
598
|
+
dynamicStyle.position = "relative"
|
|
599
|
+
if (isHorizontal) {
|
|
600
|
+
dynamicStyle.insetInlineStart = `${indicatorOffset}px`
|
|
601
|
+
} else {
|
|
602
|
+
dynamicStyle.insetBlockStart = `${indicatorOffset}px`
|
|
603
|
+
}
|
|
565
604
|
}
|
|
605
|
+
return dynamicStyle
|
|
606
|
+
}, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
|
|
566
607
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
608
|
+
const indicatorBarStyle = useMemo<CSSProperties>(
|
|
609
|
+
() => ({
|
|
610
|
+
width: `${isHorizontal ? resolvedBarThickness : resolvedBarLength}px`,
|
|
611
|
+
height: `${isHorizontal ? resolvedBarLength : resolvedBarThickness}px`,
|
|
612
|
+
flexShrink: 0,
|
|
613
|
+
}),
|
|
614
|
+
[isHorizontal, resolvedBarThickness, resolvedBarLength],
|
|
615
|
+
)
|
|
571
616
|
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
617
|
+
const baseHandleClasses = useMemo(
|
|
618
|
+
() =>
|
|
619
|
+
twMerge(
|
|
620
|
+
"group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
|
|
621
|
+
isHorizontal ? "cursor-col-resize h-full" : "cursor-row-resize w-full",
|
|
622
|
+
!isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
|
|
623
|
+
!isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
|
|
624
|
+
isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
|
|
625
|
+
isHandleDisabled && "cursor-not-allowed opacity-50",
|
|
626
|
+
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
|
|
627
|
+
className,
|
|
628
|
+
),
|
|
629
|
+
[isHorizontal, isHandleDisabled, isDragging, className],
|
|
630
|
+
)
|
|
575
631
|
|
|
576
|
-
const indicatorWrapperClasses =
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
632
|
+
const indicatorWrapperClasses = useMemo(
|
|
633
|
+
() =>
|
|
634
|
+
twMerge(
|
|
635
|
+
"pointer-events-none flex items-center justify-center rounded-full bg-white text-slate-700 ring-1 ring-inset ring-white/80 backdrop-blur-sm transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.85),0_1px_3px_rgba(15,23,42,0.18)]",
|
|
636
|
+
"dark:bg-slate-800/95 dark:text-slate-200 dark:ring-slate-500/60 dark:shadow-[0_0_0_1px_rgba(148,163,184,0.45),0_1px_3px_rgba(2,6,23,0.55)]",
|
|
637
|
+
isDragging && "bg-blue-200/80 ring-blue-300 shadow-[0_0_0_1px_rgba(191,219,254,0.65),0_1px_4px_rgba(59,130,246,0.45)] dark:bg-blue-900/70 dark:ring-blue-400 dark:shadow-[0_0_0_1px_rgba(147,197,253,0.5),0_1px_4px_rgba(59,130,246,0.55)]",
|
|
638
|
+
indicatorConfig?.className,
|
|
639
|
+
),
|
|
640
|
+
[isDragging, indicatorConfig?.className],
|
|
581
641
|
)
|
|
582
642
|
|
|
583
|
-
const indicatorBarClasses =
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
643
|
+
const indicatorBarClasses = useMemo(
|
|
644
|
+
() =>
|
|
645
|
+
twMerge(
|
|
646
|
+
"pointer-events-none rounded-full bg-slate-600 transition-colors duration-150 shadow-[0_0_0_1px_rgba(255,255,255,0.9)] dark:bg-slate-200 dark:shadow-[0_0_0_1px_rgba(15,23,42,0.55)]",
|
|
647
|
+
isDragging && "bg-blue-500 dark:bg-blue-300",
|
|
648
|
+
!isHandleDisabled && "group-hover:bg-slate-700 dark:group-hover:bg-slate-100",
|
|
649
|
+
indicatorConfig?.barClassName,
|
|
650
|
+
),
|
|
651
|
+
[isDragging, isHandleDisabled, indicatorConfig?.barClassName],
|
|
588
652
|
)
|
|
589
653
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
if (
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
// ハンドル ID が外部から変更された場合に、旧 ID の測定結果を破棄する
|
|
656
|
+
if (resolvedHandleId === handleIdRef.current) {
|
|
593
657
|
return
|
|
594
658
|
}
|
|
659
|
+
const previousId = handleIdRef.current
|
|
660
|
+
handleIdRef.current = resolvedHandleId
|
|
661
|
+
lastMeasurementRef.current = null
|
|
662
|
+
reportHandleMeasurement(previousId, null)
|
|
663
|
+
}, [reportHandleMeasurement, resolvedHandleId])
|
|
595
664
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
// ハンドルが非表示の場合は厚みをゼロとして扱う
|
|
599
|
-
if (!shouldHideHandle) {
|
|
600
|
-
const baseSize = direction === "horizontal" ? element.offsetWidth : element.offsetHeight
|
|
601
|
-
let marginStart = 0
|
|
602
|
-
let marginEnd = 0
|
|
603
|
-
|
|
604
|
-
// CSS マージンを含めた実測厚みを計算
|
|
605
|
-
if (typeof window !== "undefined") {
|
|
606
|
-
const computedStyle = window.getComputedStyle(element)
|
|
607
|
-
if (direction === "horizontal") {
|
|
608
|
-
marginStart = Number.parseFloat(computedStyle.marginLeft || "0")
|
|
609
|
-
marginEnd = Number.parseFloat(computedStyle.marginRight || "0")
|
|
610
|
-
} else {
|
|
611
|
-
marginStart = Number.parseFloat(computedStyle.marginTop || "0")
|
|
612
|
-
marginEnd = Number.parseFloat(computedStyle.marginBottom || "0")
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
const candidates = [baseSize, marginStart, marginEnd]
|
|
617
|
-
thickness = candidates.reduce((sum, value) => {
|
|
618
|
-
if (!Number.isFinite(value)) {
|
|
619
|
-
return sum
|
|
620
|
-
}
|
|
621
|
-
return sum + value
|
|
622
|
-
}, 0)
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (!Number.isFinite(thickness) || thickness < 0) {
|
|
626
|
-
thickness = 0
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// 測定結果オブジェクトを構築
|
|
665
|
+
useEffect(() => {
|
|
630
666
|
const measurement: ResizeHandleLayoutData = {
|
|
631
667
|
id: resolvedHandleId,
|
|
632
|
-
thickness,
|
|
668
|
+
thickness: shouldHideHandle ? 0 : resolvedThickness,
|
|
633
669
|
direction,
|
|
634
|
-
visible: !shouldHideHandle &&
|
|
635
|
-
startPanelId: adjacentPanelIds.
|
|
636
|
-
endPanelId: adjacentPanelIds.
|
|
670
|
+
visible: !shouldHideHandle && resolvedThickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
|
|
671
|
+
startPanelId: adjacentPanelIds.start,
|
|
672
|
+
endPanelId: adjacentPanelIds.end,
|
|
637
673
|
}
|
|
638
|
-
|
|
639
|
-
const lastMeasurement = lastHandleMeasurementRef.current
|
|
640
|
-
|
|
641
|
-
// 測定値が前回と実質的に同一であれば更新をスキップ
|
|
642
|
-
if (
|
|
643
|
-
lastMeasurement &&
|
|
644
|
-
Math.abs(lastMeasurement.thickness - measurement.thickness) <= 0.25 &&
|
|
645
|
-
lastMeasurement.visible === measurement.visible &&
|
|
646
|
-
lastMeasurement.direction === measurement.direction &&
|
|
647
|
-
lastMeasurement.startPanelId === measurement.startPanelId &&
|
|
648
|
-
lastMeasurement.endPanelId === measurement.endPanelId
|
|
649
|
-
) {
|
|
650
|
-
return
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
lastHandleMeasurementRef.current = measurement
|
|
674
|
+
lastMeasurementRef.current = measurement
|
|
654
675
|
reportHandleMeasurement(resolvedHandleId, measurement)
|
|
655
|
-
}, [direction, adjacentPanelIds.
|
|
676
|
+
}, [resolvedHandleId, resolvedThickness, shouldHideHandle, direction, adjacentPanelIds.start, adjacentPanelIds.end, reportHandleMeasurement])
|
|
656
677
|
|
|
657
678
|
useEffect(() => {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
const element = handleRef.current
|
|
663
|
-
if (!element) {
|
|
664
|
-
return
|
|
679
|
+
return () => {
|
|
680
|
+
// アンマウント時にこのハンドルの測定結果を破棄する。
|
|
681
|
+
// ID は effect の実行時ではなく後始末の時点で読む (途中で ID を変えたハンドルの登録が残らないように)
|
|
682
|
+
reportHandleMeasurement(handleIdRef.current, null)
|
|
665
683
|
}
|
|
684
|
+
}, [reportHandleMeasurement])
|
|
666
685
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
686
|
+
// 利用者のハンドラーを黙って捨てず、先に呼んでから自分の処理へ進む。
|
|
687
|
+
// preventDefault() されたイベントは利用者が握り潰したものとして、ジェスチャーを開始しない
|
|
688
|
+
const composedPointerDown = useCallback(
|
|
689
|
+
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
690
|
+
onPointerDown?.(event)
|
|
691
|
+
if (!event.defaultPrevented) {
|
|
692
|
+
handlePointerDown(event)
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
[onPointerDown, handlePointerDown],
|
|
696
|
+
)
|
|
672
697
|
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
698
|
+
const composedKeyDown = useCallback(
|
|
699
|
+
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
700
|
+
onKeyDown?.(event)
|
|
701
|
+
if (!event.defaultPrevented) {
|
|
702
|
+
handleKeyDown(event)
|
|
703
|
+
}
|
|
704
|
+
},
|
|
705
|
+
[onKeyDown, handleKeyDown],
|
|
706
|
+
)
|
|
677
707
|
|
|
678
|
-
|
|
708
|
+
const accessibleLabel = `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`
|
|
679
709
|
|
|
680
|
-
|
|
681
|
-
|
|
710
|
+
const separatorValues = useMemo(() => {
|
|
711
|
+
// スプリッターの現在値は「境界より手前のパネルがコンテナに占める割合」。可動域も同じ尺度で公開する。
|
|
712
|
+
// 折りたたまれたパネルは長さ 0 として扱う: 値を落とすと role="separator" が必須属性を失い、
|
|
713
|
+
// 支援技術からは「動かせない仕切り」に見えてしまう
|
|
714
|
+
const startPanel = adjacentPanelIds.start === null ? undefined : getPanel(adjacentPanelIds.start)
|
|
715
|
+
const endPanel = adjacentPanelIds.end === null ? undefined : getPanel(adjacentPanelIds.end)
|
|
716
|
+
if (!(startPanel && endPanel) || containerSize <= 0) {
|
|
717
|
+
return undefined
|
|
682
718
|
}
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
const merged = style ? { ...overlayBaseStyle, ...style } : overlayBaseStyle
|
|
687
|
-
if (shouldHideHandle) {
|
|
688
|
-
return {
|
|
689
|
-
...merged,
|
|
690
|
-
display: "none" as CSSProperties["display"],
|
|
691
|
-
pointerEvents: "none" as CSSProperties["pointerEvents"],
|
|
692
|
-
visibility: "hidden" as CSSProperties["visibility"],
|
|
693
|
-
}
|
|
719
|
+
const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
|
|
720
|
+
if (!bounds) {
|
|
721
|
+
return undefined
|
|
694
722
|
}
|
|
695
|
-
|
|
696
|
-
|
|
723
|
+
const toPercentage = (value: number) => Math.round((value / containerSize) * 100)
|
|
724
|
+
return { now: toPercentage(bounds.position), min: toPercentage(bounds.lower), max: toPercentage(bounds.upper) }
|
|
725
|
+
}, [adjacentPanelIds.start, adjacentPanelIds.end, getPanel, containerSize])
|
|
697
726
|
|
|
698
727
|
return (
|
|
699
|
-
<
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
728
|
+
<div ref={anchorRef} style={anchorStyle} data-resize-handle-anchor="">
|
|
729
|
+
{/* biome-ignore lint/a11y/useSemanticElements: 操作できるスプリッターは <hr> では表現できない (WAI-ARIA の window splitter パターンは focusable な role="separator" を要求する) */}
|
|
730
|
+
<button
|
|
731
|
+
{...elementProps}
|
|
732
|
+
type="button"
|
|
733
|
+
ref={handleRef}
|
|
734
|
+
className={baseHandleClasses}
|
|
735
|
+
style={handleStyle}
|
|
736
|
+
onPointerDown={composedPointerDown}
|
|
737
|
+
onKeyDown={composedKeyDown}
|
|
738
|
+
disabled={isHandleDisabled}
|
|
739
|
+
role="separator"
|
|
740
|
+
aria-orientation={isHorizontal ? "vertical" : "horizontal"}
|
|
741
|
+
aria-valuenow={separatorValues?.now}
|
|
742
|
+
aria-valuemin={separatorValues?.min}
|
|
743
|
+
aria-valuemax={separatorValues?.max}
|
|
744
|
+
aria-disabled={isHandleDisabled || undefined}
|
|
745
|
+
aria-hidden={shouldHideHandle || undefined}
|
|
746
|
+
aria-label={elementProps["aria-label"] ?? accessibleLabel}
|
|
747
|
+
data-resize-handle-id={resolvedHandleId}
|
|
748
|
+
title={title ?? accessibleLabel}>
|
|
749
|
+
{children ||
|
|
750
|
+
(!isIndicatorExplicitlyHidden && (
|
|
751
|
+
<div className={indicatorWrapperClasses} style={indicatorWrapperStyle} data-resize-handle-indicator="" aria-hidden="true">
|
|
752
|
+
<span className={indicatorBarClasses} style={indicatorBarStyle} data-resize-handle-indicator-bar="" aria-hidden="true" />
|
|
753
|
+
</div>
|
|
754
|
+
))}
|
|
755
|
+
</button>
|
|
756
|
+
</div>
|
|
717
757
|
)
|
|
718
758
|
})
|