@aiquants/resize-panels 1.9.0 → 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 +129 -22
- package/dist/GlobalDebugOverlay-Cf3GOn2A.cjs +1 -0
- package/dist/{GlobalDebugOverlay-Do70T2l6.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/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/{types.d.ts → src/types.d.ts} +164 -60
- 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 +1 -1
- 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 +470 -509
- package/src/allocateLayout.ts +412 -0
- package/src/debugOverlayStore.ts +19 -15
- package/src/hooks.ts +187 -779
- package/src/index.ts +12 -3
- package/src/reducer.ts +364 -1126
- package/src/types.ts +149 -63
- package/src/utils.ts +205 -164
- package/dist/GlobalDebugOverlay-sEQN6exo.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-B6YGX2DH.js +0 -2009
- package/dist/index-DssZUxGw.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.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/{PanelResizeHandle.d.ts → src/PanelResizeHandle.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,13 +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
|
-
import { roundHalfToEven } from "./roundHalfToEven"
|
|
10
19
|
import {
|
|
20
|
+
PANEL_ALLOCATION_EPSILON,
|
|
11
21
|
PANEL_COLLAPSE_THRESHOLD_MAX,
|
|
12
22
|
PANEL_COLLAPSE_THRESHOLD_RATIO,
|
|
13
23
|
PANEL_EXPAND_THRESHOLD_MAX,
|
|
@@ -23,14 +33,15 @@ import {
|
|
|
23
33
|
PANEL_INDICATOR_THICKNESS,
|
|
24
34
|
PANEL_KEYBOARD_RESIZE_STEP,
|
|
25
35
|
PANEL_VISIBLE_INDICATOR_SPACING,
|
|
36
|
+
type PanelLayoutData,
|
|
26
37
|
type PanelResizeHandleProps,
|
|
27
38
|
type ResizeHandleLayoutData,
|
|
28
39
|
} from "./types"
|
|
29
|
-
import { calculateSnapThreshold,
|
|
40
|
+
import { calculateSnapThreshold, findOwningGroup, getGroupLayoutElements, getVisualScale } from "./utils"
|
|
30
41
|
|
|
31
42
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
43
|
+
* Styles that remove the handle and its anchor from layout and interaction.
|
|
44
|
+
* ハンドルとアンカーをレイアウトと操作の両方から外すスタイル。
|
|
34
45
|
*/
|
|
35
46
|
const HIDDEN_HANDLE_STYLE: CSSProperties = {
|
|
36
47
|
display: "none",
|
|
@@ -39,8 +50,8 @@ const HIDDEN_HANDLE_STYLE: CSSProperties = {
|
|
|
39
50
|
}
|
|
40
51
|
|
|
41
52
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
53
|
+
* Style that collapses the visual indicator.
|
|
54
|
+
* 視覚インジケーターを非表示にするスタイル。
|
|
44
55
|
*/
|
|
45
56
|
const HIDDEN_INDICATOR_STYLE: CSSProperties = {
|
|
46
57
|
display: "none",
|
|
@@ -48,7 +59,7 @@ const HIDDEN_INDICATOR_STYLE: CSSProperties = {
|
|
|
48
59
|
|
|
49
60
|
/**
|
|
50
61
|
* Sanitize an optional dimension to ensure a positive finite number.
|
|
51
|
-
*
|
|
62
|
+
* オプション寸法を正の有限数値へ正規化する処理。
|
|
52
63
|
*/
|
|
53
64
|
const sanitizeDimension = (value: unknown, defaultValue: number): number => {
|
|
54
65
|
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
@@ -59,278 +70,298 @@ const sanitizeDimension = (value: unknown, defaultValue: number): number => {
|
|
|
59
70
|
|
|
60
71
|
/**
|
|
61
72
|
* Ensure a numeric spacing value is an even integer for symmetric pixel alignment.
|
|
62
|
-
*
|
|
73
|
+
* ピクセル配置の対称性を担保するため数値を偶数整数に整流する処理。
|
|
63
74
|
*/
|
|
64
75
|
const ensureEven = (value: number): number => {
|
|
65
76
|
const rounded = Math.round(value)
|
|
66
77
|
return rounded % 2 === 0 ? rounded : rounded + 1
|
|
67
78
|
}
|
|
68
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
|
+
}
|
|
159
|
+
|
|
69
160
|
/**
|
|
70
161
|
* Render an interactive divider that manages adjacent panel resizing gestures.
|
|
71
162
|
* 隣接パネルのリサイズ操作を管理するインタラクティブなディバイダーを描画するコンポーネント。
|
|
72
163
|
*/
|
|
73
164
|
export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
74
|
-
const { id, disabled = false, className, style, children, onDragging, thickness, indicator = true, indicatorThickness, indicatorLength, indicatorBarThickness, indicatorBarLength, title } = props
|
|
75
|
-
const { direction, resizePanels, getPanel, collapsePanel, expandPanel, panels, reportHandleMeasurement, containerSize, layoutConstraintViolation } = usePanelGroup()
|
|
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
|
+
|
|
76
168
|
const [isDragging, setIsDragging] = useState(false)
|
|
77
|
-
const
|
|
78
|
-
const containerRef = useRef<HTMLElement | null>(null)
|
|
169
|
+
const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ start: string | null; end: string | null }>({ start: null, end: null })
|
|
79
170
|
|
|
80
|
-
const
|
|
81
|
-
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("")
|
|
82
179
|
// 進行中ドラッグの後始末関数。アンマウント時や次のドラッグ開始時の残留リスナー掃除に使う
|
|
83
180
|
const dragCleanupRef = useRef<(() => void) | null>(null)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const [adjacentPanelIds, setAdjacentPanelIds] = useState<{ left: string | null; right: string | null }>({ left: null, right: null })
|
|
181
|
+
const lastMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
|
|
182
|
+
|
|
87
183
|
const generatedId = useId()
|
|
88
184
|
const handleIdRef = useRef(id ?? `resize-handle-${generatedId}`)
|
|
89
|
-
const lastHandleMeasurementRef = useRef<ResizeHandleLayoutData | null>(null)
|
|
90
185
|
const resolvedHandleId = id ?? handleIdRef.current
|
|
186
|
+
const isHorizontal = direction === "horizontal"
|
|
91
187
|
const hasLayoutConstraintViolation = Boolean(layoutConstraintViolation)
|
|
92
188
|
const isHandleDisabled = disabled || hasLayoutConstraintViolation
|
|
93
189
|
|
|
94
190
|
/**
|
|
95
|
-
* Detect the panels
|
|
96
|
-
* DOM
|
|
191
|
+
* Detect the panels this handle sits between by scanning its siblings in the DOM.
|
|
192
|
+
* DOM の兄弟要素を走査して、このハンドルが挟んでいるパネルを検出する処理。
|
|
97
193
|
*/
|
|
98
194
|
const detectAdjacentPanels = useCallback(() => {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
containerRef.current = parent
|
|
106
|
-
if (typeof window !== "undefined") {
|
|
107
|
-
setIsRtl(window.getComputedStyle(parent).direction === "rtl")
|
|
195
|
+
const anchor = anchorRef.current
|
|
196
|
+
// 中間要素が挟まると親はグループではないため、所有グループを辿って解決する
|
|
197
|
+
const group = findOwningGroup(anchor)
|
|
198
|
+
if (!(anchor && group)) {
|
|
199
|
+
return
|
|
108
200
|
}
|
|
109
|
-
const childrenElements = Array.from(parent.children)
|
|
110
|
-
const index = childrenElements.indexOf(handleElement)
|
|
111
|
-
|
|
112
|
-
let leftPanelElement: HTMLElement | null = null
|
|
113
|
-
let rightPanelElement: HTMLElement | null = null
|
|
114
201
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
210
|
break
|
|
120
211
|
}
|
|
121
212
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
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
|
|
127
217
|
break
|
|
128
218
|
}
|
|
129
219
|
}
|
|
130
220
|
|
|
131
|
-
|
|
132
|
-
const nextRightId = rightPanelElement?.dataset.panelId || ""
|
|
133
|
-
|
|
134
|
-
if (nextLeftId === leftPanelId.current && nextRightId === rightPanelId.current) {
|
|
221
|
+
if (startId === startPanelIdRef.current && endId === endPanelIdRef.current) {
|
|
135
222
|
return
|
|
136
223
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
setAdjacentPanelIds({
|
|
142
|
-
left: nextLeftId || null,
|
|
143
|
-
right: nextRightId || null,
|
|
144
|
-
})
|
|
224
|
+
startPanelIdRef.current = startId
|
|
225
|
+
endPanelIdRef.current = endId
|
|
226
|
+
setAdjacentPanelIds({ start: startId || null, end: endId || null })
|
|
145
227
|
}, [])
|
|
146
228
|
|
|
147
|
-
// パネル構成 (ID 列) の変化を検知するためのキー。ドラッグ中のサイズ更新では変化しない
|
|
148
|
-
const panelIdsKey = useMemo(() => panels.map((panel) => panel.id).join("|"), [panels])
|
|
149
|
-
|
|
150
229
|
useEffect(() => {
|
|
151
|
-
//
|
|
152
|
-
//
|
|
230
|
+
// マウント時と、グループの DOM 順が変わるたびに隣接パネルを再検出する。
|
|
231
|
+
// パネルの id 列だけを見ていると、ハンドル自身が別の境界へ移った並べ替えを取りこぼし、
|
|
232
|
+
// 見た目とは違う 2 枚を掴み続ける
|
|
153
233
|
detectAdjacentPanels()
|
|
154
|
-
}, [detectAdjacentPanels,
|
|
155
|
-
|
|
156
|
-
useEffect(() => {
|
|
157
|
-
// ハンドル ID が外部から変更された場合に、旧 ID の測定結果をクリーンアップする
|
|
158
|
-
if (resolvedHandleId === handleIdRef.current) {
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const previousId = handleIdRef.current
|
|
163
|
-
handleIdRef.current = resolvedHandleId
|
|
164
|
-
lastHandleMeasurementRef.current = null
|
|
165
|
-
|
|
166
|
-
if (previousId !== resolvedHandleId) {
|
|
167
|
-
reportHandleMeasurement(previousId, null)
|
|
168
|
-
}
|
|
169
|
-
}, [reportHandleMeasurement, resolvedHandleId])
|
|
234
|
+
}, [detectAdjacentPanels, layoutOrderKey])
|
|
170
235
|
|
|
171
236
|
useEffect(() => {
|
|
172
|
-
// コンポーネントのアンマウント時に、このハンドルの測定結果を破棄する
|
|
173
|
-
const finalId = handleIdRef.current
|
|
174
|
-
return () => {
|
|
175
|
-
reportHandleMeasurement(finalId, null)
|
|
176
|
-
}
|
|
177
|
-
}, [reportHandleMeasurement])
|
|
178
|
-
|
|
179
|
-
useEffect(() => {
|
|
180
|
-
// ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
|
|
181
237
|
return () => {
|
|
238
|
+
// ドラッグ中にアンマウントされた場合も document リスナーとドラッグ状態を確実に破棄する
|
|
182
239
|
dragCleanupRef.current?.()
|
|
183
240
|
}
|
|
184
241
|
}, [])
|
|
185
242
|
|
|
186
243
|
/**
|
|
187
|
-
*
|
|
244
|
+
* Start a resize drag when the handle receives a pointer down event.
|
|
188
245
|
* リサイズハンドルがポインタダウンを受けた際にドラッグ操作を開始する処理。
|
|
189
246
|
*/
|
|
190
247
|
const handlePointerDown = useCallback(
|
|
191
248
|
(event: React.PointerEvent<HTMLButtonElement>) => {
|
|
192
|
-
if (isHandleDisabled
|
|
193
|
-
|
|
249
|
+
if (isHandleDisabled) {
|
|
250
|
+
return
|
|
251
|
+
}
|
|
194
252
|
// 既にドラッグ中の場合は 2 本目のポインタで新たなドラッグを開始しない
|
|
195
|
-
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
|
+
}
|
|
196
267
|
|
|
197
268
|
event.preventDefault()
|
|
198
269
|
event.stopPropagation()
|
|
199
270
|
|
|
200
|
-
const
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
let lastUpdateTime = 0
|
|
213
|
-
const throttleMs = PANEL_HANDLE_DRAG_THROTTLE_MS
|
|
214
|
-
|
|
215
|
-
setIsDragging(true)
|
|
216
|
-
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
|
|
217
282
|
|
|
218
|
-
// このドラッグを開始したポインタだけを追跡する (2 本目のタッチ等の混入を防ぐ)
|
|
219
283
|
const dragPointerId = event.pointerId
|
|
220
284
|
const captureTarget = event.currentTarget
|
|
285
|
+
// 追従は document の pointermove / pointerup で行うため、キャプチャは無くても成立する。
|
|
286
|
+
// 実装しない環境 (jsdom など) で例外を投げると、利用者側のテストがドラッグを検証できなくなる
|
|
287
|
+
captureTarget.setPointerCapture?.(dragPointerId)
|
|
221
288
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// ポインタキャプチャを取得してドラッグ中のイベント配送を固定
|
|
226
|
-
captureTarget.setPointerCapture(dragPointerId)
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Handle pointer move events emitted during an active resize drag interaction.
|
|
230
|
-
* リサイズドラッグ操作中に発生するポインタ移動イベントを処理する関数。
|
|
231
|
-
*/
|
|
232
|
-
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
233
|
-
// 別ポインタのイベントはドラッグ計算に混入させない
|
|
234
|
-
if (moveEvent.pointerId !== dragPointerId) return
|
|
235
|
-
if (!handleRef.current) return
|
|
289
|
+
setIsDragging(true)
|
|
290
|
+
onDragging?.(true)
|
|
236
291
|
|
|
237
|
-
|
|
238
|
-
|
|
292
|
+
let lastUpdateTime = 0
|
|
293
|
+
let throttledMove: PointerEvent | null = null
|
|
239
294
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
243
299
|
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
|
|
300
|
+
const layout = layoutRef.current
|
|
301
|
+
const currentStart = layout.getPanel(startId)
|
|
302
|
+
const currentEnd = layout.getPanel(endId)
|
|
303
|
+
if (!(currentStart && currentEnd)) {
|
|
247
304
|
return
|
|
248
305
|
}
|
|
249
306
|
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
const updatedLeftPanel = getPanel(leftPanelId.current)
|
|
254
|
-
const updatedRightPanel = getPanel(rightPanelId.current)
|
|
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)
|
|
255
309
|
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
const collapseThresholdPixels = Math.min(containerSize * PANEL_COLLAPSE_THRESHOLD_RATIO, PANEL_COLLAPSE_THRESHOLD_MAX)
|
|
261
|
-
const expandThresholdPixels = Math.min(containerSize * PANEL_EXPAND_THRESHOLD_RATIO, PANEL_EXPAND_THRESHOLD_MAX)
|
|
262
|
-
|
|
263
|
-
const rightCanCollapseFromStart = updatedRightPanel.collapseFromStart
|
|
264
|
-
const rightCollapseDirection = updatedRightPanel.collapsedByDirection ?? "start"
|
|
265
|
-
if (rightCanCollapseFromStart && !updatedRightPanel.collapsed && newRightSizeInPixels <= Math.max(rightMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
266
|
-
collapsePanel(rightPanelId.current, "start")
|
|
267
|
-
return
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const leftCanCollapseFromEnd = updatedLeftPanel.collapseFromEnd
|
|
271
|
-
const leftCollapseDirection = updatedLeftPanel.collapsedByDirection ?? "end"
|
|
272
|
-
if (leftCanCollapseFromEnd && !updatedLeftPanel.collapsed && newLeftSizeInPixels <= Math.max(leftMinSizePixels * 0.3, collapseThresholdPixels)) {
|
|
273
|
-
collapsePanel(leftPanelId.current, "end")
|
|
274
|
-
return
|
|
310
|
+
if (currentEnd.collapsed) {
|
|
311
|
+
if (nextEndSize > expandThreshold) {
|
|
312
|
+
expandPanel(endId, "start", nextEndSize)
|
|
275
313
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
if (updatedLeftPanel.collapsed && newLeftSizeInPixels > Math.max(leftMinSizePixels * 0.3, expandThresholdPixels)) {
|
|
283
|
-
expandPanel(leftPanelId.current, leftCollapseDirection, newLeftSizeInPixels)
|
|
284
|
-
return
|
|
314
|
+
return
|
|
315
|
+
}
|
|
316
|
+
if (currentStart.collapsed) {
|
|
317
|
+
if (nextStartSize > expandThreshold) {
|
|
318
|
+
expandPanel(startId, "end", nextStartSize)
|
|
285
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
|
+
}
|
|
286
330
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const snapThreshold = calculateSnapThreshold(containerSize)
|
|
290
|
-
let snapped = false
|
|
291
|
-
|
|
292
|
-
if (leftMinSizePixels <= snapThreshold && newLeftSizeInPixels <= snapThreshold && initialLeftSize !== 0) {
|
|
293
|
-
const snappedLeft = 0
|
|
294
|
-
const snappedRight = Math.max(0, totalPanelPixels - snappedLeft)
|
|
295
|
-
|
|
296
|
-
if (snappedRight >= rightMinSizePixels) {
|
|
297
|
-
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
298
|
-
snapped = true
|
|
299
|
-
}
|
|
300
|
-
} else if (rightMinSizePixels <= snapThreshold && newRightSizeInPixels <= snapThreshold && initialRightSize !== 0) {
|
|
301
|
-
const snappedRight = 0
|
|
302
|
-
const snappedLeft = Math.max(0, totalPanelPixels - snappedRight)
|
|
303
|
-
|
|
304
|
-
if (snappedLeft >= leftMinSizePixels) {
|
|
305
|
-
resizePanels(leftPanelId.current, rightPanelId.current, snappedLeft, snappedRight)
|
|
306
|
-
snapped = true
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (snapped) {
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
}
|
|
331
|
+
resizePanels(startId, endId, nextStartSize)
|
|
332
|
+
}
|
|
314
333
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
334
|
+
const handlePointerMove = (moveEvent: PointerEvent) => {
|
|
335
|
+
// 別ポインタのイベントはドラッグ計算に混入させない
|
|
336
|
+
if (moveEvent.pointerId !== dragPointerId) {
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
moveEvent.preventDefault()
|
|
340
|
+
moveEvent.stopPropagation()
|
|
318
341
|
|
|
319
|
-
|
|
342
|
+
const now = performance.now()
|
|
343
|
+
if (now - lastUpdateTime < PANEL_HANDLE_DRAG_THROTTLE_MS) {
|
|
344
|
+
// 間引いた移動は捨てずに保持する。離上時に反映しないと、指を離した位置と確定した境界がずれる
|
|
345
|
+
throttledMove = moveEvent
|
|
346
|
+
return
|
|
320
347
|
}
|
|
348
|
+
lastUpdateTime = now
|
|
349
|
+
throttledMove = null
|
|
350
|
+
applyMove(moveEvent)
|
|
321
351
|
}
|
|
322
352
|
|
|
323
353
|
/**
|
|
324
354
|
* Tear down the active drag: remove listeners, release capture, and reset state.
|
|
325
|
-
*
|
|
355
|
+
* 進行中ドラッグの後始末 (リスナー除去・キャプチャ解放・状態リセット)。
|
|
326
356
|
* pointerup / pointercancel / lostpointercapture / アンマウントのどこから呼ばれても一度だけ実行される。
|
|
327
357
|
*/
|
|
328
358
|
let dragActive = true
|
|
329
359
|
const stopDragging = () => {
|
|
330
|
-
if (!dragActive)
|
|
360
|
+
if (!dragActive) {
|
|
361
|
+
return
|
|
362
|
+
}
|
|
331
363
|
dragActive = false
|
|
332
364
|
dragCleanupRef.current = null
|
|
333
|
-
|
|
334
365
|
setIsDragging(false)
|
|
335
366
|
onDragging?.(false)
|
|
336
367
|
|
|
@@ -345,20 +376,27 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
|
345
376
|
|
|
346
377
|
const handlePointerUp = (upEvent: PointerEvent) => {
|
|
347
378
|
// 別ポインタの離上ではドラッグを終了しない
|
|
348
|
-
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
|
+
}
|
|
349
387
|
stopDragging()
|
|
350
388
|
}
|
|
351
|
-
|
|
352
389
|
const handlePointerCancel = (cancelEvent: PointerEvent) => {
|
|
353
390
|
// OS 割込み (タッチジェスチャ奪取等) でキャンセルされた場合もドラッグ状態を残さない
|
|
354
|
-
if (cancelEvent.pointerId
|
|
355
|
-
|
|
391
|
+
if (cancelEvent.pointerId === dragPointerId) {
|
|
392
|
+
stopDragging()
|
|
393
|
+
}
|
|
356
394
|
}
|
|
357
|
-
|
|
358
395
|
const handleLostPointerCapture = (lostEvent: PointerEvent) => {
|
|
359
396
|
// キャプチャ喪失 (要素の DOM 離脱等) 時のフォールバック終了処理
|
|
360
|
-
if (lostEvent.pointerId
|
|
361
|
-
|
|
397
|
+
if (lostEvent.pointerId === dragPointerId) {
|
|
398
|
+
stopDragging()
|
|
399
|
+
}
|
|
362
400
|
}
|
|
363
401
|
|
|
364
402
|
document.addEventListener("pointermove", handlePointerMove)
|
|
@@ -367,203 +405,103 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
|
367
405
|
captureTarget.addEventListener("lostpointercapture", handleLostPointerCapture)
|
|
368
406
|
dragCleanupRef.current = stopDragging
|
|
369
407
|
},
|
|
370
|
-
[isHandleDisabled,
|
|
408
|
+
[isHandleDisabled, isHorizontal, direction, getPanel, resizePanels, collapsePanel, expandPanel, onDragging],
|
|
371
409
|
)
|
|
372
410
|
|
|
373
411
|
/**
|
|
374
412
|
* Resize adjacent panels with arrow keys for keyboard accessibility.
|
|
375
413
|
* キーボード (矢印キー) で隣接パネルをリサイズするアクセシビリティ対応。
|
|
376
|
-
* Shift 併用で 5 倍のステップ幅を適用する。
|
|
377
414
|
*/
|
|
378
415
|
const handleKeyDown = useCallback(
|
|
379
416
|
(event: React.KeyboardEvent<HTMLButtonElement>) => {
|
|
380
|
-
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
|
+
}
|
|
381
425
|
|
|
382
|
-
const
|
|
383
|
-
const
|
|
384
|
-
const
|
|
385
|
-
|
|
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
|
+
}
|
|
386
439
|
|
|
387
440
|
event.preventDefault()
|
|
388
441
|
event.stopPropagation()
|
|
389
442
|
|
|
390
|
-
|
|
391
|
-
// RTL では DOM 順 (論理順) と視覚順が反転するため符号を反転する
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
[isHandleDisabled, direction, getPanel, resizePanels],
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
const isHorizontal = direction === "horizontal"
|
|
408
|
-
const cursor = isHorizontal ? "cursor-col-resize" : "cursor-row-resize"
|
|
409
|
-
|
|
410
|
-
const baseHandleClasses = useMemo(
|
|
411
|
-
() =>
|
|
412
|
-
twMerge(
|
|
413
|
-
"group absolute z-20 flex select-none items-center justify-center border-none bg-transparent transition-colors duration-150 overflow-visible",
|
|
414
|
-
cursor,
|
|
415
|
-
isHorizontal ? "h-full" : "w-full",
|
|
416
|
-
!isHandleDisabled && "hover:bg-gray-200/70 dark:hover:bg-gray-700/70",
|
|
417
|
-
!isHandleDisabled && "active:bg-blue-300/60 dark:active:bg-blue-700/60",
|
|
418
|
-
isDragging && "bg-blue-200/80 dark:bg-blue-800/60",
|
|
419
|
-
isHandleDisabled && "cursor-not-allowed opacity-50",
|
|
420
|
-
"focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50",
|
|
421
|
-
className,
|
|
422
|
-
),
|
|
423
|
-
[cursor, isHorizontal, isHandleDisabled, isDragging, className],
|
|
424
|
-
)
|
|
425
|
-
|
|
426
|
-
const adjacentIndices = useMemo(() => {
|
|
427
|
-
const findIndex = (panelId: string | null) => {
|
|
428
|
-
if (!panelId) {
|
|
429
|
-
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
|
|
430
457
|
}
|
|
431
|
-
|
|
432
|
-
if (
|
|
433
|
-
|
|
458
|
+
if (endPanel.collapsed) {
|
|
459
|
+
if (delta < 0) {
|
|
460
|
+
expandPanel(endId, "start")
|
|
434
461
|
}
|
|
462
|
+
return
|
|
435
463
|
}
|
|
436
|
-
return -1
|
|
437
|
-
}
|
|
438
464
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const containerLength = isHorizontal ? containerSize.width : containerSize.height
|
|
446
|
-
|
|
447
|
-
const boundaryOffset = useMemo(() => {
|
|
448
|
-
const safeSum = (startIndex: number, endIndexInclusive: number) => {
|
|
449
|
-
if (startIndex > endIndexInclusive) {
|
|
450
|
-
return 0
|
|
465
|
+
// リサイズで届く端まで来たパネルをさらに押すキーは、ドラッグで端まで引いたときと同じく折りたたみになる。
|
|
466
|
+
// 判定に使うのはパネル自身の最小値ではなく対の可動域の端である。隣の上限が先に効く構成では、
|
|
467
|
+
// パネルは自分の最小値へ決して到達できず、キーボードからは永久に折りたためなくなる
|
|
468
|
+
if (delta < 0 && startPanel.collapseFromEnd && bounds.position <= bounds.resizeLower + PANEL_ALLOCATION_EPSILON) {
|
|
469
|
+
collapsePanel(startId, "end")
|
|
470
|
+
return
|
|
451
471
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
if (!panel) {
|
|
456
|
-
continue
|
|
457
|
-
}
|
|
458
|
-
const candidateSize = panel.size ?? 0
|
|
459
|
-
const sanitizedSize = Math.max(0, candidateSize)
|
|
460
|
-
total += panel.collapsed ? 0 : sanitizedSize
|
|
472
|
+
if (delta > 0 && endPanel.collapseFromStart && bounds.position >= bounds.resizeUpper - PANEL_ALLOCATION_EPSILON) {
|
|
473
|
+
collapsePanel(endId, "start")
|
|
474
|
+
return
|
|
461
475
|
}
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
return safeSum(0, adjacentIndices.left)
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (adjacentIndices.right > 0) {
|
|
470
|
-
return safeSum(0, adjacentIndices.right - 1)
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
return 0
|
|
474
|
-
}, [adjacentIndices.left, adjacentIndices.right, panels])
|
|
475
|
-
|
|
476
|
-
const clampedOffset = useMemo(() => {
|
|
477
|
-
if (!Number.isFinite(boundaryOffset) || boundaryOffset < 0) {
|
|
478
|
-
return 0
|
|
479
|
-
}
|
|
480
|
-
if (containerLength > 0 && boundaryOffset > containerLength) {
|
|
481
|
-
return containerLength
|
|
482
|
-
}
|
|
483
|
-
return boundaryOffset
|
|
484
|
-
}, [boundaryOffset, containerLength])
|
|
476
|
+
resizePanels(startId, endId, startPanel.size + delta)
|
|
477
|
+
},
|
|
478
|
+
[isHandleDisabled, isHorizontal, getPanel, resizePanels, collapsePanel, expandPanel, containerSize],
|
|
479
|
+
)
|
|
485
480
|
|
|
486
481
|
const indicatorConfig = typeof indicator === "object" && indicator !== null ? indicator : undefined
|
|
487
482
|
const isIndicatorExplicitlyHidden = indicator === false || indicatorConfig?.visible === false
|
|
488
483
|
|
|
489
|
-
const
|
|
484
|
+
const resolvedThickness = Math.max(1, sanitizeDimension(thickness, PANEL_HANDLE_THICKNESS))
|
|
490
485
|
const defaultIndicatorLength = isHorizontal ? PANEL_INDICATOR_LENGTH_HORIZONTAL : PANEL_INDICATOR_LENGTH_VERTICAL
|
|
491
486
|
const defaultBarLength = isHorizontal ? PANEL_INDICATOR_BAR_LENGTH_HORIZONTAL : PANEL_INDICATOR_BAR_LENGTH_VERTICAL
|
|
492
487
|
|
|
493
|
-
const rawIndicatorThickness = sanitizeDimension(indicatorThickness ?? indicatorConfig?.thickness, PANEL_INDICATOR_THICKNESS)
|
|
494
|
-
const rawIndicatorLength = sanitizeDimension(indicatorLength ?? indicatorConfig?.length, defaultIndicatorLength)
|
|
495
|
-
const rawBarThickness = sanitizeDimension(indicatorBarThickness ?? indicatorConfig?.barThickness, PANEL_INDICATOR_BAR_THICKNESS)
|
|
496
|
-
const rawBarLength = sanitizeDimension(indicatorBarLength ?? indicatorConfig?.barLength, defaultBarLength)
|
|
497
|
-
|
|
498
488
|
// 幾何学的内包不変条件: 内部バー寸法がインジケーター外枠およびハンドル操作領域を超えないようクランプ
|
|
499
|
-
const
|
|
500
|
-
const
|
|
501
|
-
const
|
|
502
|
-
const
|
|
503
|
-
const resolvedBarLength = Math.min(resolvedIndicatorLength, Math.max(1, rawBarLength))
|
|
504
|
-
|
|
505
|
-
// 静的 CSS 変数はドラッグ中に変化しないため、スタイル再計算時の不要なプロパティ再設定を防止
|
|
506
|
-
const customProperties = useMemo<Record<string, string>>(
|
|
507
|
-
() => ({
|
|
508
|
-
"--rp-handle-thickness": `${resolvedThickness}px`,
|
|
509
|
-
"--rp-indicator-thickness": `${resolvedIndicatorThickness}px`,
|
|
510
|
-
"--rp-indicator-length": `${resolvedIndicatorLength}px`,
|
|
511
|
-
"--rp-indicator-bar-thickness": `${resolvedBarThickness}px`,
|
|
512
|
-
"--rp-indicator-bar-length": `${resolvedBarLength}px`,
|
|
513
|
-
}),
|
|
514
|
-
[resolvedThickness, resolvedIndicatorThickness, resolvedIndicatorLength, resolvedBarThickness, resolvedBarLength],
|
|
515
|
-
)
|
|
516
|
-
|
|
517
|
-
const overlayBaseStyle = useMemo<CSSProperties>(() => {
|
|
518
|
-
// RTL では右端起点から論理距離を差し引いた位置が物理境界中心線となる
|
|
519
|
-
const centerLine = isHorizontal && isRtl && containerLength > 0 ? containerLength - clampedOffset : clampedOffset
|
|
520
|
-
|
|
521
|
-
// transform: translate(-50%) による 0.5px サブピクセル滲みを防ぐため、中心線から半厚みを引き銀行丸めで整数ピクセル原点を決定
|
|
522
|
-
const startCoord = roundHalfToEven(centerLine - resolvedThickness / 2, 0)
|
|
523
|
-
|
|
524
|
-
const base: CSSProperties = {
|
|
525
|
-
position: "absolute",
|
|
526
|
-
zIndex: 20,
|
|
527
|
-
touchAction: "none",
|
|
528
|
-
...customProperties,
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
if (isHorizontal) {
|
|
532
|
-
base.top = 0
|
|
533
|
-
base.left = `${startCoord}px`
|
|
534
|
-
base.width = `${resolvedThickness}px`
|
|
535
|
-
base.height = "100%"
|
|
536
|
-
} else {
|
|
537
|
-
base.left = 0
|
|
538
|
-
base.top = `${startCoord}px`
|
|
539
|
-
base.height = `${resolvedThickness}px`
|
|
540
|
-
base.width = "100%"
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
return base
|
|
544
|
-
}, [clampedOffset, isHorizontal, isRtl, containerLength, resolvedThickness, customProperties])
|
|
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)))
|
|
545
493
|
|
|
546
494
|
const indicatorGrouping = useMemo(() => {
|
|
547
|
-
|
|
495
|
+
// 隣接パネルが閉じていると複数のハンドルが同じ境界へ重なるため、その重なり具合を数える
|
|
496
|
+
const snapThreshold = calculateSnapThreshold(containerSize)
|
|
548
497
|
const isEffectivelyCollapsed = (panelIndex: number) => {
|
|
549
|
-
if (panelIndex < 0 || panelIndex >= panels.length) {
|
|
550
|
-
return false
|
|
551
|
-
}
|
|
552
498
|
const target = panels[panelIndex]
|
|
553
499
|
if (!target) {
|
|
554
500
|
return false
|
|
555
501
|
}
|
|
556
|
-
|
|
557
|
-
return true
|
|
558
|
-
}
|
|
559
|
-
return target.size <= snapThreshold
|
|
502
|
+
return target.collapsed || target.size <= snapThreshold
|
|
560
503
|
}
|
|
561
|
-
|
|
562
504
|
const countCollapsed = (startIndex: number, step: 1 | -1) => {
|
|
563
|
-
if (!isEffectivelyCollapsed(startIndex)) {
|
|
564
|
-
return 0
|
|
565
|
-
}
|
|
566
|
-
|
|
567
505
|
let count = 0
|
|
568
506
|
for (let index = startIndex; index >= 0 && index < panels.length; index += step) {
|
|
569
507
|
if (!isEffectivelyCollapsed(index)) {
|
|
@@ -574,18 +512,13 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
|
574
512
|
return count
|
|
575
513
|
}
|
|
576
514
|
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const groupSize = leftCollapsedCount + rightCollapsedCount + 1
|
|
582
|
-
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
|
|
583
519
|
|
|
584
|
-
return {
|
|
585
|
-
|
|
586
|
-
overlapIndex,
|
|
587
|
-
}
|
|
588
|
-
}, [adjacentIndices.left, adjacentIndices.right, panels, containerLength])
|
|
520
|
+
return { groupSize: startCollapsedCount + endCollapsedCount + 1, overlapIndex: startCollapsedCount }
|
|
521
|
+
}, [adjacentPanelIds.start, adjacentPanelIds.end, panels, containerSize])
|
|
589
522
|
|
|
590
523
|
const shouldHideHandle = useMemo(() => {
|
|
591
524
|
if (hasLayoutConstraintViolation) {
|
|
@@ -595,61 +528,106 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
|
595
528
|
return false
|
|
596
529
|
}
|
|
597
530
|
// 折りたたみ重なり時の視覚重複を防ぐため端点以外の重複ハンドルを不可視化
|
|
598
|
-
|
|
599
|
-
return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < lastIndex
|
|
531
|
+
return indicatorGrouping.overlapIndex > 0 && indicatorGrouping.overlapIndex < indicatorGrouping.groupSize - 1
|
|
600
532
|
}, [indicatorGrouping, hasLayoutConstraintViolation])
|
|
601
533
|
|
|
602
534
|
const indicatorOffset = useMemo(() => {
|
|
603
535
|
if (indicatorGrouping.groupSize <= 1 || shouldHideHandle) {
|
|
604
536
|
return 0
|
|
605
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])
|
|
606
542
|
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
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
|
+
)
|
|
617
569
|
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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])
|
|
621
586
|
|
|
622
587
|
const indicatorWrapperStyle = useMemo<CSSProperties>(() => {
|
|
623
588
|
if (shouldHideHandle || isIndicatorExplicitlyHidden) {
|
|
624
589
|
return HIDDEN_INDICATOR_STYLE
|
|
625
590
|
}
|
|
626
|
-
|
|
627
|
-
const width = isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength
|
|
628
|
-
const height = isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness
|
|
629
|
-
|
|
630
591
|
const dynamicStyle: CSSProperties = {
|
|
631
|
-
width: `${
|
|
632
|
-
height: `${
|
|
592
|
+
width: `${isHorizontal ? resolvedIndicatorThickness : resolvedIndicatorLength}px`,
|
|
593
|
+
height: `${isHorizontal ? resolvedIndicatorLength : resolvedIndicatorThickness}px`,
|
|
633
594
|
flexShrink: 0,
|
|
634
595
|
}
|
|
635
|
-
|
|
636
596
|
if (indicatorOffset !== 0) {
|
|
637
|
-
|
|
597
|
+
// 論理方向のオフセットなので RTL では自動的に鏡像になる (符号を分岐させない)
|
|
598
|
+
dynamicStyle.position = "relative"
|
|
599
|
+
if (isHorizontal) {
|
|
600
|
+
dynamicStyle.insetInlineStart = `${indicatorOffset}px`
|
|
601
|
+
} else {
|
|
602
|
+
dynamicStyle.insetBlockStart = `${indicatorOffset}px`
|
|
603
|
+
}
|
|
638
604
|
}
|
|
639
|
-
|
|
640
605
|
return dynamicStyle
|
|
641
606
|
}, [shouldHideHandle, isIndicatorExplicitlyHidden, isHorizontal, resolvedIndicatorThickness, resolvedIndicatorLength, indicatorOffset])
|
|
642
607
|
|
|
643
|
-
const indicatorBarStyle = useMemo<CSSProperties>(
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
return {
|
|
648
|
-
width: `${width}px`,
|
|
649
|
-
height: `${height}px`,
|
|
608
|
+
const indicatorBarStyle = useMemo<CSSProperties>(
|
|
609
|
+
() => ({
|
|
610
|
+
width: `${isHorizontal ? resolvedBarThickness : resolvedBarLength}px`,
|
|
611
|
+
height: `${isHorizontal ? resolvedBarLength : resolvedBarThickness}px`,
|
|
650
612
|
flexShrink: 0,
|
|
651
|
-
}
|
|
652
|
-
|
|
613
|
+
}),
|
|
614
|
+
[isHorizontal, resolvedBarThickness, resolvedBarLength],
|
|
615
|
+
)
|
|
616
|
+
|
|
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
|
+
)
|
|
653
631
|
|
|
654
632
|
const indicatorWrapperClasses = useMemo(
|
|
655
633
|
() =>
|
|
@@ -673,125 +651,108 @@ export const PanelResizeHandle = memo((props: PanelResizeHandleProps) => {
|
|
|
673
651
|
[isDragging, isHandleDisabled, indicatorConfig?.barClassName],
|
|
674
652
|
)
|
|
675
653
|
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
if (
|
|
654
|
+
useEffect(() => {
|
|
655
|
+
// ハンドル ID が外部から変更された場合に、旧 ID の測定結果を破棄する
|
|
656
|
+
if (resolvedHandleId === handleIdRef.current) {
|
|
679
657
|
return
|
|
680
658
|
}
|
|
659
|
+
const previousId = handleIdRef.current
|
|
660
|
+
handleIdRef.current = resolvedHandleId
|
|
661
|
+
lastMeasurementRef.current = null
|
|
662
|
+
reportHandleMeasurement(previousId, null)
|
|
663
|
+
}, [reportHandleMeasurement, resolvedHandleId])
|
|
681
664
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
if (!shouldHideHandle) {
|
|
685
|
-
const baseSize = direction === "horizontal" ? element.offsetWidth : element.offsetHeight
|
|
686
|
-
let marginStart = 0
|
|
687
|
-
let marginEnd = 0
|
|
688
|
-
|
|
689
|
-
if (typeof window !== "undefined") {
|
|
690
|
-
const computedStyle = window.getComputedStyle(element)
|
|
691
|
-
if (direction === "horizontal") {
|
|
692
|
-
marginStart = Number.parseFloat(computedStyle.marginLeft || "0")
|
|
693
|
-
marginEnd = Number.parseFloat(computedStyle.marginRight || "0")
|
|
694
|
-
} else {
|
|
695
|
-
marginStart = Number.parseFloat(computedStyle.marginTop || "0")
|
|
696
|
-
marginEnd = Number.parseFloat(computedStyle.marginBottom || "0")
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
const candidates = [baseSize, marginStart, marginEnd]
|
|
701
|
-
thickness = candidates.reduce((sum, value) => {
|
|
702
|
-
if (!Number.isFinite(value)) {
|
|
703
|
-
return sum
|
|
704
|
-
}
|
|
705
|
-
return sum + value
|
|
706
|
-
}, 0)
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
if (!Number.isFinite(thickness) || thickness < 0) {
|
|
710
|
-
thickness = 0
|
|
711
|
-
}
|
|
712
|
-
|
|
665
|
+
useEffect(() => {
|
|
713
666
|
const measurement: ResizeHandleLayoutData = {
|
|
714
667
|
id: resolvedHandleId,
|
|
715
|
-
thickness,
|
|
668
|
+
thickness: shouldHideHandle ? 0 : resolvedThickness,
|
|
716
669
|
direction,
|
|
717
|
-
visible: !shouldHideHandle &&
|
|
718
|
-
startPanelId: adjacentPanelIds.
|
|
719
|
-
endPanelId: adjacentPanelIds.
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
const lastMeasurement = lastHandleMeasurementRef.current
|
|
723
|
-
|
|
724
|
-
if (
|
|
725
|
-
lastMeasurement &&
|
|
726
|
-
Math.abs(lastMeasurement.thickness - measurement.thickness) <= 0.25 &&
|
|
727
|
-
lastMeasurement.visible === measurement.visible &&
|
|
728
|
-
lastMeasurement.direction === measurement.direction &&
|
|
729
|
-
lastMeasurement.startPanelId === measurement.startPanelId &&
|
|
730
|
-
lastMeasurement.endPanelId === measurement.endPanelId
|
|
731
|
-
) {
|
|
732
|
-
return
|
|
670
|
+
visible: !shouldHideHandle && resolvedThickness > PANEL_HANDLE_VISIBILITY_THRESHOLD,
|
|
671
|
+
startPanelId: adjacentPanelIds.start,
|
|
672
|
+
endPanelId: adjacentPanelIds.end,
|
|
733
673
|
}
|
|
734
|
-
|
|
735
|
-
lastHandleMeasurementRef.current = measurement
|
|
674
|
+
lastMeasurementRef.current = measurement
|
|
736
675
|
reportHandleMeasurement(resolvedHandleId, measurement)
|
|
737
|
-
}, [direction, adjacentPanelIds.
|
|
676
|
+
}, [resolvedHandleId, resolvedThickness, shouldHideHandle, direction, adjacentPanelIds.start, adjacentPanelIds.end, reportHandleMeasurement])
|
|
738
677
|
|
|
739
678
|
useEffect(() => {
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
const element = handleRef.current
|
|
745
|
-
if (!element) {
|
|
746
|
-
return
|
|
679
|
+
return () => {
|
|
680
|
+
// アンマウント時にこのハンドルの測定結果を破棄する。
|
|
681
|
+
// ID は effect の実行時ではなく後始末の時点で読む (途中で ID を変えたハンドルの登録が残らないように)
|
|
682
|
+
reportHandleMeasurement(handleIdRef.current, null)
|
|
747
683
|
}
|
|
684
|
+
}, [reportHandleMeasurement])
|
|
748
685
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
+
)
|
|
753
697
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
+
)
|
|
757
707
|
|
|
758
|
-
|
|
708
|
+
const accessibleLabel = `Resize ${isHorizontal ? "columns" : "rows"} - Drag or use arrow keys to resize, drag to the edge to collapse/expand panels`
|
|
759
709
|
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
762
718
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
const merged = style ? { ...overlayBaseStyle, ...style } : overlayBaseStyle
|
|
767
|
-
if (shouldHideHandle) {
|
|
768
|
-
return {
|
|
769
|
-
...merged,
|
|
770
|
-
...HIDDEN_HANDLE_STYLE,
|
|
771
|
-
}
|
|
719
|
+
const bounds = resolveSeparatorBounds(startPanel, endPanel, containerSize)
|
|
720
|
+
if (!bounds) {
|
|
721
|
+
return undefined
|
|
772
722
|
}
|
|
773
|
-
|
|
774
|
-
|
|
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])
|
|
775
726
|
|
|
776
727
|
return (
|
|
777
|
-
<
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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>
|
|
796
757
|
)
|
|
797
758
|
})
|