@keenmate/pure-admin-core 2.9.0-rc03 → 2.9.0-rc05
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 +55 -13
- package/dist/css/main.css +554 -7
- package/package.json +3 -1
- package/snippets/buttons.html +51 -0
- package/snippets/cards.html +132 -47
- package/snippets/comparison.html +26 -22
- package/snippets/manifest.json +180 -115
- package/snippets/range-group.html +125 -0
- package/snippets/splitter.html +44 -38
- package/snippets/statistics.html +31 -0
- package/src/js/btn-split-auto-absorb.js +327 -0
- package/src/js/command-palette.js +472 -0
- package/src/js/file-selector.js +1275 -0
- package/src/js/internal/logging.js +121 -0
- package/src/js/logic-tree-renderer.js +303 -0
- package/src/js/modal-dialogs.js +460 -0
- package/src/js/overflow.js +371 -0
- package/src/js/pa-stat-fit.js +184 -0
- package/src/js/range-group.js +663 -0
- package/src/js/search-autocomplete-v2.js +907 -0
- package/src/js/search-autocomplete.js +434 -0
- package/src/js/settings-panel.js +245 -0
- package/src/js/split-button.js +141 -0
- package/src/js/splitter.js +1323 -0
- package/src/js/toast-service.js +302 -0
- package/src/js/tooltips-popovers.js +275 -0
- package/src/js/virtual-scroll.js +143 -0
- package/src/js/virtual-textbox.js +803 -0
- package/src/scss/_core.scss +7 -0
- package/src/scss/core-components/_buttons.scss +44 -0
- package/src/scss/core-components/_cards.scss +95 -6
- package/src/scss/core-components/_overflow.scss +50 -0
- package/src/scss/core-components/_range-group.scss +474 -0
- package/src/scss/core-components/_statistics.scss +163 -0
- package/src/scss/variables/_components.scss +41 -2
|
@@ -0,0 +1,1323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure Admin Splitter
|
|
3
|
+
* Resizable container with two or more panes. Auto-initializes on
|
|
4
|
+
* [data-pa-splitter]. Panes and gutters alternate (pane, gutter, pane,
|
|
5
|
+
* gutter, …, pane); each pane carries its own sizing attributes.
|
|
6
|
+
*
|
|
7
|
+
* --- Attributes on root ---
|
|
8
|
+
* data-pa-splitter (marker; required)
|
|
9
|
+
* data-pa-splitter-id="key" (enables localStorage persistence)
|
|
10
|
+
* data-pa-splitter-step="10" (keyboard step in px; default 10)
|
|
11
|
+
* data-pa-splitter-rail-size="40" (rail width in px; default reads
|
|
12
|
+
* --pa-splitter-rail-size from
|
|
13
|
+
* getComputedStyle, then 40 literal)
|
|
14
|
+
* data-pa-splitter-minimize-threshold="0.40"
|
|
15
|
+
* (drag-to-minimize snap ratio of the
|
|
16
|
+
* drag-start size, floored at rail × 1.5)
|
|
17
|
+
*
|
|
18
|
+
* --- Accordion mode (auto) ---
|
|
19
|
+
* When the container is narrower than the sum of all pane mins + gutters
|
|
20
|
+
* + gaps + padding AND there are 2+ minimizable panes, the splitter
|
|
21
|
+
* switches into single-pane-expanded mode: restoring one minimizable pane
|
|
22
|
+
* auto-rails the others. Engages/disengages automatically as the
|
|
23
|
+
* container resizes. Adds the class `pa-splitter--accordion` to the root
|
|
24
|
+
* as a styling hook.
|
|
25
|
+
*
|
|
26
|
+
* --- Attributes on each .pa-splitter__pane ---
|
|
27
|
+
* data-pa-splitter-size="200px" | "30%" (initial size; unspecified panes
|
|
28
|
+
* share leftover equally — or the
|
|
29
|
+
* last pane absorbs if all sized)
|
|
30
|
+
* data-pa-splitter-min="150px" | "10%"
|
|
31
|
+
* data-pa-splitter-max="400px" | "50%"
|
|
32
|
+
* data-pa-splitter-minimize (marker; only honoured on the
|
|
33
|
+
* first and last panes — they
|
|
34
|
+
* roll up against the closest
|
|
35
|
+
* container edge)
|
|
36
|
+
*
|
|
37
|
+
* --- Drag-from-rail asymmetry ---
|
|
38
|
+
* When the gutter's primary neighbour is already railed, drag direction
|
|
39
|
+
* matters: dragging OUTWARD (the direction that would grow the pane)
|
|
40
|
+
* releases the rail and follows the cursor; dragging INWARD (into the
|
|
41
|
+
* rail, would shrink the pane further) is inert — the rail stays put and
|
|
42
|
+
* the gutter doesn't move. Tap-without-drag on the gutter does nothing.
|
|
43
|
+
* Restore gestures: rail-body click, dblclick on the gutter, focus the
|
|
44
|
+
* gutter and press Enter/Space, click a [data-pa-splitter-toggle], or
|
|
45
|
+
* drag the gutter outward past the snap threshold.
|
|
46
|
+
*
|
|
47
|
+
* --- Events (CustomEvent, bubbles from the pane) ---
|
|
48
|
+
* pa-splitter:resize detail: { index, pane, size } per pane on every applySizes
|
|
49
|
+
* pa-splitter:collapse detail: { index, pane } when a pane is railed
|
|
50
|
+
* pa-splitter:expand detail: { index, pane } when a pane is restored
|
|
51
|
+
* Resize fires unconditionally per pane during drag — debounce in the listener.
|
|
52
|
+
* Init does NOT fire collapse for panes that started rail'd from saved state.
|
|
53
|
+
*
|
|
54
|
+
* Public API (window.PaSplitter):
|
|
55
|
+
* init(el) - initialize a single splitter element (idempotent)
|
|
56
|
+
* initAll(root?) - initialize all uninitialized splitters under root (default: document)
|
|
57
|
+
*/
|
|
58
|
+
(function () {
|
|
59
|
+
'use strict';
|
|
60
|
+
|
|
61
|
+
var STORAGE_PREFIX = 'pa-splitter:';
|
|
62
|
+
var INIT_FLAG = '__paSplitterInit';
|
|
63
|
+
|
|
64
|
+
function parseSize(raw, totalPx) {
|
|
65
|
+
if (raw == null || raw === '') return null;
|
|
66
|
+
var s = String(raw).trim();
|
|
67
|
+
if (s.endsWith('%')) {
|
|
68
|
+
var pct = parseFloat(s);
|
|
69
|
+
if (isNaN(pct)) return null;
|
|
70
|
+
return (pct / 100) * totalPx;
|
|
71
|
+
}
|
|
72
|
+
if (s.endsWith('px')) {
|
|
73
|
+
var px = parseFloat(s);
|
|
74
|
+
return isNaN(px) ? null : px;
|
|
75
|
+
}
|
|
76
|
+
// Bare number = px
|
|
77
|
+
var n = parseFloat(s);
|
|
78
|
+
if (!isNaN(n) && /^-?\d*\.?\d+$/.test(s)) return n;
|
|
79
|
+
console.warn('[pa-splitter] unsupported size unit:', raw, '(use px or %)');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function clamp(value, min, max) {
|
|
84
|
+
if (value < min) return min;
|
|
85
|
+
if (value > max) return max;
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Rail size resolution: per-instance attribute wins, then the
|
|
90
|
+
// --pa-splitter-rail-size CSS variable (themes / inline-style overrides),
|
|
91
|
+
// then a hardcoded 40px floor. Without the CSS-var path the SCSS variable
|
|
92
|
+
// $splitter-rail-size was effectively dead — themes could rename it and
|
|
93
|
+
// nothing happened at the JS layer.
|
|
94
|
+
//
|
|
95
|
+
// Note on rem/em: the CSS variable emit is `4rem` (per SCSS source), and
|
|
96
|
+
// `getComputedStyle` returns the variable's value un-resolved — i.e. the
|
|
97
|
+
// raw string "4rem", not the px equivalent. parseFloat("4rem") returns 4,
|
|
98
|
+
// which would snap panes to 4px-wide strips instead of 40px rails. So we
|
|
99
|
+
// probe with a hidden element: set its width to the CSS variable, read
|
|
100
|
+
// offsetWidth, and let the browser do the unit resolution.
|
|
101
|
+
function readRailSize(root) {
|
|
102
|
+
var attr = root.getAttribute('data-pa-splitter-rail-size');
|
|
103
|
+
if (attr != null && attr !== '') {
|
|
104
|
+
var px = parseInt(attr, 10);
|
|
105
|
+
if (!isNaN(px) && px > 0) return px;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
var probe = root.ownerDocument.createElement('div');
|
|
109
|
+
probe.style.cssText = 'position:absolute;visibility:hidden;height:0;width:var(--pa-splitter-rail-size, 40px);';
|
|
110
|
+
root.appendChild(probe);
|
|
111
|
+
var resolved = probe.offsetWidth;
|
|
112
|
+
root.removeChild(probe);
|
|
113
|
+
if (resolved > 0) return resolved;
|
|
114
|
+
} catch (err) { /* fall through to default */ }
|
|
115
|
+
return 40;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readStorage(id) {
|
|
119
|
+
if (!id) return null;
|
|
120
|
+
try {
|
|
121
|
+
var raw = localStorage.getItem(STORAGE_PREFIX + id);
|
|
122
|
+
if (!raw) return null;
|
|
123
|
+
var parsed = JSON.parse(raw);
|
|
124
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
125
|
+
return parsed;
|
|
126
|
+
} catch (e) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function writeStorage(id, state) {
|
|
132
|
+
if (!id) return;
|
|
133
|
+
try {
|
|
134
|
+
localStorage.setItem(STORAGE_PREFIX + id, JSON.stringify(state));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
// quota / privacy mode — silently skip
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function init(root) {
|
|
141
|
+
if (!root || root[INIT_FLAG]) return;
|
|
142
|
+
initNPane(root);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ====================================================================
|
|
146
|
+
// Per-pane state lives in parallel arrays (sizes[i], mins[i], …) so the
|
|
147
|
+
// hot drag loop avoids object churn.
|
|
148
|
+
//
|
|
149
|
+
// Drag model: REBALANCE (not boundary-coupled). Each gutter `g` owns
|
|
150
|
+
// resizing of its PRIMARY neighbour (edge-closer side; LTR/RTL
|
|
151
|
+
// tiebreaker on middle/even ties). Dragging changes only the primary's
|
|
152
|
+
// size; the matching opposite delta is distributed across all other
|
|
153
|
+
// non-minimized panes proportionally to their current sizes. Rail panes
|
|
154
|
+
// (isMin[i] === true) stay pinned at railSize and don't participate.
|
|
155
|
+
//
|
|
156
|
+
// Rationale: in a mixed layout like AcBcCeDeEc (A,B,E rail; C,D
|
|
157
|
+
// expanded), the user grabbing the A|B gutter expects to grow A. A
|
|
158
|
+
// boundary-coupled model would try to shrink B for slack — but B is
|
|
159
|
+
// already railed, so the drag locks. The rebalance model pulls slack
|
|
160
|
+
// from C and D instead, matching user intent.
|
|
161
|
+
//
|
|
162
|
+
// Minimize is honoured on any pane with data-pa-splitter-minimize.
|
|
163
|
+
|
|
164
|
+
function initNPane(root) {
|
|
165
|
+
var isVertical = root.classList.contains('pa-splitter--vertical');
|
|
166
|
+
var clientAxis = isVertical ? 'clientHeight' : 'clientWidth';
|
|
167
|
+
var clientCoord = isVertical ? 'clientY' : 'clientX';
|
|
168
|
+
|
|
169
|
+
var panes = Array.prototype.slice.call(root.querySelectorAll(':scope > .pa-splitter__pane'));
|
|
170
|
+
var gutters = Array.prototype.slice.call(root.querySelectorAll(':scope > .pa-splitter__gutter'));
|
|
171
|
+
|
|
172
|
+
if (panes.length < 2 || gutters.length !== panes.length - 1) {
|
|
173
|
+
console.warn('[pa-splitter] N-pane mode needs N panes + N-1 gutters, skipping', root, {
|
|
174
|
+
panes: panes.length, gutters: gutters.length
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Verify alternating DOM order (pane, gutter, pane, gutter, …, pane).
|
|
180
|
+
// Children matching neither selector are tolerated (a stray comment
|
|
181
|
+
// node or whitespace won't trip this) but anything inserted between
|
|
182
|
+
// pane and gutter would break drag math.
|
|
183
|
+
var structural = Array.prototype.slice.call(root.children).filter(function (el) {
|
|
184
|
+
return el.classList && (el.classList.contains('pa-splitter__pane') || el.classList.contains('pa-splitter__gutter'));
|
|
185
|
+
});
|
|
186
|
+
for (var si = 0; si < structural.length; si++) {
|
|
187
|
+
var expected = si % 2 === 0 ? 'pa-splitter__pane' : 'pa-splitter__gutter';
|
|
188
|
+
if (!structural[si].classList.contains(expected)) {
|
|
189
|
+
console.warn('[pa-splitter] panes and gutters must alternate (pane, gutter, pane, …), skipping', root);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
root[INIT_FLAG] = true;
|
|
195
|
+
|
|
196
|
+
var id = root.getAttribute('data-pa-splitter-id') || null;
|
|
197
|
+
var stepPx = parseInt(root.getAttribute('data-pa-splitter-step'), 10) || 10;
|
|
198
|
+
var railSizePx = readRailSize(root);
|
|
199
|
+
var minimizeThresholdRatio = parseFloat(root.getAttribute('data-pa-splitter-minimize-threshold'));
|
|
200
|
+
if (isNaN(minimizeThresholdRatio) || minimizeThresholdRatio <= 0 || minimizeThresholdRatio >= 1) {
|
|
201
|
+
minimizeThresholdRatio = 0.40;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
var label = '[pa-splitter:' + (id || 'anon') + ']';
|
|
205
|
+
function log() {
|
|
206
|
+
console.log.apply(console, [label].concat(Array.prototype.slice.call(arguments)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
var N = panes.length;
|
|
210
|
+
var sizes = new Array(N);
|
|
211
|
+
var mins = new Array(N);
|
|
212
|
+
var maxes = new Array(N);
|
|
213
|
+
var canMin = new Array(N);
|
|
214
|
+
var minSide = new Array(N);
|
|
215
|
+
var lastNonZero = new Array(N);
|
|
216
|
+
var isMin = new Array(N);
|
|
217
|
+
|
|
218
|
+
// Per-pane orientation class — defensive against nested splitters
|
|
219
|
+
// with mixed orientations. Downstream CSS that only wants to act on
|
|
220
|
+
// panes inside a horizontal (or vertical) splitter can key off the
|
|
221
|
+
// class on the pane itself instead of walking up to find an
|
|
222
|
+
// orientation modifier (which, with nesting, returns the outer
|
|
223
|
+
// splitter's orientation for inner panes).
|
|
224
|
+
var paneOrientationClass = isVertical ? 'pa-splitter__pane--vertical' : 'pa-splitter__pane--horizontal';
|
|
225
|
+
|
|
226
|
+
// Read per-pane attributes. Sizes are resolved later (against total).
|
|
227
|
+
var sizeRaws = new Array(N);
|
|
228
|
+
var minRaws = new Array(N);
|
|
229
|
+
var maxRaws = new Array(N);
|
|
230
|
+
for (var i = 0; i < N; i++) {
|
|
231
|
+
sizeRaws[i] = panes[i].getAttribute('data-pa-splitter-size');
|
|
232
|
+
minRaws[i] = panes[i].getAttribute('data-pa-splitter-min');
|
|
233
|
+
maxRaws[i] = panes[i].getAttribute('data-pa-splitter-max');
|
|
234
|
+
// Any pane with the minimize marker can collapse to rail.
|
|
235
|
+
// Start / end panes dock against the container edge; middle
|
|
236
|
+
// panes shrink in place, with the released slack split between
|
|
237
|
+
// both neighbours (and on restore, taken back from both).
|
|
238
|
+
var hasMinAttr = panes[i].hasAttribute('data-pa-splitter-minimize');
|
|
239
|
+
canMin[i] = hasMinAttr;
|
|
240
|
+
minSide[i] = canMin[i] ? (i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle')) : null;
|
|
241
|
+
isMin[i] = false;
|
|
242
|
+
lastNonZero[i] = 0;
|
|
243
|
+
// Flex setup: every pane is fully JS-controlled, no fill via flex-grow.
|
|
244
|
+
panes[i].style.flex = '0 0 auto';
|
|
245
|
+
panes[i].classList.add(paneOrientationClass);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// CustomEvent dispatch helpers. Events bubble from the pane element
|
|
249
|
+
// so consumers can listen on the splitter root (or higher) with a
|
|
250
|
+
// single handler. resize fires per pane per applySizes — unfiltered;
|
|
251
|
+
// debounce in the listener if needed. collapse/expand fire only on
|
|
252
|
+
// user-initiated transitions (toggle, dblclick, drag snap, rail
|
|
253
|
+
// click) — NOT on init from saved state.
|
|
254
|
+
function fireResize(i) {
|
|
255
|
+
panes[i].dispatchEvent(new CustomEvent('pa-splitter:resize', {
|
|
256
|
+
bubbles: true,
|
|
257
|
+
detail: { index: i, pane: panes[i], size: sizes[i] }
|
|
258
|
+
}));
|
|
259
|
+
}
|
|
260
|
+
function fireCollapse(i) {
|
|
261
|
+
panes[i].dispatchEvent(new CustomEvent('pa-splitter:collapse', {
|
|
262
|
+
bubbles: true,
|
|
263
|
+
detail: { index: i, pane: panes[i] }
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
function fireExpand(i) {
|
|
267
|
+
panes[i].dispatchEvent(new CustomEvent('pa-splitter:expand', {
|
|
268
|
+
bubbles: true,
|
|
269
|
+
detail: { index: i, pane: panes[i] }
|
|
270
|
+
}));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function gapPx() {
|
|
274
|
+
var cs = getComputedStyle(root);
|
|
275
|
+
var raw = isVertical ? cs.rowGap : cs.columnGap;
|
|
276
|
+
var px = parseFloat(raw);
|
|
277
|
+
return isNaN(px) ? 0 : px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function paddingPx() {
|
|
281
|
+
// `clientWidth` / `clientHeight` include padding but flex children
|
|
282
|
+
// are placed inside the content area only. If we don't subtract
|
|
283
|
+
// padding here, the panes' total flex-basis overflows the content
|
|
284
|
+
// area by 2 × padding and the last pane gets clipped by the
|
|
285
|
+
// splitter's `overflow: hidden`.
|
|
286
|
+
var cs = getComputedStyle(root);
|
|
287
|
+
var start = isVertical ? cs.paddingTop : cs.paddingLeft;
|
|
288
|
+
var end = isVertical ? cs.paddingBottom : cs.paddingRight;
|
|
289
|
+
return (parseFloat(start) || 0) + (parseFloat(end) || 0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function gutterTotal() {
|
|
293
|
+
// Sum across all gutters — they may not all be the same size if a
|
|
294
|
+
// theme overrides one — but in practice they're uniform.
|
|
295
|
+
var t = 0;
|
|
296
|
+
for (var g = 0; g < gutters.length; g++) t += gutters[g][clientAxis];
|
|
297
|
+
return t;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function totalAvailable() {
|
|
301
|
+
// Flex `gap` lands between every adjacent child. With panes and
|
|
302
|
+
// gutters alternating (pane, gutter, pane, …) there are 2(N-1)
|
|
303
|
+
// such boundaries.
|
|
304
|
+
var gapCount = 2 * (N - 1);
|
|
305
|
+
return root[clientAxis] - paddingPx() - gutterTotal() - (gapCount * gapPx());
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function resolveConstraints() {
|
|
309
|
+
var rootSize = root[clientAxis];
|
|
310
|
+
var total = totalAvailable();
|
|
311
|
+
for (var i = 0; i < N; i++) {
|
|
312
|
+
var mn = parseSize(minRaws[i], rootSize);
|
|
313
|
+
var mx = parseSize(maxRaws[i], rootSize);
|
|
314
|
+
if (mn == null) mn = 0;
|
|
315
|
+
if (mx == null) mx = total;
|
|
316
|
+
if (mx > total) mx = total;
|
|
317
|
+
if (mn > mx) mn = mx;
|
|
318
|
+
mins[i] = mn;
|
|
319
|
+
maxes[i] = mx;
|
|
320
|
+
}
|
|
321
|
+
return total;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function clampToConstraints(arr, total, pinned) {
|
|
325
|
+
// Clamp each pane to [min, max] and redistribute any shortfall/excess
|
|
326
|
+
// to unclamped neighbours proportionally. Iterates up to N times to
|
|
327
|
+
// converge — each pass may free up new slack as panes hit walls.
|
|
328
|
+
// `pinned[i] === true` excludes pane i from the flexable pool, which
|
|
329
|
+
// is how minimized (rail) panes stay at rail across container resizes
|
|
330
|
+
// — without this, a growing container would let them flex above
|
|
331
|
+
// railSize because their `max - railSize` headroom looks fine.
|
|
332
|
+
pinned = pinned || [];
|
|
333
|
+
for (var pass = 0; pass < N + 1; pass++) {
|
|
334
|
+
var sum = 0;
|
|
335
|
+
for (var i = 0; i < N; i++) sum += arr[i];
|
|
336
|
+
var diff = total - sum;
|
|
337
|
+
if (Math.abs(diff) < 0.5) break;
|
|
338
|
+
var flexable = [];
|
|
339
|
+
var flexableWeight = 0;
|
|
340
|
+
for (var j = 0; j < N; j++) {
|
|
341
|
+
if (pinned[j]) continue;
|
|
342
|
+
var headroom = diff > 0 ? (maxes[j] - arr[j]) : (arr[j] - mins[j]);
|
|
343
|
+
if (headroom > 0.5) {
|
|
344
|
+
flexable.push(j);
|
|
345
|
+
flexableWeight += arr[j] > 0 ? arr[j] : 1;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (flexable.length === 0) break;
|
|
349
|
+
for (var k = 0; k < flexable.length; k++) {
|
|
350
|
+
var idx = flexable[k];
|
|
351
|
+
var share = diff * ((arr[idx] > 0 ? arr[idx] : 1) / flexableWeight);
|
|
352
|
+
var next = arr[idx] + share;
|
|
353
|
+
if (next < mins[idx]) next = mins[idx];
|
|
354
|
+
if (next > maxes[idx]) next = maxes[idx];
|
|
355
|
+
arr[idx] = next;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return arr;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function applySizes(opts) {
|
|
362
|
+
opts = opts || {};
|
|
363
|
+
var anyMin = false;
|
|
364
|
+
var lastNonZeroChanged = [];
|
|
365
|
+
var classBefore = panes.map(function (p) { return p.classList.contains('pa-splitter__pane--minimized'); });
|
|
366
|
+
for (var i = 0; i < N; i++) {
|
|
367
|
+
panes[i].style.flexBasis = sizes[i] + 'px';
|
|
368
|
+
panes[i].classList.toggle('pa-splitter__pane--minimized', isMin[i]);
|
|
369
|
+
if (isMin[i]) anyMin = true;
|
|
370
|
+
// Only persist sizes at or above mins[i] as "lastNonZero".
|
|
371
|
+
// During a drag-from-rail, sizes[i] may sit at the rail
|
|
372
|
+
// floor (well below mins[i]) for the whole drag if the
|
|
373
|
+
// boundary couldn't move — overwriting lastNonZero with
|
|
374
|
+
// that value would silently destroy the pane's remembered
|
|
375
|
+
// expanded size, causing future restores to fall back to
|
|
376
|
+
// bare mins instead of the original expanded width.
|
|
377
|
+
if (sizes[i] >= mins[i] && !isMin[i]) {
|
|
378
|
+
if (lastNonZero[i] !== sizes[i]) lastNonZeroChanged.push({ i: i, from: lastNonZero[i], to: sizes[i] });
|
|
379
|
+
lastNonZero[i] = sizes[i];
|
|
380
|
+
}
|
|
381
|
+
fireResize(i);
|
|
382
|
+
}
|
|
383
|
+
root.classList.toggle('pa-splitter--minimized', anyMin);
|
|
384
|
+
// Only log applySizes when a pane's minimized class actually
|
|
385
|
+
// flipped — that's the moment the card visually re-renders.
|
|
386
|
+
// Suppresses the per-frame flood while still surfacing every
|
|
387
|
+
// rail/expanded transition.
|
|
388
|
+
var anyClassChanged = false;
|
|
389
|
+
var perPane = [];
|
|
390
|
+
for (var k = 0; k < N; k++) {
|
|
391
|
+
var nowMin = panes[k].classList.contains('pa-splitter__pane--minimized');
|
|
392
|
+
if (classBefore[k] !== nowMin) anyClassChanged = true;
|
|
393
|
+
perPane.push({
|
|
394
|
+
i: k,
|
|
395
|
+
size: Math.round(sizes[k]),
|
|
396
|
+
isMin: isMin[k],
|
|
397
|
+
classDOM: nowMin,
|
|
398
|
+
classChanged: classBefore[k] !== nowMin
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
if (anyClassChanged) {
|
|
402
|
+
log('applySizes (class flip)', perPane, 'persist=' + (opts.persist !== false));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Update each gutter's ARIA (uses left-pane size as the value).
|
|
406
|
+
for (var g = 0; g < gutters.length; g++) {
|
|
407
|
+
gutters[g].setAttribute('aria-valuenow', String(Math.round(sizes[g])));
|
|
408
|
+
gutters[g].setAttribute('aria-valuemin', String(Math.round(mins[g])));
|
|
409
|
+
gutters[g].setAttribute('aria-valuemax', String(Math.round(maxes[g])));
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (opts.persist !== false && id) {
|
|
413
|
+
writeStorage(id, { v: 2, sizes: sizes.slice(), lasts: lastNonZero.slice(), minimized: isMin.slice() });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function setupGutters() {
|
|
418
|
+
for (var g = 0; g < gutters.length; g++) {
|
|
419
|
+
var gut = gutters[g];
|
|
420
|
+
gut.setAttribute('role', gut.getAttribute('role') || 'separator');
|
|
421
|
+
gut.setAttribute('aria-orientation', isVertical ? 'horizontal' : 'vertical');
|
|
422
|
+
if (!gut.hasAttribute('tabindex')) gut.setAttribute('tabindex', '0');
|
|
423
|
+
bindGutter(gut, g);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function bindGutter(gut, g) {
|
|
428
|
+
// Rebalance-on-drag model: each gutter `g` resizes its PRIMARY
|
|
429
|
+
// neighbour only (primary = edge-closer side; LTR/RTL tiebreaker
|
|
430
|
+
// on ties). The slack — the matching opposite delta — is
|
|
431
|
+
// absorbed by *all other non-minimized panes* proportionally
|
|
432
|
+
// to their current sizes. The adjacent non-primary pane is
|
|
433
|
+
// treated like any other absorber: rail panes stay railed, the
|
|
434
|
+
// rest share the load. This is intentionally different from the
|
|
435
|
+
// classic boundary-coupled splitter (left and right neighbours
|
|
436
|
+
// trade space 1:1); in mixed rail/expanded layouts the boundary
|
|
437
|
+
// model produced a stuck-at-rail experience where dragging A|B
|
|
438
|
+
// couldn't grow A while B was collapsed.
|
|
439
|
+
var dragStartCoord = 0;
|
|
440
|
+
var dragStartPrimary = 0;
|
|
441
|
+
var activePointerId = null;
|
|
442
|
+
var primaryIdx = -1;
|
|
443
|
+
// +1 if primary is the left neighbour (g): cursor moving right
|
|
444
|
+
// grows the primary; -1 if primary is the right neighbour (g+1):
|
|
445
|
+
// cursor moving right shrinks the primary (gutter trails cursor
|
|
446
|
+
// toward primary's start edge).
|
|
447
|
+
var primarySign = 1;
|
|
448
|
+
// Read-only after pointerdown — see "startedMin" / "canSnap"
|
|
449
|
+
// comments in earlier versions. Promoting these mid-drag would
|
|
450
|
+
// permanently disable the snap-in gate and block re-snap.
|
|
451
|
+
var primaryStartedMin = false;
|
|
452
|
+
// Transient "rail → expanded" transition state. Set when the
|
|
453
|
+
// user drags a mid-drag-snapped pane back out past the
|
|
454
|
+
// threshold; while true the floor stays at railSizePx so the
|
|
455
|
+
// pane grows smoothly out of rail instead of jumping to mins.
|
|
456
|
+
var primaryInEscape = false;
|
|
457
|
+
// "Is snap-into-rail allowed right now". Starts as
|
|
458
|
+
// !primaryStartedMin (drag-from-expanded can snap immediately;
|
|
459
|
+
// drag-from-rail needs to first commit to expanded by crossing
|
|
460
|
+
// mins[primary] outward).
|
|
461
|
+
var primaryCanSnap = false;
|
|
462
|
+
// Largest size the primary has reached during this drag. Anchors
|
|
463
|
+
// the snap-threshold formula so a drag-from-rail user who has
|
|
464
|
+
// expanded the pane to e.g. 300 gets a meaningful re-snap
|
|
465
|
+
// threshold instead of the rail*1.5 = 60 floor.
|
|
466
|
+
var primaryMaxReached = 0;
|
|
467
|
+
// Distinguish tap from drag at pointerup (set true when the
|
|
468
|
+
// pointer crosses a small jitter threshold, even if the user
|
|
469
|
+
// dragged out and back to the same size).
|
|
470
|
+
var everMoved = false;
|
|
471
|
+
var TAP_PX = 2;
|
|
472
|
+
// Per-frame `move` logs are throttled to size-bucket transitions
|
|
473
|
+
// (0–25 % / 25–50 % / 50–75 % / 75–100 % of total). State
|
|
474
|
+
// transitions (SNAP, DRAG-OUT, CLAMP-TO-MIN, etc.) always log.
|
|
475
|
+
var lastBucket = -1;
|
|
476
|
+
|
|
477
|
+
function onPointerDown(e) {
|
|
478
|
+
if (e.button != null && e.button !== 0) return;
|
|
479
|
+
// Defensive sweep: clear --active from every gutter before
|
|
480
|
+
// committing this one. Covers a stale class from a previous
|
|
481
|
+
// pointerup that didn't fire cleanly (rare).
|
|
482
|
+
for (var gi = 0; gi < gutters.length; gi++) {
|
|
483
|
+
gutters[gi].classList.remove('pa-splitter__gutter--active');
|
|
484
|
+
}
|
|
485
|
+
primaryIdx = primaryNeighbour(g);
|
|
486
|
+
primarySign = (primaryIdx === g) ? 1 : -1;
|
|
487
|
+
// Snapshot rail state at drag start. Crucially DO NOT clear
|
|
488
|
+
// isMin here — the user might be dragging INTO the rail (no
|
|
489
|
+
// expand intent), and pre-clearing would visually flash the
|
|
490
|
+
// card out of its rail state before we know which direction
|
|
491
|
+
// they're going. Rail release is deferred to onPointerMove,
|
|
492
|
+
// where it gates on outward direction (primarySign * delta
|
|
493
|
+
// > 0). Tap-without-drag is therefore inert on the gutter.
|
|
494
|
+
primaryStartedMin = isMin[primaryIdx];
|
|
495
|
+
primaryInEscape = false;
|
|
496
|
+
primaryCanSnap = !primaryStartedMin;
|
|
497
|
+
everMoved = false;
|
|
498
|
+
activePointerId = e.pointerId;
|
|
499
|
+
dragStartCoord = e[clientCoord];
|
|
500
|
+
dragStartPrimary = sizes[primaryIdx];
|
|
501
|
+
primaryMaxReached = dragStartPrimary;
|
|
502
|
+
lastBucket = -1;
|
|
503
|
+
log('pointerdown g=' + g, {
|
|
504
|
+
primaryIdx: primaryIdx,
|
|
505
|
+
primarySign: primarySign,
|
|
506
|
+
primaryStartedMin: primaryStartedMin,
|
|
507
|
+
dragStartPrimary: dragStartPrimary,
|
|
508
|
+
mins_primary: mins[primaryIdx],
|
|
509
|
+
railSizePx: railSizePx,
|
|
510
|
+
lastNonZero_primary: lastNonZero[primaryIdx]
|
|
511
|
+
});
|
|
512
|
+
try { gut.setPointerCapture(e.pointerId); } catch (err) { /* iOS */ }
|
|
513
|
+
root.classList.add('pa-splitter--dragging');
|
|
514
|
+
gut.classList.add('pa-splitter__gutter--active');
|
|
515
|
+
gut.addEventListener('pointermove', onPointerMove);
|
|
516
|
+
gut.addEventListener('pointerup', onPointerUp);
|
|
517
|
+
gut.addEventListener('pointercancel', onPointerUp);
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// rAF-throttle the move handler so each frame coalesces all
|
|
522
|
+
// pending pointermove events into a single applySizes. Without
|
|
523
|
+
// this, a 120 Hz trackpad fires move at ~8 ms and every event
|
|
524
|
+
// triggers flex-basis writes + reflow — measurable jank on a
|
|
525
|
+
// pane containing a chart canvas or iframe.
|
|
526
|
+
var pendingMove = null;
|
|
527
|
+
var rafScheduled = false;
|
|
528
|
+
function snapThreshold(anchor) {
|
|
529
|
+
// Rebase on the drag-start (or max-reached) size of the
|
|
530
|
+
// primary so the threshold is meaningful even when min is 0.
|
|
531
|
+
// At ratio=0.4 the user has to drag below 40% of the anchor
|
|
532
|
+
// to commit. Floored at rail × 1.5 so a pane already near
|
|
533
|
+
// rail doesn't insta-snap on first move.
|
|
534
|
+
return Math.max(railSizePx * 1.5, railSizePx + (anchor - railSizePx) * minimizeThresholdRatio);
|
|
535
|
+
}
|
|
536
|
+
function processMove() {
|
|
537
|
+
rafScheduled = false;
|
|
538
|
+
if (pendingMove == null) return;
|
|
539
|
+
var coord = pendingMove;
|
|
540
|
+
pendingMove = null;
|
|
541
|
+
var delta = coord - dragStartCoord;
|
|
542
|
+
var newPrimary = dragStartPrimary + (primarySign * delta);
|
|
543
|
+
|
|
544
|
+
// SNAP-INTO-RAIL: only fires on the primary, and only after
|
|
545
|
+
// it has committed to expanded (primaryCanSnap = true,
|
|
546
|
+
// which is set to true at pointerdown for drag-from-expanded
|
|
547
|
+
// and flipped on the first crossing of mins[primary] for
|
|
548
|
+
// drag-from-rail). Threshold anchored on maxReached.
|
|
549
|
+
if (canMin[primaryIdx] && primaryCanSnap) {
|
|
550
|
+
if (newPrimary < snapThreshold(primaryMaxReached) && nonMinCount() > 1) {
|
|
551
|
+
log('move g=' + g + ' SNAP-INTO-RAIL primary i=' + primaryIdx,
|
|
552
|
+
{ newPrimary: newPrimary, threshold: snapThreshold(primaryMaxReached), maxReached: primaryMaxReached });
|
|
553
|
+
// Compute absorbers BEFORE flipping isMin so the
|
|
554
|
+
// walk doesn't treat the primary as a rail wall.
|
|
555
|
+
var snapAbsorbers = computeAbsorbers(g, primaryIdx);
|
|
556
|
+
isMin[primaryIdx] = true;
|
|
557
|
+
sizes[primaryIdx] = railSizePx;
|
|
558
|
+
// Redistribute the freed slack across the contiguous
|
|
559
|
+
// non-rail block on the secondary side only.
|
|
560
|
+
var totalSnap = totalAvailable();
|
|
561
|
+
clampToConstraints(sizes, totalSnap, pinnedForAbsorbers(snapAbsorbers));
|
|
562
|
+
applySizes({ persist: false });
|
|
563
|
+
fireCollapse(primaryIdx);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// DRAG-OUT-OF-RAIL: primary snapped mid-drag and user has
|
|
569
|
+
// now dragged back past the threshold — release the rail
|
|
570
|
+
// commitment. Sets primaryInEscape so the floor stays at
|
|
571
|
+
// railSizePx for the transition out (auto-cleared once
|
|
572
|
+
// newPrimary reaches mins[primary]).
|
|
573
|
+
if (isMin[primaryIdx] && newPrimary >= snapThreshold(primaryMaxReached)) {
|
|
574
|
+
log('move g=' + g + ' DRAG-OUT-OF-RAIL primary i=' + primaryIdx,
|
|
575
|
+
{ newPrimary: newPrimary, threshold: snapThreshold(primaryMaxReached) });
|
|
576
|
+
isMin[primaryIdx] = false;
|
|
577
|
+
primaryInEscape = true;
|
|
578
|
+
fireExpand(primaryIdx);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// If primary is still railed (cursor in the snap zone), the
|
|
582
|
+
// drag is a no-op for this frame — primary stays at
|
|
583
|
+
// railSizePx, layout is already correct from the SNAP-INTO-
|
|
584
|
+
// RAIL action. Skipping the floor/max/rebalance below is
|
|
585
|
+
// essential: otherwise the floor would clamp newPrimary up
|
|
586
|
+
// to mins[primary] and re-introduce a "minimized AND at
|
|
587
|
+
// min-width" state (the bug visible as the Inspector
|
|
588
|
+
// showing rail icon but at 180px instead of 40px width).
|
|
589
|
+
if (isMin[primaryIdx]) return;
|
|
590
|
+
|
|
591
|
+
// Floor / max for primary. Floor is railSizePx (not mins)
|
|
592
|
+
// when the primary is still in its rail→expanded transition;
|
|
593
|
+
// otherwise it's mins[primary]. The real mins clamp is
|
|
594
|
+
// applied on pointerup if the final size landed below it.
|
|
595
|
+
var floor = ((primaryStartedMin && !primaryCanSnap) || primaryInEscape) ? railSizePx : mins[primaryIdx];
|
|
596
|
+
if (newPrimary < floor) newPrimary = floor;
|
|
597
|
+
if (newPrimary > maxes[primaryIdx]) newPrimary = maxes[primaryIdx];
|
|
598
|
+
|
|
599
|
+
if (primaryInEscape && newPrimary >= mins[primaryIdx]) primaryInEscape = false;
|
|
600
|
+
if (!primaryCanSnap && newPrimary >= mins[primaryIdx]) primaryCanSnap = true;
|
|
601
|
+
if (newPrimary > primaryMaxReached) primaryMaxReached = newPrimary;
|
|
602
|
+
|
|
603
|
+
// Apply primary's new size, then let clampToConstraints
|
|
604
|
+
// distribute the opposite delta across the contiguous
|
|
605
|
+
// non-rail block on the secondary side ONLY. Everything
|
|
606
|
+
// else (rails, primary, panes across a rail wall) stays
|
|
607
|
+
// pinned. Primary stays at newPrimary unless absorbers
|
|
608
|
+
// can't yield enough — see post-check below.
|
|
609
|
+
sizes[primaryIdx] = newPrimary;
|
|
610
|
+
var total = totalAvailable();
|
|
611
|
+
var absorbers = computeAbsorbers(g, primaryIdx);
|
|
612
|
+
clampToConstraints(sizes, total, pinnedForAbsorbers(absorbers));
|
|
613
|
+
|
|
614
|
+
// Post-check: if absorbers couldn't absorb fully (all at
|
|
615
|
+
// their mins), the sum overshoots total. Pull primary back
|
|
616
|
+
// by the overshoot so the layout fits.
|
|
617
|
+
var sum = 0;
|
|
618
|
+
for (var ck = 0; ck < N; ck++) sum += sizes[ck];
|
|
619
|
+
if (Math.abs(sum - total) > 0.5) {
|
|
620
|
+
sizes[primaryIdx] -= (sum - total);
|
|
621
|
+
if (sizes[primaryIdx] < floor) sizes[primaryIdx] = floor;
|
|
622
|
+
if (sizes[primaryIdx] > maxes[primaryIdx]) sizes[primaryIdx] = maxes[primaryIdx];
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
var bucket = total > 0 ? Math.floor(sizes[primaryIdx] / total * 4) : -1;
|
|
626
|
+
if (bucket !== lastBucket) {
|
|
627
|
+
lastBucket = bucket;
|
|
628
|
+
log('move g=' + g + ' bucket=' + bucket + '/4',
|
|
629
|
+
'primary=i' + primaryIdx + ':' + Math.round(sizes[primaryIdx]),
|
|
630
|
+
'sign=' + primarySign,
|
|
631
|
+
'floor=' + floor,
|
|
632
|
+
'startedMin=' + primaryStartedMin,
|
|
633
|
+
'canSnap=' + primaryCanSnap,
|
|
634
|
+
'maxReached=' + Math.round(primaryMaxReached),
|
|
635
|
+
'inEscape=' + primaryInEscape,
|
|
636
|
+
'isMin=' + isMin[primaryIdx]);
|
|
637
|
+
}
|
|
638
|
+
applySizes({ persist: false });
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function onPointerMove(e) {
|
|
642
|
+
if (e.pointerId !== activePointerId) return;
|
|
643
|
+
var delta = e[clientCoord] - dragStartCoord;
|
|
644
|
+
if (!everMoved && Math.abs(delta) >= TAP_PX) {
|
|
645
|
+
everMoved = true;
|
|
646
|
+
}
|
|
647
|
+
// Asymmetric drag against a primary that started rail'd:
|
|
648
|
+
// outward (primarySign * delta > 0, would grow primary) →
|
|
649
|
+
// release the rail, fire expand, fall through to drag math.
|
|
650
|
+
// inward (primarySign * delta <= 0, would shrink further) →
|
|
651
|
+
// no-op for the frame. Pane stays rail'd, gutter doesn't
|
|
652
|
+
// move, no applySizes call. Stay inert until either the
|
|
653
|
+
// direction flips outward or the user releases.
|
|
654
|
+
if (primaryStartedMin && isMin[primaryIdx]) {
|
|
655
|
+
if (primarySign * delta <= 0) return;
|
|
656
|
+
if (Math.abs(delta) < TAP_PX) return;
|
|
657
|
+
log('move g=' + g + ' DRAG-OUT-OF-RAIL-AT-START primary i=' + primaryIdx);
|
|
658
|
+
isMin[primaryIdx] = false;
|
|
659
|
+
primaryInEscape = true;
|
|
660
|
+
fireExpand(primaryIdx);
|
|
661
|
+
}
|
|
662
|
+
pendingMove = e[clientCoord];
|
|
663
|
+
if (rafScheduled) return;
|
|
664
|
+
rafScheduled = true;
|
|
665
|
+
requestAnimationFrame(processMove);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function onPointerUp(e) {
|
|
669
|
+
if (e.pointerId !== activePointerId) return;
|
|
670
|
+
// Flush any rAF-pending move so the final cursor position is
|
|
671
|
+
// reflected in sizes before end-of-drag decisions. Without
|
|
672
|
+
// this, a last-millisecond move scheduled a rAF that fires
|
|
673
|
+
// AFTER pointerup, overwriting pointerup's decisions.
|
|
674
|
+
if (rafScheduled && pendingMove != null) {
|
|
675
|
+
log('pointerup g=' + g + ' flushing pending move');
|
|
676
|
+
processMove();
|
|
677
|
+
}
|
|
678
|
+
rafScheduled = false;
|
|
679
|
+
pendingMove = null;
|
|
680
|
+
try { gut.releasePointerCapture(e.pointerId); } catch (err) { /* */ }
|
|
681
|
+
activePointerId = null;
|
|
682
|
+
root.classList.remove('pa-splitter--dragging');
|
|
683
|
+
gut.classList.remove('pa-splitter__gutter--active');
|
|
684
|
+
gut.removeEventListener('pointermove', onPointerMove);
|
|
685
|
+
gut.removeEventListener('pointerup', onPointerUp);
|
|
686
|
+
gut.removeEventListener('pointercancel', onPointerUp);
|
|
687
|
+
log('pointerup g=' + g, {
|
|
688
|
+
everMoved: everMoved,
|
|
689
|
+
primaryIdx: primaryIdx,
|
|
690
|
+
primaryStartedMin: primaryStartedMin,
|
|
691
|
+
sizes_primary: sizes[primaryIdx],
|
|
692
|
+
mins_primary: mins[primaryIdx],
|
|
693
|
+
isMin_primary: isMin[primaryIdx]
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
// Tap-without-drag on the gutter is intentionally inert.
|
|
697
|
+
// Restore gestures: rail-body click (handler below), dblclick
|
|
698
|
+
// on the gutter, focus + Enter/Space, toggle button, or drag
|
|
699
|
+
// the gutter outward past the snap threshold.
|
|
700
|
+
|
|
701
|
+
// RAIL-STAYED: primary started rail, the user dragged
|
|
702
|
+
// outward enough to release isMin (set in onPointerMove),
|
|
703
|
+
// but the size never grew meaningfully above rail (absorbers
|
|
704
|
+
// had no headroom, or the user dragged back into the snap
|
|
705
|
+
// zone before release). Restore isMin so the visual state
|
|
706
|
+
// matches and fire collapse so consumers see the round-trip.
|
|
707
|
+
var anyChange = false;
|
|
708
|
+
if (primaryStartedMin && Math.abs(sizes[primaryIdx] - railSizePx) < 1 && !isMin[primaryIdx]) {
|
|
709
|
+
log('pointerup g=' + g + ' RAIL-STAYED primary i=' + primaryIdx);
|
|
710
|
+
isMin[primaryIdx] = true;
|
|
711
|
+
anyChange = true;
|
|
712
|
+
fireCollapse(primaryIdx);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// CLAMP-TO-MIN: any non-rail pane sitting below its mins
|
|
716
|
+
// (primary that got pulled back during absorber starvation,
|
|
717
|
+
// or absorbers themselves) gets bumped up; remainder
|
|
718
|
+
// redistributed via clampToConstraints across all non-rail
|
|
719
|
+
// panes proportionally.
|
|
720
|
+
var anyBelowMin = false;
|
|
721
|
+
for (var bj = 0; bj < N; bj++) {
|
|
722
|
+
if (!isMin[bj] && sizes[bj] < mins[bj] - 0.5) { anyBelowMin = true; break; }
|
|
723
|
+
}
|
|
724
|
+
if (anyBelowMin) {
|
|
725
|
+
log('pointerup g=' + g + ' CLAMP-TO-MIN');
|
|
726
|
+
for (var bk = 0; bk < N; bk++) {
|
|
727
|
+
if (!isMin[bk] && sizes[bk] < mins[bk]) sizes[bk] = mins[bk];
|
|
728
|
+
}
|
|
729
|
+
var totalPU = totalAvailable();
|
|
730
|
+
clampToConstraints(sizes, totalPU, isMin);
|
|
731
|
+
applySizes({ persist: false });
|
|
732
|
+
} else if (anyChange) {
|
|
733
|
+
applySizes({ persist: false });
|
|
734
|
+
}
|
|
735
|
+
primaryStartedMin = false;
|
|
736
|
+
primaryInEscape = false;
|
|
737
|
+
primaryCanSnap = false;
|
|
738
|
+
primaryMaxReached = 0;
|
|
739
|
+
primaryIdx = -1;
|
|
740
|
+
if (id) writeStorage(id, { v: 2, sizes: sizes.slice(), lasts: lastNonZero.slice(), minimized: isMin.slice() });
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
gut.addEventListener('pointerdown', onPointerDown);
|
|
744
|
+
|
|
745
|
+
gut.addEventListener('dblclick', function (e) {
|
|
746
|
+
e.preventDefault();
|
|
747
|
+
// Double-click toggles the primary neighbour, falling back
|
|
748
|
+
// to the other neighbour if the primary can't minimize.
|
|
749
|
+
var primary = primaryNeighbour(g);
|
|
750
|
+
var other = primary === g ? g + 1 : g;
|
|
751
|
+
log('dblclick g=' + g, { primary: primary, other: other, canMin_primary: canMin[primary], canMin_other: canMin[other] });
|
|
752
|
+
if (canMin[primary]) togglePane(primary);
|
|
753
|
+
else if (canMin[other]) togglePane(other);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
gut.addEventListener('keydown', function (e) {
|
|
757
|
+
var handled = false;
|
|
758
|
+
var step = stepPx;
|
|
759
|
+
switch (e.key) {
|
|
760
|
+
case 'ArrowLeft':
|
|
761
|
+
case 'ArrowUp':
|
|
762
|
+
shiftPrimary(g, -step);
|
|
763
|
+
handled = true;
|
|
764
|
+
break;
|
|
765
|
+
case 'ArrowRight':
|
|
766
|
+
case 'ArrowDown':
|
|
767
|
+
shiftPrimary(g, step);
|
|
768
|
+
handled = true;
|
|
769
|
+
break;
|
|
770
|
+
case 'Home':
|
|
771
|
+
setPrimaryTo(g, mins[primaryNeighbour(g)]);
|
|
772
|
+
handled = true;
|
|
773
|
+
break;
|
|
774
|
+
case 'End':
|
|
775
|
+
setPrimaryTo(g, maxes[primaryNeighbour(g)]);
|
|
776
|
+
handled = true;
|
|
777
|
+
break;
|
|
778
|
+
case 'Enter':
|
|
779
|
+
case ' ':
|
|
780
|
+
var primaryK = primaryNeighbour(g);
|
|
781
|
+
var otherK = primaryK === g ? g + 1 : g;
|
|
782
|
+
if (canMin[primaryK]) togglePane(primaryK);
|
|
783
|
+
else if (canMin[otherK]) togglePane(otherK);
|
|
784
|
+
handled = true;
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
if (handled) e.preventDefault();
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Keyboard equivalent of a drag: change the primary neighbour by
|
|
792
|
+
// `gutterDelta` px (sign convention: positive moves the gutter in
|
|
793
|
+
// the "right/down" direction, so primary on the left grows and
|
|
794
|
+
// primary on the right shrinks). Mirrors the rebalance model used
|
|
795
|
+
// by drag — slack absorbed by all non-rail non-primary panes.
|
|
796
|
+
function shiftPrimary(g, gutterDelta) {
|
|
797
|
+
var primary = primaryNeighbour(g);
|
|
798
|
+
if (isMin[primary]) return;
|
|
799
|
+
var sign = (primary === g) ? 1 : -1;
|
|
800
|
+
setPrimaryTo(g, sizes[primary] + sign * gutterDelta);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function setPrimaryTo(g, newSize) {
|
|
804
|
+
var primary = primaryNeighbour(g);
|
|
805
|
+
if (isMin[primary]) return;
|
|
806
|
+
var target = newSize;
|
|
807
|
+
if (target < mins[primary]) target = mins[primary];
|
|
808
|
+
if (target > maxes[primary]) target = maxes[primary];
|
|
809
|
+
sizes[primary] = target;
|
|
810
|
+
var total = totalAvailable();
|
|
811
|
+
var absorbers = computeAbsorbers(g, primary);
|
|
812
|
+
clampToConstraints(sizes, total, pinnedForAbsorbers(absorbers));
|
|
813
|
+
var sum = 0;
|
|
814
|
+
for (var i = 0; i < N; i++) sum += sizes[i];
|
|
815
|
+
if (Math.abs(sum - total) > 0.5) {
|
|
816
|
+
sizes[primary] -= (sum - total);
|
|
817
|
+
if (sizes[primary] < mins[primary]) sizes[primary] = mins[primary];
|
|
818
|
+
if (sizes[primary] > maxes[primary]) sizes[primary] = maxes[primary];
|
|
819
|
+
}
|
|
820
|
+
applySizes();
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// "At least one expanded pane" invariant. Without it, restore-from-rail
|
|
824
|
+
// math degenerates (all panes at rail → available headroom from other
|
|
825
|
+
// panes is zero or negative → restore can't pull enough room for the
|
|
826
|
+
// pane being restored, layout overflows). Toggle button / dblclick /
|
|
827
|
+
// drag-to-rail all gate on this.
|
|
828
|
+
function nonMinCount() {
|
|
829
|
+
var c = 0;
|
|
830
|
+
for (var k = 0; k < N; k++) if (!isMin[k]) c++;
|
|
831
|
+
return c;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// Absorbers for a drag on gutter `g` with the given `primary`
|
|
835
|
+
// neighbour. Two modes:
|
|
836
|
+
//
|
|
837
|
+
// 1. CLASSIC (no rail wall between primary and the immediate
|
|
838
|
+
// secondary neighbour): just the immediate adjacent non-rail
|
|
839
|
+
// pane absorbs. Equivalent to a standard boundary-coupled
|
|
840
|
+
// splitter — panes farther away on the same side don't shift.
|
|
841
|
+
//
|
|
842
|
+
// 2. TUNNEL (immediate secondary is rail): skip the rail(s),
|
|
843
|
+
// then collect the contiguous non-rail block beyond. Lets
|
|
844
|
+
// slack punch through a rail wall to the next "section",
|
|
845
|
+
// which is the only way drag-from-rail can grow the primary
|
|
846
|
+
// when its immediate neighbour is also rail (e.g. AcBcCeDeEc
|
|
847
|
+
// dragging A|B — slack has to come from C/D past the B wall).
|
|
848
|
+
//
|
|
849
|
+
// In all-expanded layouts mode 1 fires and only the adjacent pane
|
|
850
|
+
// changes, which matches user intuition: dragging D|E in `abcde`
|
|
851
|
+
// shouldn't ripple A and B around.
|
|
852
|
+
function computeAbsorbers(g, primary) {
|
|
853
|
+
var absorbers = [];
|
|
854
|
+
var sawRail = false;
|
|
855
|
+
if (primary === g) {
|
|
856
|
+
// Secondary side = right of gutter; walk from g+1 forward.
|
|
857
|
+
for (var i = g + 1; i < N; i++) {
|
|
858
|
+
if (isMin[i]) {
|
|
859
|
+
if (absorbers.length > 0) break; // end of tunneled block
|
|
860
|
+
sawRail = true;
|
|
861
|
+
continue;
|
|
862
|
+
}
|
|
863
|
+
absorbers.push(i);
|
|
864
|
+
if (!sawRail) break; // CLASSIC: only the immediate neighbour
|
|
865
|
+
}
|
|
866
|
+
} else {
|
|
867
|
+
// Secondary side = left of gutter; walk from g backward.
|
|
868
|
+
for (var j = g; j >= 0; j--) {
|
|
869
|
+
if (isMin[j]) {
|
|
870
|
+
if (absorbers.length > 0) break;
|
|
871
|
+
sawRail = true;
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
absorbers.push(j);
|
|
875
|
+
if (!sawRail) break;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return absorbers;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Build a `pinned` array for clampToConstraints that lets ONLY the
|
|
882
|
+
// listed absorbers flex; everything else (rail panes, primary,
|
|
883
|
+
// panes on the far side of a rail wall) stays put.
|
|
884
|
+
function pinnedForAbsorbers(absorbers) {
|
|
885
|
+
var pinned = new Array(N);
|
|
886
|
+
for (var i = 0; i < N; i++) pinned[i] = true;
|
|
887
|
+
for (var k = 0; k < absorbers.length; k++) pinned[absorbers[k]] = false;
|
|
888
|
+
return pinned;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Primary-neighbour heuristic for gutter `g`: pick the side closer to
|
|
892
|
+
// its container edge (so a gutter near the right edge picks pane g+1,
|
|
893
|
+
// a gutter near the left edge picks pane g). When the two neighbours
|
|
894
|
+
// are equidistant from their respective edges — exact-middle gutter on
|
|
895
|
+
// an even-N splitter, or the lone gutter on a 2-pane splitter —
|
|
896
|
+
// LTR/RTL is the tiebreaker (LTR → left, RTL → right). Used by
|
|
897
|
+
// dblclick and keyboard toggle (`Enter` / `Space`) to decide which
|
|
898
|
+
// neighbour collapses when both are candidates.
|
|
899
|
+
function primaryNeighbour(g) {
|
|
900
|
+
var leftDist = g; // pane g → left edge
|
|
901
|
+
var rightDist = N - 2 - g; // pane g+1 → right edge
|
|
902
|
+
if (leftDist < rightDist) return g;
|
|
903
|
+
if (rightDist < leftDist) return g + 1;
|
|
904
|
+
return getComputedStyle(root).direction === 'rtl' ? (g + 1) : g;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function minimizePane(i) {
|
|
908
|
+
if (!canMin[i] || isMin[i]) {
|
|
909
|
+
log('minimizePane i=' + i + ' NOOP', { canMin: canMin[i], isMin: isMin[i] });
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (nonMinCount() <= 1) {
|
|
913
|
+
log('minimizePane i=' + i + ' BLOCKED (would leave zero non-min)');
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
var slack = sizes[i] - railSizePx;
|
|
917
|
+
log('minimizePane i=' + i, {
|
|
918
|
+
sizes_i: sizes[i],
|
|
919
|
+
slack: slack,
|
|
920
|
+
position: i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle'),
|
|
921
|
+
lastNonZero_i_before: lastNonZero[i]
|
|
922
|
+
});
|
|
923
|
+
isMin[i] = true;
|
|
924
|
+
sizes[i] = railSizePx;
|
|
925
|
+
// Distribute slack across ALL non-minimized panes proportionally
|
|
926
|
+
// to their current sizes. clampToConstraints walks the sum, picks
|
|
927
|
+
// panes with growth headroom (max - size), and redistributes the
|
|
928
|
+
// delta to them — respecting per-pane maxes and converging in
|
|
929
|
+
// up to N passes. Pinning all currently-minimized panes (via
|
|
930
|
+
// isMin) keeps railed panes locked at rail width.
|
|
931
|
+
var total = totalAvailable();
|
|
932
|
+
clampToConstraints(sizes, total, isMin);
|
|
933
|
+
applySizes();
|
|
934
|
+
fireCollapse(i);
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function restorePane(i) {
|
|
938
|
+
if (!isMin[i]) {
|
|
939
|
+
log('restorePane i=' + i + ' NOOP (not minimized)');
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// ACCORDION SWEEP: when accordion mode is engaged, restoring a
|
|
943
|
+
// pane auto-rails any other currently-expanded minimizable
|
|
944
|
+
// panes so exactly one stays open. `lastNonZero` for the
|
|
945
|
+
// panes being railed was already captured on their last
|
|
946
|
+
// applySizes while they were expanded — no extra bookkeeping
|
|
947
|
+
// needed for a future restore to recover their size.
|
|
948
|
+
if (accordionActive) {
|
|
949
|
+
for (var ai = 0; ai < N; ai++) {
|
|
950
|
+
if (ai !== i && canMin[ai] && !isMin[ai]) {
|
|
951
|
+
log('restorePane accordion-rail i=' + ai + ' (sweep for restore of i=' + i + ')');
|
|
952
|
+
isMin[ai] = true;
|
|
953
|
+
sizes[ai] = railSizePx;
|
|
954
|
+
fireCollapse(ai);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
isMin[i] = false;
|
|
959
|
+
// Target: remembered "expanded" size, but never below mins[i].
|
|
960
|
+
// Compute the mins floor first so `deficit` below reflects the
|
|
961
|
+
// actual restored amount (the old order silently bumped target
|
|
962
|
+
// after deficit was captured, making the log say `deficit: 0`
|
|
963
|
+
// for a 120 px restore).
|
|
964
|
+
var target = lastNonZero[i] > 0 ? lastNonZero[i] : Math.max(mins[i], railSizePx * 4);
|
|
965
|
+
if (target < mins[i]) target = mins[i];
|
|
966
|
+
var total = totalAvailable();
|
|
967
|
+
// Two sources of room for the restore:
|
|
968
|
+
// 1. Headroom from other non-minimized panes (sizes - mins).
|
|
969
|
+
// 2. Empty space currently in the container (total - sum) —
|
|
970
|
+
// a prior minimize can leave a gap when the only available
|
|
971
|
+
// absorbers hit their `max` cap before consuming all the
|
|
972
|
+
// slack. Restoring should reclaim that gap first; without
|
|
973
|
+
// this the restored pane only sees source #1 and gets
|
|
974
|
+
// stuck at mins even though there's plenty of empty layout
|
|
975
|
+
// space waiting.
|
|
976
|
+
var currentSum = 0;
|
|
977
|
+
var fromOthers = 0;
|
|
978
|
+
for (var j = 0; j < N; j++) {
|
|
979
|
+
currentSum += sizes[j];
|
|
980
|
+
if (j !== i && !isMin[j]) fromOthers += sizes[j] - mins[j];
|
|
981
|
+
}
|
|
982
|
+
var emptySpace = total - currentSum;
|
|
983
|
+
if (emptySpace < 0) emptySpace = 0;
|
|
984
|
+
var available = fromOthers + emptySpace;
|
|
985
|
+
var deficit = target - sizes[i];
|
|
986
|
+
if (deficit > available) {
|
|
987
|
+
target = sizes[i] + available;
|
|
988
|
+
deficit = available;
|
|
989
|
+
}
|
|
990
|
+
log('restorePane i=' + i, {
|
|
991
|
+
lastNonZero_i: lastNonZero[i],
|
|
992
|
+
position: i === 0 ? 'start' : (i === N - 1 ? 'end' : 'middle'),
|
|
993
|
+
deficit: deficit,
|
|
994
|
+
target: target,
|
|
995
|
+
available: available,
|
|
996
|
+
fromOthers: fromOthers,
|
|
997
|
+
emptySpace: emptySpace
|
|
998
|
+
});
|
|
999
|
+
sizes[i] = target;
|
|
1000
|
+
// Only redistribute on OVERSHOOT (sum > total). If the restored
|
|
1001
|
+
// pane plus the unchanged others already fits, leave any
|
|
1002
|
+
// remaining gap alone — don't grow neighbours to fill it.
|
|
1003
|
+
// Without this, restoring pane i with a leftover gap (e.g. 5
|
|
1004
|
+
// panes where pane 0 was capped at its max during minimization)
|
|
1005
|
+
// would make clampToConstraints "fill" the gap by inflating
|
|
1006
|
+
// the only flexable non-rail pane, producing the jump UX where
|
|
1007
|
+
// a previously-restored pane suddenly grows when another pane
|
|
1008
|
+
// is restored. Visible empty space is the lesser evil here:
|
|
1009
|
+
// it goes away naturally as the user restores more panes.
|
|
1010
|
+
//
|
|
1011
|
+
// Accordion mode is the explicit opposite — only one pane is
|
|
1012
|
+
// expanded so a gap to its right is just dead space. Fill it.
|
|
1013
|
+
var newSum = 0;
|
|
1014
|
+
for (var ns = 0; ns < N; ns++) newSum += sizes[ns];
|
|
1015
|
+
if (newSum > total + 0.5) {
|
|
1016
|
+
var pinned = isMin.slice();
|
|
1017
|
+
pinned[i] = true;
|
|
1018
|
+
clampToConstraints(sizes, total, pinned);
|
|
1019
|
+
} else if (accordionActive) {
|
|
1020
|
+
expandNonMinToFill();
|
|
1021
|
+
}
|
|
1022
|
+
applySizes();
|
|
1023
|
+
fireExpand(i);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function togglePane(i) {
|
|
1027
|
+
log('togglePane i=' + i, { currentlyMin: isMin[i] });
|
|
1028
|
+
if (isMin[i]) restorePane(i);
|
|
1029
|
+
else minimizePane(i);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ---- Accordion mode (auto, viewport-driven) ----
|
|
1033
|
+
// Engaged when container width is below the sum of all panes'
|
|
1034
|
+
// mins + gutters + gaps + padding AND there are 2+ minimizable
|
|
1035
|
+
// panes (otherwise there's nothing to switch between). While
|
|
1036
|
+
// active, restoring one pane auto-rails the others — see the
|
|
1037
|
+
// ACCORDION SWEEP block in restorePane().
|
|
1038
|
+
var accordionActive = false;
|
|
1039
|
+
|
|
1040
|
+
function requiredForAllExpanded() {
|
|
1041
|
+
// mins[] are in px (resolved by resolveConstraints against the
|
|
1042
|
+
// current container). gutterTotal/gapPx/paddingPx mirror
|
|
1043
|
+
// totalAvailable()'s deductions so the comparison is apples-
|
|
1044
|
+
// to-apples with root[clientAxis].
|
|
1045
|
+
var sum = 0;
|
|
1046
|
+
for (var i = 0; i < N; i++) sum += mins[i];
|
|
1047
|
+
var gapCount = 2 * (N - 1);
|
|
1048
|
+
return sum + gutterTotal() + (gapCount * gapPx()) + paddingPx();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function shouldBeAccordion() {
|
|
1052
|
+
var minimizableCount = 0;
|
|
1053
|
+
for (var i = 0; i < N; i++) if (canMin[i]) minimizableCount++;
|
|
1054
|
+
if (minimizableCount < 2) return false;
|
|
1055
|
+
return root[clientAxis] < requiredForAllExpanded();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function enterAccordion() {
|
|
1059
|
+
if (accordionActive) return;
|
|
1060
|
+
accordionActive = true;
|
|
1061
|
+
root.classList.add('pa-splitter--accordion');
|
|
1062
|
+
// Pick what to keep expanded. Priority:
|
|
1063
|
+
// 1. A non-minimizable pane (it has to stay expanded anyway)
|
|
1064
|
+
// 2. The first currently-expanded minimizable pane (user's
|
|
1065
|
+
// current focus survives the transition)
|
|
1066
|
+
// 3. Pane 0 as fallback
|
|
1067
|
+
var keepIdx = -1;
|
|
1068
|
+
for (var i = 0; i < N; i++) {
|
|
1069
|
+
if (!canMin[i]) { keepIdx = i; break; }
|
|
1070
|
+
}
|
|
1071
|
+
if (keepIdx === -1) {
|
|
1072
|
+
for (var j = 0; j < N; j++) {
|
|
1073
|
+
if (canMin[j] && !isMin[j]) { keepIdx = j; break; }
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
if (keepIdx === -1) keepIdx = 0;
|
|
1077
|
+
log('enterAccordion keepIdx=' + keepIdx);
|
|
1078
|
+
for (var k = 0; k < N; k++) {
|
|
1079
|
+
if (k !== keepIdx && canMin[k] && !isMin[k]) {
|
|
1080
|
+
isMin[k] = true;
|
|
1081
|
+
sizes[k] = railSizePx;
|
|
1082
|
+
fireCollapse(k);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
expandNonMinToFill();
|
|
1086
|
+
applySizes();
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// In accordion mode the expanded pane(s) consume all remaining
|
|
1090
|
+
// space, IGNORING their declared `max` constraints. Rationale:
|
|
1091
|
+
// `max` is a "share fairly with siblings" ceiling that stops
|
|
1092
|
+
// making sense once the siblings are all railed — strictly
|
|
1093
|
+
// honouring it just leaves a visible gap to the right of the
|
|
1094
|
+
// expanded pane. Mins are still respected.
|
|
1095
|
+
function expandNonMinToFill() {
|
|
1096
|
+
var total = totalAvailable();
|
|
1097
|
+
var sum = 0;
|
|
1098
|
+
var flexable = [];
|
|
1099
|
+
var flexableWeight = 0;
|
|
1100
|
+
for (var i = 0; i < N; i++) {
|
|
1101
|
+
sum += sizes[i];
|
|
1102
|
+
if (!isMin[i]) {
|
|
1103
|
+
flexable.push(i);
|
|
1104
|
+
flexableWeight += sizes[i] > 0 ? sizes[i] : 1;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
var diff = total - sum;
|
|
1108
|
+
if (flexable.length === 0 || Math.abs(diff) < 0.5) return;
|
|
1109
|
+
for (var k = 0; k < flexable.length; k++) {
|
|
1110
|
+
var idx = flexable[k];
|
|
1111
|
+
var weight = (sizes[idx] > 0 ? sizes[idx] : 1) / flexableWeight;
|
|
1112
|
+
var next = sizes[idx] + diff * weight;
|
|
1113
|
+
if (next < mins[idx]) next = mins[idx];
|
|
1114
|
+
sizes[idx] = next;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function exitAccordion() {
|
|
1119
|
+
if (!accordionActive) return;
|
|
1120
|
+
accordionActive = false;
|
|
1121
|
+
root.classList.remove('pa-splitter--accordion');
|
|
1122
|
+
log('exitAccordion');
|
|
1123
|
+
// Don't auto-restore panes. The user railed them implicitly
|
|
1124
|
+
// by going narrow; restoring them on widen would surprise
|
|
1125
|
+
// anyone who narrowed/widened during a single session. They
|
|
1126
|
+
// click a rail when they want it back.
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// ---- Initial size resolution ----
|
|
1130
|
+
var initialTotal = resolveConstraints();
|
|
1131
|
+
log('init total available', initialTotal);
|
|
1132
|
+
var saved = readStorage(id);
|
|
1133
|
+
log('storage read', saved);
|
|
1134
|
+
|
|
1135
|
+
var startupMinimized = new Array(N);
|
|
1136
|
+
// Storage merge is forgiving: matching-index entries are trusted,
|
|
1137
|
+
// missing slots fall through to attribute resolution, extra slots are
|
|
1138
|
+
// ignored. Survives markup drift (consumer adds / removes a pane
|
|
1139
|
+
// without nuking everyone else's saved layout).
|
|
1140
|
+
var savedSizes = null, savedLasts = null, savedMin = null;
|
|
1141
|
+
if (saved && saved.v === 2 && Array.isArray(saved.sizes)) {
|
|
1142
|
+
savedSizes = saved.sizes;
|
|
1143
|
+
savedLasts = Array.isArray(saved.lasts) ? saved.lasts : null;
|
|
1144
|
+
savedMin = Array.isArray(saved.minimized) ? saved.minimized : null;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// Pass 1: assign explicit sizes from attributes, collect unspecified.
|
|
1148
|
+
var rootSizeRef = root[clientAxis];
|
|
1149
|
+
var explicitSum = 0;
|
|
1150
|
+
var unspecified = [];
|
|
1151
|
+
for (var p = 0; p < N; p++) {
|
|
1152
|
+
var v = parseSize(sizeRaws[p], rootSizeRef);
|
|
1153
|
+
if (v == null) {
|
|
1154
|
+
sizes[p] = 0;
|
|
1155
|
+
unspecified.push(p);
|
|
1156
|
+
} else {
|
|
1157
|
+
sizes[p] = v;
|
|
1158
|
+
explicitSum += v;
|
|
1159
|
+
}
|
|
1160
|
+
startupMinimized[p] = false;
|
|
1161
|
+
}
|
|
1162
|
+
// Pass 2: distribute leftover across unspecified panes.
|
|
1163
|
+
var leftover = initialTotal - explicitSum;
|
|
1164
|
+
if (unspecified.length > 0) {
|
|
1165
|
+
var share = leftover / unspecified.length;
|
|
1166
|
+
for (var u = 0; u < unspecified.length; u++) sizes[unspecified[u]] = Math.max(0, share);
|
|
1167
|
+
} else if (Math.abs(leftover) > 0.5) {
|
|
1168
|
+
// All panes sized but they don't sum to total — give the
|
|
1169
|
+
// delta to the last pane (matches the "content absorbs" UX
|
|
1170
|
+
// most admin layouts expect).
|
|
1171
|
+
sizes[N - 1] += leftover;
|
|
1172
|
+
}
|
|
1173
|
+
for (var z = 0; z < N; z++) lastNonZero[z] = sizes[z];
|
|
1174
|
+
|
|
1175
|
+
// Pass 3: overlay saved state on matching-index slots, leaving the
|
|
1176
|
+
// rest at their attribute-derived defaults.
|
|
1177
|
+
// savedLasts[s] is filtered: values at or below the pane's `mins[s]`
|
|
1178
|
+
// are useless for restore (would just bump to mins anyway) and
|
|
1179
|
+
// typically reflect a stale buggy state where the pane's rail size
|
|
1180
|
+
// got persisted as its "last expanded size". Falling back to the
|
|
1181
|
+
// attribute-derived default (set in pass 2) gives a sensible restore.
|
|
1182
|
+
if (savedSizes) {
|
|
1183
|
+
for (var s = 0; s < Math.min(N, savedSizes.length); s++) {
|
|
1184
|
+
if (typeof savedSizes[s] === 'number') sizes[s] = savedSizes[s];
|
|
1185
|
+
if (savedLasts && typeof savedLasts[s] === 'number' && savedLasts[s] > mins[s]) lastNonZero[s] = savedLasts[s];
|
|
1186
|
+
if (savedMin && savedMin[s]) startupMinimized[s] = !!canMin[s];
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
setupGutters();
|
|
1191
|
+
|
|
1192
|
+
// Defer the first paint to rAF so a container that's 0-sized at
|
|
1193
|
+
// DOMContentLoaded (hidden parent, modal not yet open) gets a real
|
|
1194
|
+
// measurement before we clamp.
|
|
1195
|
+
requestAnimationFrame(function () {
|
|
1196
|
+
var total = resolveConstraints();
|
|
1197
|
+
log('rAF apply, total=', total, 'sizes=', sizes.slice(), 'startupMin=', startupMinimized);
|
|
1198
|
+
// Enforce the "at least one expanded pane" invariant on saved state.
|
|
1199
|
+
// A blob from before this rule (or from a corrupted localStorage)
|
|
1200
|
+
// could claim every pane is minimized; we keep the first one open
|
|
1201
|
+
// so the user has something to drag from.
|
|
1202
|
+
var allMin = true;
|
|
1203
|
+
for (var sm = 0; sm < N; sm++) if (!startupMinimized[sm]) { allMin = false; break; }
|
|
1204
|
+
if (allMin) {
|
|
1205
|
+
log('rAF apply: saved state had all panes minimized — keeping pane 0 expanded');
|
|
1206
|
+
startupMinimized[0] = false;
|
|
1207
|
+
}
|
|
1208
|
+
// Pin minimized panes to rail BEFORE clamping so the redistribute
|
|
1209
|
+
// pass leaves them alone — clampToConstraints would otherwise
|
|
1210
|
+
// happily flex a rail pane upward if the container has slack.
|
|
1211
|
+
for (var m = 0; m < N; m++) {
|
|
1212
|
+
if (startupMinimized[m]) {
|
|
1213
|
+
isMin[m] = true;
|
|
1214
|
+
sizes[m] = railSizePx;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
clampToConstraints(sizes, total, isMin);
|
|
1218
|
+
applySizes({ persist: false });
|
|
1219
|
+
// If the startup viewport is already too narrow to fit all
|
|
1220
|
+
// panes at their mins, engage accordion immediately so the
|
|
1221
|
+
// user never sees the cramped all-rails-except-one-natural
|
|
1222
|
+
// state.
|
|
1223
|
+
if (shouldBeAccordion()) enterAccordion();
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
// ---- Toggle delegation ----
|
|
1227
|
+
root.addEventListener('click', function (e) {
|
|
1228
|
+
var toggle = e.target.closest && e.target.closest('[data-pa-splitter-toggle]');
|
|
1229
|
+
if (!toggle || toggle.closest('[data-pa-splitter]') !== root) return;
|
|
1230
|
+
// Find which pane the toggle lives in.
|
|
1231
|
+
var pane = toggle.closest('.pa-splitter__pane');
|
|
1232
|
+
if (!pane) return;
|
|
1233
|
+
var idx = panes.indexOf(pane);
|
|
1234
|
+
if (idx < 0) return;
|
|
1235
|
+
if (!canMin[idx]) return;
|
|
1236
|
+
log('toggle-button click i=' + idx);
|
|
1237
|
+
e.preventDefault();
|
|
1238
|
+
e.stopPropagation();
|
|
1239
|
+
togglePane(idx);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// ---- Click rail to restore ----
|
|
1243
|
+
for (var rp = 0; rp < N; rp++) {
|
|
1244
|
+
(function (idx) {
|
|
1245
|
+
panes[idx].addEventListener('click', function () {
|
|
1246
|
+
log('rail click i=' + idx, { isMin: isMin[idx] });
|
|
1247
|
+
if (isMin[idx]) restorePane(idx);
|
|
1248
|
+
});
|
|
1249
|
+
})(rp);
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ---- Container resize ----
|
|
1253
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
1254
|
+
var lastTotal = initialTotal;
|
|
1255
|
+
var ro = new ResizeObserver(function () {
|
|
1256
|
+
var total = resolveConstraints();
|
|
1257
|
+
if (Math.abs(total - lastTotal) < 0.5) return;
|
|
1258
|
+
if (lastTotal <= 0) { lastTotal = total; return; }
|
|
1259
|
+
log('ResizeObserver fire', {
|
|
1260
|
+
oldTotal: lastTotal,
|
|
1261
|
+
newTotal: total,
|
|
1262
|
+
sizesBefore: sizes.slice(),
|
|
1263
|
+
isMin: isMin.slice()
|
|
1264
|
+
});
|
|
1265
|
+
// Accordion mode toggles BEFORE the scale pass. Entering
|
|
1266
|
+
// accordion rails most panes (large layout change); exiting
|
|
1267
|
+
// is a flag-only flip and falls through to the normal
|
|
1268
|
+
// scale so the remaining expanded panes reflow into the
|
|
1269
|
+
// new width.
|
|
1270
|
+
var wantAccordion = shouldBeAccordion();
|
|
1271
|
+
if (wantAccordion && !accordionActive) {
|
|
1272
|
+
enterAccordion();
|
|
1273
|
+
lastTotal = total;
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (!wantAccordion && accordionActive) {
|
|
1277
|
+
exitAccordion();
|
|
1278
|
+
}
|
|
1279
|
+
// Scale only the non-minimized pool. Minimized panes hold at
|
|
1280
|
+
// railSize and don't participate — their fraction of the
|
|
1281
|
+
// container intentionally drifts as the container grows.
|
|
1282
|
+
var oldNonMinSum = 0;
|
|
1283
|
+
var railBudget = 0;
|
|
1284
|
+
for (var i = 0; i < N; i++) {
|
|
1285
|
+
if (isMin[i]) { railBudget += sizes[i]; }
|
|
1286
|
+
else { oldNonMinSum += sizes[i]; }
|
|
1287
|
+
}
|
|
1288
|
+
var newNonMinTotal = total - railBudget;
|
|
1289
|
+
if (oldNonMinSum > 0 && newNonMinTotal > 0) {
|
|
1290
|
+
var scale = newNonMinTotal / oldNonMinSum;
|
|
1291
|
+
for (var j = 0; j < N; j++) {
|
|
1292
|
+
if (!isMin[j]) sizes[j] *= scale;
|
|
1293
|
+
}
|
|
1294
|
+
}
|
|
1295
|
+
// In accordion mode the expanded pane(s) ignore max and
|
|
1296
|
+
// consume all remaining space — clampToConstraints would
|
|
1297
|
+
// re-clip to max and leave a gap.
|
|
1298
|
+
if (accordionActive) {
|
|
1299
|
+
expandNonMinToFill();
|
|
1300
|
+
} else {
|
|
1301
|
+
clampToConstraints(sizes, total, isMin);
|
|
1302
|
+
}
|
|
1303
|
+
applySizes({ persist: false });
|
|
1304
|
+
lastTotal = total;
|
|
1305
|
+
});
|
|
1306
|
+
ro.observe(root);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function initAll(root) {
|
|
1311
|
+
var scope = root || document;
|
|
1312
|
+
var nodes = scope.querySelectorAll('[data-pa-splitter]');
|
|
1313
|
+
for (var i = 0; i < nodes.length; i++) init(nodes[i]);
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
window.PaSplitter = { init: init, initAll: initAll };
|
|
1317
|
+
|
|
1318
|
+
if (document.readyState === 'loading') {
|
|
1319
|
+
document.addEventListener('DOMContentLoaded', function () { initAll(); });
|
|
1320
|
+
} else {
|
|
1321
|
+
initAll();
|
|
1322
|
+
}
|
|
1323
|
+
})();
|