phonux 0.1.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/DESIGN.md +985 -0
- package/LICENSE +21 -0
- package/Panel.d.ts +76 -0
- package/Panel.js +45 -0
- package/PanelFrame.d.ts +154 -0
- package/PanelFrame.js +160 -0
- package/PanelRow.d.ts +46 -0
- package/PanelRow.js +98 -0
- package/PanelRowSlot.d.ts +74 -0
- package/PanelRowSlot.js +68 -0
- package/PhoneDetectPrompt.d.ts +18 -0
- package/PhoneDetectPrompt.js +52 -0
- package/README.md +86 -0
- package/Workspace.d.ts +65 -0
- package/Workspace.js +47 -0
- package/defaultTheme.d.ts +10 -0
- package/defaultTheme.js +26 -0
- package/directionalTransition.d.ts +33 -0
- package/directionalTransition.js +24 -0
- package/dragToClose.d.ts +60 -0
- package/dragToClose.js +151 -0
- package/fakeHost.d.ts +33 -0
- package/fakeHost.js +85 -0
- package/hostApi.d.ts +247 -0
- package/hostApi.js +54 -0
- package/index.d.ts +46 -0
- package/index.js +30 -0
- package/package.json +30 -0
- package/panelCapacity.d.ts +15 -0
- package/panelCapacity.js +19 -0
- package/panelRowEntry.d.ts +37 -0
- package/panelRowEntry.js +11 -0
- package/panelRowLayout.d.ts +57 -0
- package/panelRowLayout.js +52 -0
- package/panelRowOrder.d.ts +46 -0
- package/panelRowOrder.js +105 -0
- package/panelTiers.d.ts +24 -0
- package/panelTiers.js +29 -0
- package/panelWindow.d.ts +170 -0
- package/panelWindow.js +243 -0
- package/panels/usePanelClosing.d.ts +58 -0
- package/panels/usePanelClosing.js +140 -0
- package/panels/usePanelManager.d.ts +74 -0
- package/panels/usePanelManager.js +403 -0
- package/panels/useProvidePanels.d.ts +82 -0
- package/panels/useProvidePanels.js +142 -0
- package/panels/useRowScrollGesture.d.ts +2 -0
- package/panels/useRowScrollGesture.js +70 -0
- package/panels/useWorkspacePersistence.d.ts +14 -0
- package/panels/useWorkspacePersistence.js +74 -0
- package/phoneModels.d.ts +26 -0
- package/phoneModels.js +43 -0
- package/snapshots.d.ts +58 -0
- package/snapshots.js +22 -0
- package/viewRegistry.d.ts +23 -0
- package/viewRegistry.js +30 -0
- package/viewState.d.ts +32 -0
- package/viewState.js +135 -0
- package/windowOverlay.d.ts +87 -0
- package/windowOverlay.js +137 -0
- package/workspaceState.d.ts +63 -0
- package/workspaceState.js +95 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel capacity machinery: how many live collapsible panels the row shows at once (`capacity`),
|
|
3
|
+
* parking/revealing to stay at it, revive-with-demotion (`handleActivatePanel`), and the row gesture
|
|
4
|
+
* (`scrollRow`) that brings a hidden panel back from either side. Every hide is a park (`live: false`,
|
|
5
|
+
* never a removal) pushed onto `collapseOrderRef`, so `revealMostRecentlyCollapsed` keeps working
|
|
6
|
+
* regardless of which path did the parking.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { flushSync } from 'react-dom';
|
|
10
|
+
import { GAP } from '../PanelRowSlot.js';
|
|
11
|
+
import { computeMaxLiveWidth, computeRowLayout } from '../panelRowLayout.js';
|
|
12
|
+
import { panelWidthOf } from '../panelTiers.js';
|
|
13
|
+
import { effectiveMaxPanels, firstParkedAfter, lastParkedBefore, leftmostLiveIndex, farthestLiveCollapsibleId, movePanel, promoteToLive, rightmostLiveIndex, setFormFactor, sideOfLiveRange, } from '../panelWindow.js';
|
|
14
|
+
export function usePanelManager(panels, setPanels, storedMaxPanels, phoneWidth, containerWidth,
|
|
15
|
+
/** How many always-rendered, non-webview columns the row currently has (`count(!collapsible)` over the provider's own list, unless its host overrides it). */
|
|
16
|
+
fixedColumnCount, rootId) {
|
|
17
|
+
/** The live-collapsible ceiling for a row whose live set has these widths (row order). */
|
|
18
|
+
const capacityFor = (liveWidths) => {
|
|
19
|
+
// `containerWidth === 0` means unmeasured (the first commit, and every jsdom test that never calls
|
|
20
|
+
// measure()): null means no cap and no reconcile, never "zero panels fit".
|
|
21
|
+
const fitCount = containerWidth === 0
|
|
22
|
+
? null
|
|
23
|
+
: fixedColumnCount + computeRowLayout({ containerWidth, phoneWidth, gap: GAP, fixedItemCount: fixedColumnCount, liveWidths }).maxLiveThatFit;
|
|
24
|
+
const effectiveMax = effectiveMaxPanels(storedMaxPanels, fitCount, fixedColumnCount);
|
|
25
|
+
// Floor of 1 keeps the panel just opened on screen even at the narrowest legal width (effectiveMaxPanels
|
|
26
|
+
// already floors at fixedColumnCount + 1, so this bites only when the fixed columns alone reach effectiveMax).
|
|
27
|
+
return effectiveMax === Number.POSITIVE_INFINITY ? Number.POSITIVE_INFINITY : Math.max(1, effectiveMax - fixedColumnCount);
|
|
28
|
+
};
|
|
29
|
+
// The same cap every other width reader passes, from the same row inputs.
|
|
30
|
+
const maxLiveWidth = computeMaxLiveWidth({ containerWidth, phoneWidth, gap: GAP, fixedItemCount: fixedColumnCount });
|
|
31
|
+
const widthOf = (p) => panelWidthOf(p, phoneWidth, maxLiveWidth);
|
|
32
|
+
/** Whether `set` (live collapsible panels, row order) can stay live as it is: the one test both reconcile branches settle against. */
|
|
33
|
+
const settles = (set) => set.length <= capacityFor(set.map(widthOf));
|
|
34
|
+
const capacity = capacityFor(panels.filter((p) => p.collapsible && p.live).map(widthOf));
|
|
35
|
+
/**
|
|
36
|
+
* Parked ids in park order, most recent last. A ref, not state: only handlers and the reconcile effect below
|
|
37
|
+
* read it, so a push or pop need not re-render.
|
|
38
|
+
*/
|
|
39
|
+
const collapseOrderRef = useRef([]);
|
|
40
|
+
/**
|
|
41
|
+
* The panels the next capacity-reconcile DROP must keep live, in the order they were acted on: each one just
|
|
42
|
+
* resized, opened or activated, so acting on a panel never parks it, and each of several actions in one
|
|
43
|
+
* update keeps its own. The value is the array the action read: a reconcile still on it ran before the action.
|
|
44
|
+
*/
|
|
45
|
+
const keepLiveRef = useRef(new Map());
|
|
46
|
+
const markKeepLive = (id) => {
|
|
47
|
+
keepLiveRef.current = new Map(keepLiveRef.current).set(id, panels);
|
|
48
|
+
};
|
|
49
|
+
const keptIds = () => new Set(keepLiveRef.current.keys());
|
|
50
|
+
/** Tiers resizePanel queued on `seen` that have not reached `panels` yet, so a later action in the same handler can price them. */
|
|
51
|
+
const pendingTiersRef = useRef({ seen: null, tiers: new Map() });
|
|
52
|
+
/** `row` (read in this render) with those tiers applied. */
|
|
53
|
+
const withPendingTiers = (row) => {
|
|
54
|
+
const { seen, tiers } = pendingTiersRef.current;
|
|
55
|
+
return seen === panels ? [...tiers].reduce((acc, [id, tier]) => setFormFactor(acc, id, tier), row) : row;
|
|
56
|
+
};
|
|
57
|
+
/** `next`, plus in the same update whatever the reconcile's DROP would park, so no committed row overflows. */
|
|
58
|
+
const settledKeeping = (next, keep) => {
|
|
59
|
+
const live = next.filter((p) => p.collapsible && p.live);
|
|
60
|
+
if (settles(live))
|
|
61
|
+
return next;
|
|
62
|
+
const parked = new Set(fewestParksThatSettle(live, widthOf, settles, keep));
|
|
63
|
+
return parked.size === 0 ? next : next.map((p) => (parked.has(p.id) ? { ...p, live: false } : p));
|
|
64
|
+
};
|
|
65
|
+
/** Nonzero inside this hook's own flushSync: a reconcile run there is mid-handler, before later actions land. */
|
|
66
|
+
const flushingRef = useRef(0);
|
|
67
|
+
const flushOwn = (update) => {
|
|
68
|
+
flushingRef.current++;
|
|
69
|
+
try {
|
|
70
|
+
flushSync(update);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
flushingRef.current--;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
const enteringViaAnchorRef = useRef(new Set());
|
|
77
|
+
const markEnteringViaAnchor = (id) => {
|
|
78
|
+
enteringViaAnchorRef.current = new Set(enteringViaAnchorRef.current).add(id);
|
|
79
|
+
};
|
|
80
|
+
const forgetEnteringViaAnchor = (id) => {
|
|
81
|
+
if (!enteringViaAnchorRef.current.has(id))
|
|
82
|
+
return;
|
|
83
|
+
const next = new Set(enteringViaAnchorRef.current);
|
|
84
|
+
next.delete(id);
|
|
85
|
+
enteringViaAnchorRef.current = next;
|
|
86
|
+
};
|
|
87
|
+
const enterSideRef = useRef(new Map());
|
|
88
|
+
const [exitRightIds, setExitRightIds] = useState(new Set());
|
|
89
|
+
/** Every path that makes an id live must forget any stale exit-right mark from a PREVIOUS park of the same id -- ids cycle live/parked repeatedly over a session. Put here, once, so no reveal path can skip it. */
|
|
90
|
+
const purgeExitRight = (id) => {
|
|
91
|
+
setExitRightIds((prev) => {
|
|
92
|
+
if (!prev.has(id))
|
|
93
|
+
return prev;
|
|
94
|
+
const next = new Set(prev);
|
|
95
|
+
next.delete(id);
|
|
96
|
+
return next;
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
/** No flushSync needed: the mark only has to be in the commit that mounts the slot, like enteringViaAnchorRef's. */
|
|
100
|
+
const setEnterSide = (id, side) => {
|
|
101
|
+
enterSideRef.current = new Map(enterSideRef.current).set(id, side);
|
|
102
|
+
purgeExitRight(id);
|
|
103
|
+
};
|
|
104
|
+
/** Forces the NEXT mount of `id` back to the default (anchor formula / right), overriding a stale gesture mark from an earlier reveal of the same id. */
|
|
105
|
+
const clearEnterSide = (id) => {
|
|
106
|
+
if (enterSideRef.current.has(id)) {
|
|
107
|
+
const next = new Map(enterSideRef.current);
|
|
108
|
+
next.delete(id);
|
|
109
|
+
enterSideRef.current = next;
|
|
110
|
+
}
|
|
111
|
+
purgeExitRight(id);
|
|
112
|
+
};
|
|
113
|
+
/** flushSync BEFORE the parking setPanels -- see `UsePanelManagerResult.exitRightIds`'s own doc comment for why. */
|
|
114
|
+
const markExitRight = (id) => {
|
|
115
|
+
flushOwn(() => setExitRightIds((prev) => new Set(prev).add(id)));
|
|
116
|
+
};
|
|
117
|
+
const pushCollapsed = (ids) => {
|
|
118
|
+
const parkedSet = new Set(ids);
|
|
119
|
+
collapseOrderRef.current = [...collapseOrderRef.current.filter((x) => !parkedSet.has(x)), ...ids];
|
|
120
|
+
};
|
|
121
|
+
const forgetCollapsed = (id) => {
|
|
122
|
+
collapseOrderRef.current = collapseOrderRef.current.filter((x) => x !== id);
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* Before a panel is shown -- a new one from PanelsAPI.create(), or usePanelClosing's undo restoring a LIVE
|
|
126
|
+
* one -- parks whatever is necessary to stay at capacity (by count, then by width), in the SAME update that
|
|
127
|
+
* reveals it. `apply` is called once, purely (no side effect), against `panelsNow` to learn the id and array
|
|
128
|
+
* position of whatever it adds/restores; the real commit re-runs it against the LATEST state instead of
|
|
129
|
+
* trusting this snapshot.
|
|
130
|
+
*/
|
|
131
|
+
const revealPanelProactively = (panelsNow, apply) => {
|
|
132
|
+
const afterInsert = apply(panelsNow);
|
|
133
|
+
const targetId = afterInsert.find((p) => !panelsNow.some((q) => q.id === p.id))?.id;
|
|
134
|
+
if (targetId !== undefined)
|
|
135
|
+
markKeepLive(targetId);
|
|
136
|
+
const keep = [...keptIds()];
|
|
137
|
+
const settledApply = (prev) => settledKeeping(apply(prev), keep);
|
|
138
|
+
if (targetId === undefined || capacity === Number.POSITIVE_INFINITY) {
|
|
139
|
+
setPanels(settledApply);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const targetIndex = afterInsert.findIndex((p) => p.id === targetId);
|
|
143
|
+
const liveCollapsibleCountAfter = afterInsert.filter((p) => p.collapsible && p.live).length;
|
|
144
|
+
const overCapacity = Math.max(0, liveCollapsibleCountAfter - capacity);
|
|
145
|
+
if (overCapacity === 0) {
|
|
146
|
+
setPanels(settledApply);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const toPark = [];
|
|
150
|
+
const exempt = keptIds();
|
|
151
|
+
let scratch = afterInsert;
|
|
152
|
+
for (let i = 0; i < overCapacity; i++) {
|
|
153
|
+
const id = farthestLiveCollapsibleId(scratch, targetIndex, exempt);
|
|
154
|
+
if (!id)
|
|
155
|
+
break;
|
|
156
|
+
toPark.push(id);
|
|
157
|
+
scratch = scratch.map((p) => (p.id === id ? { ...p, live: false } : p));
|
|
158
|
+
}
|
|
159
|
+
if (toPark.length === 0) {
|
|
160
|
+
setPanels(settledApply);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const rightParks = toPark.filter((id) => sideOfLiveRange(afterInsert, id) === 'right');
|
|
164
|
+
if (rightParks.length > 0) {
|
|
165
|
+
flushOwn(() => setExitRightIds((prev) => {
|
|
166
|
+
const next = new Set(prev);
|
|
167
|
+
for (const id of rightParks)
|
|
168
|
+
next.add(id);
|
|
169
|
+
return next;
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
pushCollapsed(toPark);
|
|
173
|
+
const parkedSet = new Set(toPark);
|
|
174
|
+
setPanels((current) => settledKeeping(apply(current).map((p) => (parkedSet.has(p.id) ? { ...p, live: false } : p)), keep));
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* Clicking ANY row -- live or parked alike -- activates it (promoteToLive's own doc
|
|
178
|
+
* comment has the full "make/keep it live, preserve array order" spec).
|
|
179
|
+
*/
|
|
180
|
+
const handleActivatePanel = (id) => {
|
|
181
|
+
const target = panels.find((p) => p.id === id);
|
|
182
|
+
if (!target || target.live)
|
|
183
|
+
return;
|
|
184
|
+
markKeepLive(id);
|
|
185
|
+
const keep = [...keptIds()];
|
|
186
|
+
// A manual History-row activation enters from the right (or by its own `anchoredEnter`), so it drops any
|
|
187
|
+
// anchor-entry or gesture-side mark left from an earlier reveal of this id.
|
|
188
|
+
forgetEnteringViaAnchor(id);
|
|
189
|
+
clearEnterSide(id);
|
|
190
|
+
const before = panels;
|
|
191
|
+
const result = promoteToLive(panels, id, capacity, keptIds());
|
|
192
|
+
if (!result)
|
|
193
|
+
return;
|
|
194
|
+
forgetCollapsed(id);
|
|
195
|
+
const demoted = result.find((p) => !p.live && before.find((prev) => prev.id === p.id)?.live);
|
|
196
|
+
if (!demoted) {
|
|
197
|
+
// An updater, not `result`: `result` is built from this render's array and would drop an update queued
|
|
198
|
+
// earlier in the same handler.
|
|
199
|
+
setPanels((prev) => settledKeeping(promoteToLive(prev, id, Number.POSITIVE_INFINITY) ?? prev, keep));
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (sideOfLiveRange(before, demoted.id) === 'right')
|
|
203
|
+
markExitRight(demoted.id);
|
|
204
|
+
pushCollapsed([demoted.id]);
|
|
205
|
+
// Applied in two functional updates, in this order: `id` goes live first (no demotion yet -- capacity
|
|
206
|
+
// Infinity guarantees that for this call only), then the panel it displaces is parked.
|
|
207
|
+
setPanels((prev) => promoteToLive(prev, id, Number.POSITIVE_INFINITY) ?? prev);
|
|
208
|
+
setPanels((current) => settledKeeping(current.map((p) => (p.id === demoted.id ? { ...p, live: false } : p)), keep));
|
|
209
|
+
};
|
|
210
|
+
/** See `UsePanelManagerResult.scrollRow`'s own doc comment. */
|
|
211
|
+
const scrollRow = (direction) => {
|
|
212
|
+
const reveal = direction === 'right' ? firstParkedAfter(panels, rightmostLiveIndex(panels)) : lastParkedBefore(panels, leftmostLiveIndex(panels));
|
|
213
|
+
if (!reveal)
|
|
214
|
+
return;
|
|
215
|
+
const kept = keptIds();
|
|
216
|
+
markKeepLive(reveal.id);
|
|
217
|
+
// A resize earlier in this handler is priced at its new tier, or the row committed below could overflow.
|
|
218
|
+
const sized = withPendingTiers(panels);
|
|
219
|
+
const live = sized.filter((p) => p.collapsible && p.live);
|
|
220
|
+
const farFirst = (direction === 'right' ? live : [...live].reverse()).filter((p) => !kept.has(p.id));
|
|
221
|
+
const toPark = new Set();
|
|
222
|
+
const rowAfter = () => sized.filter((p) => p.collapsible && (p.live || p.id === reveal.id) && !toPark.has(p.id));
|
|
223
|
+
// At least the one panel the gesture swaps out, then more only while the row with `reveal` would not settle.
|
|
224
|
+
for (const p of farFirst) {
|
|
225
|
+
if (toPark.size > 0 && settles(rowAfter()))
|
|
226
|
+
break;
|
|
227
|
+
toPark.add(p.id);
|
|
228
|
+
}
|
|
229
|
+
setEnterSide(reveal.id, direction);
|
|
230
|
+
// A leftward gesture parks off the RIGHT edge: mark those BEFORE the setPanels below, in their own commit.
|
|
231
|
+
if (direction === 'left' && toPark.size > 0)
|
|
232
|
+
flushOwn(() => setExitRightIds((prev) => new Set([...prev, ...toPark])));
|
|
233
|
+
pushCollapsed([...toPark]);
|
|
234
|
+
forgetCollapsed(reveal.id);
|
|
235
|
+
setPanels((prev) => prev.map((p) => (toPark.has(p.id) ? { ...p, live: false } : p.id === reveal.id ? { ...p, live: true } : p)));
|
|
236
|
+
};
|
|
237
|
+
/** See `UsePanelManagerResult.reorderPanel`'s own doc comment. */
|
|
238
|
+
const reorderPanel = (id, direction) => {
|
|
239
|
+
setPanels((prev) => movePanel(prev, id, direction, rootId));
|
|
240
|
+
};
|
|
241
|
+
/** See `UsePanelManagerResult.resizePanel`'s own doc comment. */
|
|
242
|
+
const resizePanel = (id, next) => {
|
|
243
|
+
if (setFormFactor(panels, id, next) === panels)
|
|
244
|
+
return false;
|
|
245
|
+
const wasLive = panels.find((p) => p.id === id)?.live === true;
|
|
246
|
+
markKeepLive(id);
|
|
247
|
+
const keep = [...keptIds()];
|
|
248
|
+
const pending = pendingTiersRef.current.seen === panels ? pendingTiersRef.current.tiers : [];
|
|
249
|
+
pendingTiersRef.current = { seen: panels, tiers: new Map(pending).set(id, next) };
|
|
250
|
+
if (wasLive) {
|
|
251
|
+
purgeExitRight(id);
|
|
252
|
+
forgetCollapsed(id);
|
|
253
|
+
}
|
|
254
|
+
setPanels((prev) => {
|
|
255
|
+
const resized = setFormFactor(prev, id, next);
|
|
256
|
+
// An earlier call in this same handler may have queued a park of it (its exit mark and stack entry are
|
|
257
|
+
// dropped above): a resize never parks its own panel, so that park is cancelled before anything commits.
|
|
258
|
+
const own = wasLive && resized.some((p) => p.id === id && !p.live) ? resized.map((p) => (p.id === id ? { ...p, live: true } : p)) : resized;
|
|
259
|
+
return settledKeeping(own, keep);
|
|
260
|
+
});
|
|
261
|
+
return true;
|
|
262
|
+
};
|
|
263
|
+
/**
|
|
264
|
+
* Reconciles the live set against `capacity` whenever either changes -- an effect, since it must act on a
|
|
265
|
+
* measurement that just changed, not one this render was given. Both branches judge a candidate set by `settles`,
|
|
266
|
+
* never `capacity` as a count: `capacity` prices every not-yet-live slot at `phoneWidth`, so WHICH panels park or
|
|
267
|
+
* return decides whether the rest fits, and a set that does not settle re-triggers a branch next render. A DROP
|
|
268
|
+
* parks `fewestParksThatSettle` and exits 'left' (flushSync cannot mark a right exit first from a passive effect).
|
|
269
|
+
* A RISE reveals from the top of `collapseOrderRef`; one that doesn't settle stays on the stack for an OLDER, narrower one.
|
|
270
|
+
*/
|
|
271
|
+
const committedRef = useRef(panels);
|
|
272
|
+
useEffect(() => {
|
|
273
|
+
// `settledKeeping` parks inside a state update, where no call-time code sees which panels it chose: the
|
|
274
|
+
// stack learns of any park not already on it from the commit itself.
|
|
275
|
+
const before = committedRef.current;
|
|
276
|
+
committedRef.current = panels;
|
|
277
|
+
const unrecorded = panels.filter((p) => p.collapsible && !p.live && !collapseOrderRef.current.includes(p.id) && before.some((q) => q.id === p.id && q.live));
|
|
278
|
+
if (unrecorded.length > 0)
|
|
279
|
+
pushCollapsed(unrecorded.map((p) => p.id));
|
|
280
|
+
const keepIds = [];
|
|
281
|
+
const waiting = new Map();
|
|
282
|
+
// A mark still on the array its action read waits for that action to land. Inside flushOwn every mark
|
|
283
|
+
// outlives this reconcile: the rest of its handler has not landed yet.
|
|
284
|
+
for (const [id, seen] of keepLiveRef.current) {
|
|
285
|
+
if (seen !== panels)
|
|
286
|
+
keepIds.push(id);
|
|
287
|
+
if (seen === panels || flushingRef.current > 0)
|
|
288
|
+
waiting.set(id, seen);
|
|
289
|
+
}
|
|
290
|
+
keepLiveRef.current = waiting;
|
|
291
|
+
if (capacity === Number.POSITIVE_INFINITY)
|
|
292
|
+
return;
|
|
293
|
+
const live = panels.filter((p) => p.collapsible && p.live);
|
|
294
|
+
if (live.length > capacity) {
|
|
295
|
+
const toPark = fewestParksThatSettle(live, widthOf, settles, keepIds);
|
|
296
|
+
if (toPark.length === 0)
|
|
297
|
+
return;
|
|
298
|
+
pushCollapsed(toPark);
|
|
299
|
+
const parkedSet = new Set(toPark);
|
|
300
|
+
setPanels((current) => current.map((p) => (parkedSet.has(p.id) ? { ...p, live: false } : p)));
|
|
301
|
+
}
|
|
302
|
+
else if (live.length < capacity && collapseOrderRef.current.length > 0) {
|
|
303
|
+
const stack = collapseOrderRef.current;
|
|
304
|
+
const revealed = new Set();
|
|
305
|
+
const remaining = [];
|
|
306
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
307
|
+
const id = stack[i];
|
|
308
|
+
const candidate = panels.find((p) => p.id === id);
|
|
309
|
+
if (!candidate || candidate.live)
|
|
310
|
+
continue; // stale entry (closed, or already live some other way) -- drop it
|
|
311
|
+
if (settles(panels.filter((p) => p.collapsible && (p.live || revealed.has(p.id) || p.id === id))))
|
|
312
|
+
revealed.add(id);
|
|
313
|
+
else
|
|
314
|
+
remaining.unshift(id);
|
|
315
|
+
}
|
|
316
|
+
if (revealed.size === 0)
|
|
317
|
+
return;
|
|
318
|
+
collapseOrderRef.current = remaining;
|
|
319
|
+
for (const id of revealed)
|
|
320
|
+
setEnterSide(id, sideOfLiveRange(panels, id));
|
|
321
|
+
setPanels((current) => current.map((p) => (revealed.has(p.id) ? { ...p, live: true } : p)));
|
|
322
|
+
}
|
|
323
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
324
|
+
}, [capacity, panels]);
|
|
325
|
+
/** See `UsePanelManagerResult.revealMostRecentlyCollapsed`'s own doc comment for the full contract. */
|
|
326
|
+
const revealMostRecentlyCollapsed = (panelsAfterClose) => {
|
|
327
|
+
// Priced like scrollRow: a resize earlier in this handler is not in `panelsAfterClose` yet.
|
|
328
|
+
const sized = withPendingTiers(panelsAfterClose);
|
|
329
|
+
const stack = collapseOrderRef.current;
|
|
330
|
+
const remaining = [];
|
|
331
|
+
let candidateId = null;
|
|
332
|
+
for (let i = stack.length - 1; i >= 0; i--) {
|
|
333
|
+
const id = stack[i];
|
|
334
|
+
const candidate = panelsAfterClose.find((p) => p.id === id);
|
|
335
|
+
if (!candidate || candidate.live)
|
|
336
|
+
continue; // stale entry (closed, or already live some other way) -- drop it
|
|
337
|
+
if (candidateId === null && settles(sized.filter((p) => p.collapsible && (p.live || p.id === id))))
|
|
338
|
+
candidateId = id;
|
|
339
|
+
else
|
|
340
|
+
remaining.unshift(id);
|
|
341
|
+
}
|
|
342
|
+
collapseOrderRef.current = remaining;
|
|
343
|
+
if (candidateId === null)
|
|
344
|
+
return;
|
|
345
|
+
const revealId = candidateId;
|
|
346
|
+
// Clear before marking, as `clearEnterSide`'s contract requires: a stale explicit side would win over the anchor.
|
|
347
|
+
clearEnterSide(revealId);
|
|
348
|
+
// One of the two "reappearance" paths that enter from the anchor's side -- mark it BEFORE the setPanels
|
|
349
|
+
// call below; no flushSync, since the mark only has to be in the commit that mounts the slot.
|
|
350
|
+
markEnteringViaAnchor(revealId);
|
|
351
|
+
setPanels((current) => promoteToLive(current, revealId, Number.POSITIVE_INFINITY) ?? current);
|
|
352
|
+
};
|
|
353
|
+
return {
|
|
354
|
+
revealPanelProactively,
|
|
355
|
+
handleActivatePanel,
|
|
356
|
+
forgetCollapsed,
|
|
357
|
+
revealMostRecentlyCollapsed,
|
|
358
|
+
enteringViaAnchorIds: enteringViaAnchorRef.current,
|
|
359
|
+
markEnteringViaAnchor,
|
|
360
|
+
clearEnterSide,
|
|
361
|
+
scrollRow,
|
|
362
|
+
reorderPanel,
|
|
363
|
+
resizePanel,
|
|
364
|
+
enterSideById: enterSideRef.current,
|
|
365
|
+
exitRightIds,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* The ids (row order) to park so the live panels left settle: as FEW as possible, and among equally few the
|
|
370
|
+
* LEFTMOST -- so a row of equal widths parks exactly its leftmost excess, and a wide panel parks alone when
|
|
371
|
+
* that is what leaves the most panels live. Each of `keepIds` (in the order they were acted on) is never
|
|
372
|
+
* parked, whatever that costs the others, unless the ones before it already fill the row.
|
|
373
|
+
*/
|
|
374
|
+
function fewestParksThatSettle(live, widthOf, settles, keepIds = []) {
|
|
375
|
+
const narrowestFirst = (set) => [...set].sort((a, b) => widthOf(a) - widthOf(b));
|
|
376
|
+
const inRowOrder = (set) => live.filter((p) => set.includes(p));
|
|
377
|
+
// The first kept panel alone always settles (capacity is >= 1).
|
|
378
|
+
const mustKeep = [];
|
|
379
|
+
for (const id of keepIds) {
|
|
380
|
+
const p = live.find((q) => q.id === id);
|
|
381
|
+
if (p && !mustKeep.includes(p) && settles(inRowOrder([...mustKeep, p])))
|
|
382
|
+
mustKeep.push(p);
|
|
383
|
+
}
|
|
384
|
+
const candidates = live.filter((p) => !mustKeep.includes(p));
|
|
385
|
+
// Whether a set settles depends only on its size and its width sum, so the k narrowest candidates are the
|
|
386
|
+
// cheapest k to keep: if they do not settle, no k do.
|
|
387
|
+
let keep = candidates.length;
|
|
388
|
+
while (keep > 0 && mustKeep.length + keep > 1 && !settles(inRowOrder([...mustKeep, ...narrowestFirst(candidates).slice(0, keep)])))
|
|
389
|
+
keep--;
|
|
390
|
+
const kept = [...mustKeep];
|
|
391
|
+
const toPark = [];
|
|
392
|
+
candidates.forEach((p, i) => {
|
|
393
|
+
const stillToKeep = keep - (kept.length - mustKeep.length);
|
|
394
|
+
const rest = candidates.slice(i + 1);
|
|
395
|
+
// Park `p` only if what is left to its right can still complete a set that settles.
|
|
396
|
+
const canPark = toPark.length < candidates.length - keep && rest.length >= stillToKeep && settles(inRowOrder([...kept, ...narrowestFirst(rest).slice(0, stillToKeep)]));
|
|
397
|
+
if (canPark)
|
|
398
|
+
toPark.push(p.id);
|
|
399
|
+
else
|
|
400
|
+
kept.push(p);
|
|
401
|
+
});
|
|
402
|
+
return toPark;
|
|
403
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's implementation of PanelsAPI: the panel list, create/activate/close/undo/clear-all. Wraps
|
|
3
|
+
* usePanelManager, composes usePanelClosing and owns the row's scroll gesture. panelList and recentlyClosed hold
|
|
4
|
+
* PanelSummary objects built here, never PanelWindow records (phonux-app's provide-panels test pins their key set); `entries`
|
|
5
|
+
* is the raw list, hostData included, for the app and PanelRow only: an app keeps it out of the bag its views read,
|
|
6
|
+
* and the row's numbers (fixedViewCount, liveWidths, maxLiveWidth) beside it.
|
|
7
|
+
*/
|
|
8
|
+
import { type RefObject } from 'react';
|
|
9
|
+
import type { JsonValue, PanelsAPI } from '../hostApi.js';
|
|
10
|
+
import { type CaptureTrigger } from '../snapshots.js';
|
|
11
|
+
import { type PanelWindow } from '../panelWindow.js';
|
|
12
|
+
import type { WorkspaceStorage } from '../workspaceState.js';
|
|
13
|
+
/**
|
|
14
|
+
* What an app hands `useProvidePanels`: the row's measured inputs, plus the behaviour only the host can decide
|
|
15
|
+
* (where closes are logged, what `hostData` means).
|
|
16
|
+
* @public
|
|
17
|
+
*/
|
|
18
|
+
export interface PanelsInput {
|
|
19
|
+
/** The list the provider starts with, read once on mount (the host seeds its always-shown columns here); the provider owns the list from then on. Default: empty. */
|
|
20
|
+
initialEntries?: readonly PanelWindow[];
|
|
21
|
+
/** The id of the row's root entry, the one every anchor leads to -- threaded through to every panelWindow.ts call that needs it. */
|
|
22
|
+
rootId: string;
|
|
23
|
+
/** The stored "max panels on screen" choice (`null` = follow the fit count) -- SettingsAPI's own value. */
|
|
24
|
+
storedMaxPanels: number | null;
|
|
25
|
+
/** The phone frame's current width: the unit a default-sized panel is priced in. */
|
|
26
|
+
phoneWidth: number;
|
|
27
|
+
/** Overrides `count(!collapsible)` over the provider's own list, which is the default: a host that prices the row from a count it already holds passes it through so usePanelManager always agrees with it. */
|
|
28
|
+
fixedViewCount?: number;
|
|
29
|
+
/** The host's measured row width (0 before the first measurement) -- usePanelManager derives the fit count and capacity from this, the same inputs SettingsAPI.fitCount uses. */
|
|
30
|
+
containerWidth: number;
|
|
31
|
+
/** The row's scroll container: the gesture's wheel listener attaches here. */
|
|
32
|
+
overflowContainerRef: RefObject<HTMLDivElement | null>;
|
|
33
|
+
/** Defaults to phonux's `noopCaptureTrigger`: a host that wants real pictures passes its own. */
|
|
34
|
+
capture?: CaptureTrigger;
|
|
35
|
+
/** Called when setFormFactor really resizes a panel, so the host can glide the row's margin with it. */
|
|
36
|
+
onPanelResize?: () => void;
|
|
37
|
+
/** Called once per close with its closed-pages log entry; the host decides where that log lives. See usePanelClosing.ts. */
|
|
38
|
+
onPanelsClosed?: (entry: {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
url: string;
|
|
42
|
+
closedAt: string;
|
|
43
|
+
}) => void;
|
|
44
|
+
/** Called once per undo batch with the log ids it restores. See usePanelClosing.ts. */
|
|
45
|
+
onPanelsRestored?: (logIds: string[]) => void;
|
|
46
|
+
/** Called once per restored panel; the return value becomes its `hostData` (`undefined` drops the key). Unset keeps what it closed with. phonux never reads inside `hostData`, so what needs resetting after an undo is the host's call. */
|
|
47
|
+
resetHostDataOnRestore?: (hostData: JsonValue | undefined, panel: PanelWindow) => JsonValue | undefined;
|
|
48
|
+
/** Builds the `hostData` of every panel `create()` opens, and of every panel restored from `storage`. Unset: new panels carry none. */
|
|
49
|
+
defaultHostData?: () => JsonValue;
|
|
50
|
+
/** Where the workspace is kept between launches: loaded once at mount, then saved after every change that settles. Unset: nothing is loaded or saved. */
|
|
51
|
+
storage?: WorkspaceStorage;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* PanelsAPI plus the inputs PanelRow and the workspace read: the raw list, the exit/enter animation sets and the
|
|
55
|
+
* row's numbers. They stay OFF PanelsAPI on purpose: that interface is the narrow surface a third-party view is
|
|
56
|
+
* written against (hostApi.tsx's header).
|
|
57
|
+
* @public
|
|
58
|
+
*/
|
|
59
|
+
export interface ProvidedPanels extends PanelsAPI {
|
|
60
|
+
/** The provider's one panel list, raw (not summarised): the row's input, never part of PanelsAPI. */
|
|
61
|
+
readonly entries: readonly PanelWindow[];
|
|
62
|
+
/** How many always-rendered columns the row has: `PanelsInput.fixedViewCount` when passed, else `count(!collapsible)` over `entries`. Capacity and `SettingsAPI.fitCount` are priced from it. */
|
|
63
|
+
readonly fixedViewCount: number;
|
|
64
|
+
/** Each live collapsible panel's own width in row order, capped at `maxLiveWidth`: the row's centring margin and `SettingsAPI.fitCount` read the same array capacity is priced from. */
|
|
65
|
+
readonly liveWidths: readonly number[];
|
|
66
|
+
/** The widest one live panel can be in this row; `Infinity` until the row is measured (`containerWidth` 0). */
|
|
67
|
+
readonly maxLiveWidth: number;
|
|
68
|
+
/** Ids being closed: each exits DOWN before it leaves `entries`. */
|
|
69
|
+
readonly closingIds: ReadonlySet<string>;
|
|
70
|
+
/** Ids (re)joining `live` by a reveal or an undo: each enters from its anchor's side. */
|
|
71
|
+
readonly enteringViaAnchorIds: ReadonlySet<string>;
|
|
72
|
+
/** See usePanelManager.ts's `UsePanelManagerResult.exitRightIds` for the full contract. */
|
|
73
|
+
readonly exitRightIds: ReadonlySet<string>;
|
|
74
|
+
/** See usePanelManager.ts's `UsePanelManagerResult.enterSideById` for the full contract. */
|
|
75
|
+
readonly enterSideById: ReadonlyMap<string, 'left' | 'right'>;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Builds the `PanelsAPI` an app hands its views. The provider owns the panel list, seeded once from `initialEntries`.
|
|
79
|
+
* Call it with this render's inputs and never memoise the result: its handlers close over this render's list.
|
|
80
|
+
* @public
|
|
81
|
+
*/
|
|
82
|
+
export declare function useProvidePanels(i: PanelsInput): ProvidedPanels;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app's implementation of PanelsAPI: the panel list, create/activate/close/undo/clear-all. Wraps
|
|
3
|
+
* usePanelManager, composes usePanelClosing and owns the row's scroll gesture. panelList and recentlyClosed hold
|
|
4
|
+
* PanelSummary objects built here, never PanelWindow records (phonux-app's provide-panels test pins their key set); `entries`
|
|
5
|
+
* is the raw list, hostData included, for the app and PanelRow only: an app keeps it out of the bag its views read,
|
|
6
|
+
* and the row's numbers (fixedViewCount, liveWidths, maxLiveWidth) beside it.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { GAP } from '../PanelRowSlot.js';
|
|
10
|
+
import { computeMaxLiveWidth } from '../panelRowLayout.js';
|
|
11
|
+
import { summarisePanel } from '../panelRowEntry.js';
|
|
12
|
+
import { panelWidthOf } from '../panelTiers.js';
|
|
13
|
+
import { noopCaptureTrigger } from '../snapshots.js';
|
|
14
|
+
import { appendPanel, reanchorPanel } from '../panelWindow.js';
|
|
15
|
+
import { usePanelClosing } from './usePanelClosing.js';
|
|
16
|
+
import { usePanelManager } from './usePanelManager.js';
|
|
17
|
+
import { useRowScrollGesture } from './useRowScrollGesture.js';
|
|
18
|
+
import { useWorkspacePersistence } from './useWorkspacePersistence.js';
|
|
19
|
+
/** Ids for panels this app creates itself. A module counter, bumped only from event handlers and the restore callback (never from a state initialiser or updater), so StrictMode's double invocation cannot skip one. */
|
|
20
|
+
let nextWindowId = 1;
|
|
21
|
+
const freshId = () => String(nextWindowId++);
|
|
22
|
+
/**
|
|
23
|
+
* Duck-typed (a `preventDefault` function): a bare `onClick={panels.create}` reference passes the click
|
|
24
|
+
* event positionally, so this discards it rather than spreading `view` into the new panel (see DESIGN.md).
|
|
25
|
+
*/
|
|
26
|
+
function isEventLike(value) {
|
|
27
|
+
return typeof value === 'object' && value !== null && typeof value.preventDefault === 'function';
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Builds the `PanelsAPI` an app hands its views. The provider owns the panel list, seeded once from `initialEntries`.
|
|
31
|
+
* Call it with this render's inputs and never memoise the result: its handlers close over this render's list.
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
34
|
+
export function useProvidePanels(i) {
|
|
35
|
+
const capture = i.capture ?? noopCaptureTrigger;
|
|
36
|
+
const [panels, setPanels] = useState(() => [...(i.initialEntries ?? [])]);
|
|
37
|
+
const fixedViewCount = i.fixedViewCount ?? panels.filter((p) => !p.collapsible).length;
|
|
38
|
+
// Filters over the whole list, never an array position: a panel anchored left of History changes none of these.
|
|
39
|
+
const maxLiveWidth = computeMaxLiveWidth({ containerWidth: i.containerWidth, phoneWidth: i.phoneWidth, gap: GAP, fixedItemCount: fixedViewCount });
|
|
40
|
+
const liveWidths = panels.filter((p) => p.collapsible && p.live).map((p) => panelWidthOf(p, i.phoneWidth, maxLiveWidth));
|
|
41
|
+
const { revealPanelProactively, handleActivatePanel, forgetCollapsed, revealMostRecentlyCollapsed, enteringViaAnchorIds, markEnteringViaAnchor, clearEnterSide, scrollRow, reorderPanel, resizePanel, enterSideById, exitRightIds, } = usePanelManager(panels, setPanels, i.storedMaxPanels, i.phoneWidth, i.containerWidth, fixedViewCount, i.rootId);
|
|
42
|
+
// The row's wheel/arrow-key gesture is wired here, not in the host, so a host stays free of effects and refs.
|
|
43
|
+
useRowScrollGesture(i.overflowContainerRef, scrollRow);
|
|
44
|
+
useWorkspacePersistence({ storage: i.storage, panels, setPanels, rootId: i.rootId, freshId, defaultHostData: i.defaultHostData });
|
|
45
|
+
// Diffs the one list instead of capturing per call site: usePanelManager parks from several sites with no single
|
|
46
|
+
// "on park" hook, and a per-site call would silently miss any site not touched.
|
|
47
|
+
const prevLiveRef = useRef(new Map());
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const prev = prevLiveRef.current;
|
|
50
|
+
for (const p of panels) {
|
|
51
|
+
if (prev.get(p.id) === true && !p.live)
|
|
52
|
+
capture.capture(p.id);
|
|
53
|
+
}
|
|
54
|
+
prevLiveRef.current = new Map(panels.map((p) => [p.id, p.live]));
|
|
55
|
+
}, [panels, capture]);
|
|
56
|
+
// clearAll only records ids here: React may run its updater during render, where evict would notify other components.
|
|
57
|
+
const clearedIds = useRef(new Set());
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
for (const id of clearedIds.current)
|
|
60
|
+
capture.evict(id);
|
|
61
|
+
clearedIds.current.clear();
|
|
62
|
+
}, [panels, capture]);
|
|
63
|
+
// After usePanelManager: undo makes room for a live view through its revealPanelProactively. `onAfterClose`
|
|
64
|
+
// forgets the closed id (it may have been parked), then offers the freed room to the most recently parked panel.
|
|
65
|
+
const closing = usePanelClosing(panels, setPanels, revealPanelProactively, i.rootId, (id, remaining) => {
|
|
66
|
+
forgetCollapsed(id);
|
|
67
|
+
revealMostRecentlyCollapsed(remaining);
|
|
68
|
+
},
|
|
69
|
+
// A restored LIVE panel can carry a stale gesture-enter-side/exit-right mark from an earlier park of
|
|
70
|
+
// the same id; clear it before marking entering-via-anchor (usePanelManager's own clear-then-mark order).
|
|
71
|
+
(id) => {
|
|
72
|
+
clearEnterSide(id);
|
|
73
|
+
markEnteringViaAnchor(id);
|
|
74
|
+
},
|
|
75
|
+
// A closed-and-not-restored panel's picture must not outlive its undo window; usePanelClosing.ts alone
|
|
76
|
+
// knows which ids the window's NATURAL elapse (never Undo) just finalized.
|
|
77
|
+
(ids) => {
|
|
78
|
+
for (const id of ids)
|
|
79
|
+
capture.evict(id);
|
|
80
|
+
}, i.onPanelsClosed, i.onPanelsRestored, i.resetHostDataOnRestore);
|
|
81
|
+
const api = {
|
|
82
|
+
// `listed` excludes History: it must not list itself (a host's history column renders every entry here as a
|
|
83
|
+
// clickable row).
|
|
84
|
+
panelList: panels.filter((p) => p.listed).map(summarisePanel),
|
|
85
|
+
create: (options) => {
|
|
86
|
+
// `options` is explicitly optional here (not left to infer from `create`'s own overloaded interface
|
|
87
|
+
// member) -- an implementation inferred as requiring it would no longer satisfy the zero-arg overload.
|
|
88
|
+
const safeOptions = isEventLike(options) ? undefined : options;
|
|
89
|
+
const id = freshId();
|
|
90
|
+
// Built here, outside the updater below: React may run an updater twice, and the host's builder is not required to be pure.
|
|
91
|
+
const hostData = i.defaultHostData?.();
|
|
92
|
+
// "Panel N", not "about:blank": every panel needs a distinguishable title, for testing. options?.title
|
|
93
|
+
// overrides it; the explicit `title:` below (after the spread) is what makes that override win either way.
|
|
94
|
+
revealPanelProactively(panels, (prev) => appendPanel(prev, { url: 'about:blank', hostData, ...safeOptions, title: safeOptions?.title ?? `Panel ${id}` }, id, i.rootId));
|
|
95
|
+
},
|
|
96
|
+
activate: handleActivatePanel,
|
|
97
|
+
move: reorderPanel,
|
|
98
|
+
setFormFactor: (id, next) => {
|
|
99
|
+
if (resizePanel(id, next))
|
|
100
|
+
i.onPanelResize?.();
|
|
101
|
+
},
|
|
102
|
+
// No keep-live mark and no settle step: settles() reads only the live set's widths and count, never its
|
|
103
|
+
// order, so a reorder cannot unsettle a row, and a DROP later in the handler may park this panel as any other.
|
|
104
|
+
reanchor: (id, anchor) => setPanels((prev) => reanchorPanel(prev, id, anchor, i.rootId)),
|
|
105
|
+
close: (id) => {
|
|
106
|
+
// Fires on every close() call, even an unknown or locked id's no-op below: a harmless extra attempt,
|
|
107
|
+
// and gating it exactly would need a second callback into usePanelClosing.ts.
|
|
108
|
+
capture.capture(id);
|
|
109
|
+
closing.close(id);
|
|
110
|
+
},
|
|
111
|
+
recentlyClosed: closing.recentlyClosed?.map(summarisePanel) ?? null,
|
|
112
|
+
undoClose: closing.undoClose,
|
|
113
|
+
// Drops every collapsible panel, never History (the always-shown column). Its picture goes now, not at an undo
|
|
114
|
+
// window: a dropped panel can never be shown again. The ids come from the updater's own list, because this
|
|
115
|
+
// render's `panels` misses a panel created earlier in the same batch.
|
|
116
|
+
clearAll: () => setPanels((prev) => {
|
|
117
|
+
for (const p of prev)
|
|
118
|
+
if (p.collapsible)
|
|
119
|
+
clearedIds.current.add(p.id);
|
|
120
|
+
return prev.filter((p) => !p.collapsible);
|
|
121
|
+
}),
|
|
122
|
+
entries: panels,
|
|
123
|
+
fixedViewCount,
|
|
124
|
+
liveWidths,
|
|
125
|
+
maxLiveWidth,
|
|
126
|
+
closingIds: closing.closingIds,
|
|
127
|
+
enteringViaAnchorIds,
|
|
128
|
+
exitRightIds,
|
|
129
|
+
enterSideById,
|
|
130
|
+
};
|
|
131
|
+
// Test-only hook: a host's end-to-end tests reach the API here, since no production UI calls create({ permanent })
|
|
132
|
+
// or reanchor(). Gated on MODE 'test', which only a host's test build sets. An effect with no deps array, not a
|
|
133
|
+
// render-body assignment: StrictMode double-invokes render bodies, and a test must always reach the LATEST closure.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
// Structural cast: the package builds without Vite's ambient types, so `import.meta.env` is untyped here.
|
|
136
|
+
const mode = import.meta.env?.MODE;
|
|
137
|
+
if (typeof window === 'undefined' || mode !== 'test')
|
|
138
|
+
return;
|
|
139
|
+
window.__panelsApiForTests = api;
|
|
140
|
+
});
|
|
141
|
+
return api;
|
|
142
|
+
}
|