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
package/panelWindow.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { computeRowOrder, deriveAnchorsFromOrder } from './panelRowOrder.js';
|
|
2
|
+
import { DEFAULT_PANEL_SIZE, TIER_SIZES } from './panelTiers.js';
|
|
3
|
+
export { effectiveMaxPanels } from './panelCapacity.js';
|
|
4
|
+
/** History's own seeded entry: never collapsible (an always-shown column), never listed (it must not list itself), always live, locked by default (the host's close path honours `locked`); raised, so a left-exiting park slides under it; anchoredEnter, which against itself resolves 'right'; no anchor: it is the root every anchor leads to. Same default tier/size as every other entry: a permanent column carries a tier like any other. @internal */
|
|
5
|
+
export function createHistoryPanel(rootId) {
|
|
6
|
+
return {
|
|
7
|
+
id: rootId, url: '', title: 'History', live: true, locked: true,
|
|
8
|
+
view: 'history', collapsible: false, listed: false, layer: 'raised', anchoredEnter: true,
|
|
9
|
+
formFactor: 'phone', size: DEFAULT_PANEL_SIZE,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/** `request` with its defaults filled against `panels`: History, and one past the highest priority already on that side (`self` excluded). */
|
|
13
|
+
function fillAnchor(panels, rootId, request, self) {
|
|
14
|
+
const panel = request.panel ?? rootId;
|
|
15
|
+
if (request.priority !== undefined)
|
|
16
|
+
return { panel, position: request.position, priority: request.priority };
|
|
17
|
+
const positional = new Map(deriveAnchorsFromOrder(panels, rootId).map((p) => [p.id, p.anchor]));
|
|
18
|
+
let max = -1;
|
|
19
|
+
for (const p of panels) {
|
|
20
|
+
const a = p.anchor ?? positional.get(p.id);
|
|
21
|
+
if (p.id !== self && p.id !== rootId && a && a.panel === panel && a.position === request.position)
|
|
22
|
+
max = Math.max(max, a.priority);
|
|
23
|
+
}
|
|
24
|
+
return { panel, position: request.position, priority: max + 1 };
|
|
25
|
+
}
|
|
26
|
+
/** `id` re-anchored per `request` (defaults as for appendPanel), the row re-ordered to match. The SAME array for an unknown id or a `collapsible: false` entry. @internal */
|
|
27
|
+
export function reanchorPanel(panels, id, request, rootId) {
|
|
28
|
+
const target = panels.find((p) => p.id === id);
|
|
29
|
+
if (!target || !target.collapsible)
|
|
30
|
+
return panels;
|
|
31
|
+
const anchor = fillAnchor(panels, rootId, request, id);
|
|
32
|
+
return computeRowOrder(panels.map((p) => (p.id === id ? { ...p, anchor } : p)), rootId);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Builds a brand-new, live 'extra' panel immutably and places it by `info.anchor` (default: right of
|
|
36
|
+
* History, past every panel already there -- the row's far right end). History is seeded directly, never
|
|
37
|
+
* through this function. `permanent` forces `collapsible:false` and `listed:false` (overriding any `info.listed`
|
|
38
|
+
* passed) since such a column must supply its own close control, never the row's trash; `locked`
|
|
39
|
+
* stays false regardless so that control keeps working with no extra plumbing (the host's close path gates
|
|
40
|
+
* only on `locked`).
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export function appendPanel(panels, info, id, rootId) {
|
|
44
|
+
const formFactor = info.permanent ? 'phone' : (info.formFactor ?? 'phone');
|
|
45
|
+
const entry = {
|
|
46
|
+
id,
|
|
47
|
+
url: info.url,
|
|
48
|
+
...(info.title !== undefined ? { title: info.title } : {}),
|
|
49
|
+
...(info.hostData !== undefined ? { hostData: info.hostData } : {}),
|
|
50
|
+
live: true,
|
|
51
|
+
locked: false,
|
|
52
|
+
view: info.view ?? 'web',
|
|
53
|
+
collapsible: !info.permanent,
|
|
54
|
+
listed: info.permanent ? false : (info.listed ?? true),
|
|
55
|
+
...(info.params !== undefined ? { params: info.params } : {}),
|
|
56
|
+
anchor: fillAnchor(panels, rootId, info.anchor ?? { position: 'right' }),
|
|
57
|
+
layer: info.layer ?? 'base',
|
|
58
|
+
anchoredEnter: info.anchoredEnter ?? false,
|
|
59
|
+
// Every entry carries a tier; a permanent column's is always 'phone'.
|
|
60
|
+
formFactor,
|
|
61
|
+
size: formFactor === 'phone' ? DEFAULT_PANEL_SIZE : { ...TIER_SIZES[formFactor] },
|
|
62
|
+
};
|
|
63
|
+
return computeRowOrder([...panels, entry], rootId);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The id of the live collapsible panel farthest (by array index) from `fromIndex`, or null with none live.
|
|
67
|
+
* Ties go to the lower index (the strict `>` keeps the first). From an append (`fromIndex` = the new length)
|
|
68
|
+
* that is the LEFT end of the live range; from an open nearest History or a revive left of the range, the
|
|
69
|
+
* RIGHT end. promoteToLive and the host's open/restore park both pick through this. `exclude`
|
|
70
|
+
* exempts the panel being opened or restored, which is already live in the array the caller passes, and any
|
|
71
|
+
* panel another action in the same update must keep live.
|
|
72
|
+
* @internal
|
|
73
|
+
*/
|
|
74
|
+
export function farthestLiveCollapsibleId(panels, fromIndex, exclude = new Set()) {
|
|
75
|
+
let bestId = null;
|
|
76
|
+
let bestDist = -1;
|
|
77
|
+
panels.forEach((p, i) => {
|
|
78
|
+
if (!p.collapsible || !p.live || exclude.has(p.id))
|
|
79
|
+
return;
|
|
80
|
+
const dist = Math.abs(i - fromIndex);
|
|
81
|
+
if (dist > bestDist) {
|
|
82
|
+
bestDist = dist;
|
|
83
|
+
bestId = p.id;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
return bestId;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Which side of the row a park/reveal at `id` reads as, for the exit/enter animation: 'left' when `id`'s
|
|
90
|
+
* own array index sits below every OTHER live collapsible panel's index, 'right' otherwise (a parked panel
|
|
91
|
+
* between two live ones included). Computed fresh from array position every time, never a persisted flag,
|
|
92
|
+
* so an append's park always reads 'left' and a park for an open anchored nearest History reads 'right'.
|
|
93
|
+
* @internal
|
|
94
|
+
*/
|
|
95
|
+
export function sideOfLiveRange(panels, id) {
|
|
96
|
+
const ownIndex = panels.findIndex((p) => p.id === id);
|
|
97
|
+
let firstOtherLiveIndex = Number.POSITIVE_INFINITY;
|
|
98
|
+
panels.forEach((p, i) => {
|
|
99
|
+
if (p.collapsible && p.live && p.id !== id)
|
|
100
|
+
firstOtherLiveIndex = Math.min(firstOtherLiveIndex, i);
|
|
101
|
+
});
|
|
102
|
+
return ownIndex < firstOtherLiveIndex ? 'left' : 'right';
|
|
103
|
+
}
|
|
104
|
+
/** The row-slot index of the LEFTMOST (lowest-index) live collapsible panel, or -1 if none are live. @internal */
|
|
105
|
+
export function leftmostLiveIndex(panels) {
|
|
106
|
+
for (let i = 0; i < panels.length; i++)
|
|
107
|
+
if (panels[i].collapsible && panels[i].live)
|
|
108
|
+
return i;
|
|
109
|
+
return -1;
|
|
110
|
+
}
|
|
111
|
+
/** The row-slot index of the RIGHTMOST (highest-index) live collapsible panel, or -1 if none are live. @internal */
|
|
112
|
+
export function rightmostLiveIndex(panels) {
|
|
113
|
+
let found = -1;
|
|
114
|
+
panels.forEach((p, i) => {
|
|
115
|
+
if (p.collapsible && p.live)
|
|
116
|
+
found = i;
|
|
117
|
+
});
|
|
118
|
+
return found;
|
|
119
|
+
}
|
|
120
|
+
/** The first PARKED collapsible panel strictly after `index` -- a rightward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
|
|
121
|
+
export function firstParkedAfter(panels, index) {
|
|
122
|
+
for (let i = index + 1; i < panels.length; i++)
|
|
123
|
+
if (panels[i].collapsible && !panels[i].live)
|
|
124
|
+
return panels[i];
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
/** The last PARKED collapsible panel strictly before `index` -- a leftward gesture's own "next one to bring in", or null with nothing parked on that side. @internal */
|
|
128
|
+
export function lastParkedBefore(panels, index) {
|
|
129
|
+
for (let i = index - 1; i >= 0; i--)
|
|
130
|
+
if (panels[i].collapsible && !panels[i].live)
|
|
131
|
+
return panels[i];
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Activating ANY panel (live or parked) flips `live` in place, never moving the entry's array position.
|
|
136
|
+
* Returns `null` for an unknown id or one already live (a legitimate no-op, not an error). When promoting
|
|
137
|
+
* `id` would exceed `capacity`, demotes exactly one OTHER live panel: whichever `farthestLiveCollapsibleId`
|
|
138
|
+
* picks from `id`'s own index, the same far rule an open/restore parks by. Never demotes a
|
|
139
|
+
* `collapsible: false` entry or any id in `keep`.
|
|
140
|
+
* @internal
|
|
141
|
+
*/
|
|
142
|
+
export function promoteToLive(panels, id, capacity, keep = new Set()) {
|
|
143
|
+
const target = panels.find((p) => p.id === id);
|
|
144
|
+
if (!target || target.live)
|
|
145
|
+
return null;
|
|
146
|
+
const ownIndex = panels.findIndex((p) => p.id === id);
|
|
147
|
+
const liveCountAfter = panels.filter((p) => p.collapsible && p.live).length + 1;
|
|
148
|
+
let next = panels.map((p) => (p.id === id ? { ...p, live: true } : p));
|
|
149
|
+
if (liveCountAfter > capacity) {
|
|
150
|
+
// `panels` (pre-promotion): `id` itself still reads live:false there, so it is naturally excluded
|
|
151
|
+
// from the search without needing `keep`.
|
|
152
|
+
const demote = farthestLiveCollapsibleId(panels, ownIndex, keep);
|
|
153
|
+
if (demote) {
|
|
154
|
+
next = next.map((p) => (p.id === demote ? { ...p, live: false } : p));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return next;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Closes one panel COMPLETELY: it leaves the array, live or parked, so it leaves the row and History
|
|
161
|
+
* together. Returns what a caller needs to log it and to undo it -- the array without it, the panel itself
|
|
162
|
+
* and the index it had -- or `null` for an unknown `id` (already closed, cleared): nothing to do, not an
|
|
163
|
+
* error. Every panel anchored to it takes over its own anchor, so they close up in the place it held.
|
|
164
|
+
* @internal
|
|
165
|
+
*/
|
|
166
|
+
export function closePanel(panels, id, rootId) {
|
|
167
|
+
const index = panels.findIndex((p) => p.id === id);
|
|
168
|
+
if (index < 0)
|
|
169
|
+
return null;
|
|
170
|
+
const closed = panels[index];
|
|
171
|
+
const inherited = closed.anchor ?? deriveAnchorsFromOrder(panels, rootId)[index].anchor;
|
|
172
|
+
const rest = panels.filter((p) => p.id !== id).map((p) => (inherited && p.anchor?.panel === id ? { ...p, anchor: { ...inherited } } : p));
|
|
173
|
+
return { remaining: computeRowOrder(rest, rootId), closed, index };
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Moves `id` one PRESENT slot (`!collapsible || live`, PanelRow's filter) toward `direction`, past
|
|
177
|
+
* whichever present panel sits there, History included; a parked panel between the two shifts one array slot,
|
|
178
|
+
* keeping firstParkedAfter/lastParkedBefore/sideOfLiveRange meaningful. Every anchor is then re-read from the
|
|
179
|
+
* new order. The SAME array for every no-op: unknown id, a `collapsible: false` source (an always-shown column
|
|
180
|
+
* is never dragged), a parked panel, or one already at that edge.
|
|
181
|
+
* @internal
|
|
182
|
+
*/
|
|
183
|
+
export function movePanel(panels, id, direction, rootId) {
|
|
184
|
+
const present = panels.filter((p) => !p.collapsible || p.live);
|
|
185
|
+
const presentIndex = present.findIndex((p) => p.id === id);
|
|
186
|
+
if (presentIndex < 0 || !present[presentIndex].collapsible)
|
|
187
|
+
return panels;
|
|
188
|
+
const neighbor = present[direction === 'left' ? presentIndex - 1 : presentIndex + 1];
|
|
189
|
+
if (!neighbor)
|
|
190
|
+
return panels;
|
|
191
|
+
const from = panels.findIndex((p) => p.id === id);
|
|
192
|
+
const to = panels.findIndex((p) => p.id === neighbor.id);
|
|
193
|
+
const next = panels.slice();
|
|
194
|
+
const [entry] = next.splice(from, 1);
|
|
195
|
+
next.splice(to, 0, entry);
|
|
196
|
+
return deriveAnchorsFromOrder(next, rootId);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* How many movePanel hops a horizontal drag of `offsetX` px asks for (+right/-left). A hop past a neighbour
|
|
200
|
+
* moves the dragged panel by that NEIGHBOUR's span (its width plus `gap`), so each hop lands half-way across
|
|
201
|
+
* the next neighbour's span, counted from where the previous hop ended; the dragged panel's own width never
|
|
202
|
+
* enters it. Exactly half a span is a hop either way, and this never returns -0.
|
|
203
|
+
* @public
|
|
204
|
+
*/
|
|
205
|
+
export function reorderStepsForOffset(offsetX, neighbors, gap) {
|
|
206
|
+
const distance = Math.abs(offsetX);
|
|
207
|
+
let travelled = 0;
|
|
208
|
+
let steps = 0;
|
|
209
|
+
for (const width of offsetX > 0 ? neighbors.right : neighbors.left) {
|
|
210
|
+
const span = width + gap;
|
|
211
|
+
if (distance < travelled + span / 2)
|
|
212
|
+
break;
|
|
213
|
+
travelled += span;
|
|
214
|
+
steps++;
|
|
215
|
+
}
|
|
216
|
+
return steps === 0 ? 0 : Math.sign(offsetX) * steps;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* `id` at `next`'s tier, its `size` REPLACED by a fresh copy of that tier's TIER_SIZES entry. Returns the SAME
|
|
220
|
+
* array reference for every no-op: an unknown id, a `collapsible: false` entry (History or a permanent column,
|
|
221
|
+
* movePanel's own source guard), or a panel already at `next`. A parked panel may change tier and stays parked.
|
|
222
|
+
* @internal
|
|
223
|
+
*/
|
|
224
|
+
export function setFormFactor(panels, id, next) {
|
|
225
|
+
const target = panels.find((p) => p.id === id);
|
|
226
|
+
if (!target || !target.collapsible || (target.formFactor ?? 'phone') === next)
|
|
227
|
+
return panels;
|
|
228
|
+
return panels.map((p) => (p.id === id ? { ...p, formFactor: next, size: { ...TIER_SIZES[next] } } : p));
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Reverses closePanel for one panel: puts `panel` back at `index` (clamped to the current length, so a shorter
|
|
232
|
+
* array still takes it), unchanged -- a pure spread, no `hostData` reset (the host's undo does that itself: only
|
|
233
|
+
* it knows what "unmounted when it closed" means for the opaque blob). An id already present leaves `panels`
|
|
234
|
+
* unchanged (a double undo never duplicates). Live or parked is whatever `panel.live` says. Anchors are
|
|
235
|
+
* re-read from the result: its old anchor may no longer describe that index.
|
|
236
|
+
* @internal
|
|
237
|
+
*/
|
|
238
|
+
export function restorePanel(panels, panel, index, rootId) {
|
|
239
|
+
if (panels.some((p) => p.id === panel.id))
|
|
240
|
+
return panels;
|
|
241
|
+
const at = Math.max(0, Math.min(index, panels.length));
|
|
242
|
+
return deriveAnchorsFromOrder([...panels.slice(0, at), { ...panel }, ...panels.slice(at)], rootId);
|
|
243
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type Dispatch, type SetStateAction } from 'react';
|
|
2
|
+
import type { JsonValue } from '../hostApi.js';
|
|
3
|
+
import { type PanelWindow } from '../panelWindow.js';
|
|
4
|
+
import type { UsePanelManagerResult } from './usePanelManager.js';
|
|
5
|
+
/** The undo toast stays up for 10 seconds, one window, restarted by every close. */
|
|
6
|
+
export declare const UNDO_WINDOW_MS = 10000;
|
|
7
|
+
export interface UsePanelClosingResult {
|
|
8
|
+
/** Closes a view completely and opens (or restarts) the 10 s undo window. Unknown or already-closed id: does nothing. */
|
|
9
|
+
close: (id: string) => void;
|
|
10
|
+
/** Restores every view closed inside the window, last closed first, each at its old index; a live one comes back live. */
|
|
11
|
+
undoClose: () => void;
|
|
12
|
+
/** The views closed inside the window, oldest first; non-null exactly while the window is open. */
|
|
13
|
+
recentlyClosed: PanelWindow[] | null;
|
|
14
|
+
/**
|
|
15
|
+
* Ids leaving through THIS hook's `close()`, never usePanelManager's auto-collapse (which only flips `live`):
|
|
16
|
+
* PanelRow reads it for each exiting PanelRowSlot's `exitDirection` (down for a close, left for a park).
|
|
17
|
+
* See `close()` for why an id must land here BEFORE the panel leaves `panels`.
|
|
18
|
+
*/
|
|
19
|
+
closingIds: ReadonlySet<string>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A close (drag down, or History's trash) removes the view COMPLETELY, reports it through `onPanelsClosed` and offers
|
|
23
|
+
* Undo for 10 s. Takes the CURRENT render's values, so nothing here may be memoised, and `close` reads the index from
|
|
24
|
+
* that render: two closes before one re-render would undo in the wrong order (discrete gestures re-render between
|
|
25
|
+
* events), so a caller that batches closes needs one setPanels for the lot. Log ids are `<closedAt>#<panel id>`: panel
|
|
26
|
+
* ids restart each launch, and a host undoing by id would otherwise also delete an older launch's entry.
|
|
27
|
+
*/
|
|
28
|
+
export declare function usePanelClosing(panels: PanelWindow[], setPanels: Dispatch<SetStateAction<PanelWindow[]>>, revealPanelProactively: UsePanelManagerResult['revealPanelProactively'], rootId: string,
|
|
29
|
+
/**
|
|
30
|
+
* Called once per successful close with the id that just left and `closePanel`'s `remaining`, the array right
|
|
31
|
+
* after it, computed from THIS render's `panels` like everything else in `close()`.
|
|
32
|
+
*/
|
|
33
|
+
onAfterClose?: (id: string, remaining: PanelWindow[]) => void,
|
|
34
|
+
/**
|
|
35
|
+
* Called per panel `undoClose` restores LIVE (the branch that calls `revealPanelProactively`): with
|
|
36
|
+
* usePanelManager.ts's `revealMostRecentlyCollapsed`, one of the two paths that enter from the anchor's side.
|
|
37
|
+
*/
|
|
38
|
+
onRestoreLive?: (id: string) => void,
|
|
39
|
+
/**
|
|
40
|
+
* Called with the ids the undo window's NATURAL elapse just finalized (Undo calls stopWindow() instead), so a
|
|
41
|
+
* caller can tell a restored close from one nothing will ever undo.
|
|
42
|
+
*/
|
|
43
|
+
onWindowElapsed?: (ids: string[]) => void,
|
|
44
|
+
/** Called once per successful close with the log entry: `id` is the log id, `title` already falls back to the url, then 'Untitled panel'. */
|
|
45
|
+
onPanelsClosed?: (entry: {
|
|
46
|
+
id: string;
|
|
47
|
+
title: string;
|
|
48
|
+
url: string;
|
|
49
|
+
closedAt: string;
|
|
50
|
+
}) => void,
|
|
51
|
+
/** Called once per `undoClose` batch with the log ids of every close the batch restores, in close order. */
|
|
52
|
+
onPanelsRestored?: (logIds: string[]) => void,
|
|
53
|
+
/**
|
|
54
|
+
* Called once per restored panel with the `hostData` it closed carrying; its return value is what the panel comes
|
|
55
|
+
* back with (`undefined` drops the key). Unset leaves `hostData` exactly as it closed: only the host knows what
|
|
56
|
+
* the opaque blob pointed at, and whether that outlived the close.
|
|
57
|
+
*/
|
|
58
|
+
resetHostDataOnRestore?: (hostData: JsonValue | undefined, panel: PanelWindow) => JsonValue | undefined): UsePanelClosingResult;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { flushSync } from 'react-dom';
|
|
3
|
+
import { closePanel, restorePanel } from '../panelWindow.js';
|
|
4
|
+
/** The undo toast stays up for 10 seconds, one window, restarted by every close. */
|
|
5
|
+
export const UNDO_WINDOW_MS = 10_000;
|
|
6
|
+
/** `panel` carrying `hostData`; `undefined` leaves no `hostData` key at all, the same shape appendPanel produces. */
|
|
7
|
+
function withHostData(panel, hostData) {
|
|
8
|
+
const next = { ...panel };
|
|
9
|
+
if (hostData === undefined)
|
|
10
|
+
delete next.hostData;
|
|
11
|
+
else
|
|
12
|
+
next.hostData = hostData;
|
|
13
|
+
return next;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A close (drag down, or History's trash) removes the view COMPLETELY, reports it through `onPanelsClosed` and offers
|
|
17
|
+
* Undo for 10 s. Takes the CURRENT render's values, so nothing here may be memoised, and `close` reads the index from
|
|
18
|
+
* that render: two closes before one re-render would undo in the wrong order (discrete gestures re-render between
|
|
19
|
+
* events), so a caller that batches closes needs one setPanels for the lot. Log ids are `<closedAt>#<panel id>`: panel
|
|
20
|
+
* ids restart each launch, and a host undoing by id would otherwise also delete an older launch's entry.
|
|
21
|
+
*/
|
|
22
|
+
export function usePanelClosing(panels, setPanels, revealPanelProactively, rootId,
|
|
23
|
+
/**
|
|
24
|
+
* Called once per successful close with the id that just left and `closePanel`'s `remaining`, the array right
|
|
25
|
+
* after it, computed from THIS render's `panels` like everything else in `close()`.
|
|
26
|
+
*/
|
|
27
|
+
onAfterClose,
|
|
28
|
+
/**
|
|
29
|
+
* Called per panel `undoClose` restores LIVE (the branch that calls `revealPanelProactively`): with
|
|
30
|
+
* usePanelManager.ts's `revealMostRecentlyCollapsed`, one of the two paths that enter from the anchor's side.
|
|
31
|
+
*/
|
|
32
|
+
onRestoreLive,
|
|
33
|
+
/**
|
|
34
|
+
* Called with the ids the undo window's NATURAL elapse just finalized (Undo calls stopWindow() instead), so a
|
|
35
|
+
* caller can tell a restored close from one nothing will ever undo.
|
|
36
|
+
*/
|
|
37
|
+
onWindowElapsed,
|
|
38
|
+
/** Called once per successful close with the log entry: `id` is the log id, `title` already falls back to the url, then 'Untitled panel'. */
|
|
39
|
+
onPanelsClosed,
|
|
40
|
+
/** Called once per `undoClose` batch with the log ids of every close the batch restores, in close order. */
|
|
41
|
+
onPanelsRestored,
|
|
42
|
+
/**
|
|
43
|
+
* Called once per restored panel with the `hostData` it closed carrying; its return value is what the panel comes
|
|
44
|
+
* back with (`undefined` drops the key). Unset leaves `hostData` exactly as it closed: only the host knows what
|
|
45
|
+
* the opaque blob pointed at, and whether that outlived the close.
|
|
46
|
+
*/
|
|
47
|
+
resetHostDataOnRestore) {
|
|
48
|
+
const [closed, setClosed] = useState([]);
|
|
49
|
+
// Mirrors `closed` synchronously (never via an effect): the timer callback below fires from a closure
|
|
50
|
+
// created at the LAST close() call, whose own render still saw `closed` BEFORE that call's own setClosed
|
|
51
|
+
// took effect -- reading this ref instead always sees every entry the window is actually holding when it fires.
|
|
52
|
+
const closedRef = useRef([]);
|
|
53
|
+
const setClosedBoth = (next) => {
|
|
54
|
+
closedRef.current = next;
|
|
55
|
+
setClosed(next);
|
|
56
|
+
};
|
|
57
|
+
const [closingIds, setClosingIds] = useState(new Set());
|
|
58
|
+
const timer = useRef(null);
|
|
59
|
+
// Ids closed in the current window: a second close of the same id (a gesture callback that outlived a trash click) is ignored.
|
|
60
|
+
const claimed = useRef(new Set());
|
|
61
|
+
useEffect(() => () => {
|
|
62
|
+
if (timer.current)
|
|
63
|
+
clearTimeout(timer.current);
|
|
64
|
+
}, []);
|
|
65
|
+
const stopWindow = () => {
|
|
66
|
+
if (timer.current)
|
|
67
|
+
clearTimeout(timer.current);
|
|
68
|
+
timer.current = null;
|
|
69
|
+
claimed.current.clear();
|
|
70
|
+
};
|
|
71
|
+
const close = (id) => {
|
|
72
|
+
const found = closePanel(panels, id, rootId);
|
|
73
|
+
// History defaults locked (no unlock control exists yet): a locked entry's close is a no-op, the one
|
|
74
|
+
// list's own lock gate -- every entry (History included) goes through this same close().
|
|
75
|
+
if (!found || claimed.current.has(id) || found.closed.locked)
|
|
76
|
+
return;
|
|
77
|
+
claimed.current.add(id);
|
|
78
|
+
// Commits ALONE, before the removal: AnimatePresence takes an exiting child's `exit` from its LAST props
|
|
79
|
+
// before it left, and a plain setState would batch with the setPanels below into one commit that never
|
|
80
|
+
// has the panel both present AND marked.
|
|
81
|
+
flushSync(() => setClosingIds((prev) => new Set(prev).add(id)));
|
|
82
|
+
const closedAt = new Date().toISOString();
|
|
83
|
+
const logId = `${closedAt}#${id}`;
|
|
84
|
+
setPanels((prev) => closePanel(prev, id, rootId)?.remaining ?? prev);
|
|
85
|
+
onAfterClose?.(id, found.remaining);
|
|
86
|
+
// A url-less custom panel has nothing sensible to fall back to: 'Untitled panel', never an empty string.
|
|
87
|
+
onPanelsClosed?.({ id: logId, title: found.closed.title || found.closed.url || 'Untitled panel', url: found.closed.url, closedAt });
|
|
88
|
+
setClosedBoth([...closedRef.current, { panel: found.closed, index: found.index, logId }]);
|
|
89
|
+
if (timer.current)
|
|
90
|
+
clearTimeout(timer.current);
|
|
91
|
+
timer.current = setTimeout(() => {
|
|
92
|
+
timer.current = null;
|
|
93
|
+
claimed.current.clear();
|
|
94
|
+
const elapsedIds = closedRef.current.map((e) => e.panel.id);
|
|
95
|
+
setClosedBoth([]);
|
|
96
|
+
onWindowElapsed?.(elapsedIds);
|
|
97
|
+
}, UNDO_WINDOW_MS);
|
|
98
|
+
};
|
|
99
|
+
const undoClose = () => {
|
|
100
|
+
if (closed.length === 0)
|
|
101
|
+
return;
|
|
102
|
+
const batch = closed;
|
|
103
|
+
stopWindow();
|
|
104
|
+
setClosedBoth([]);
|
|
105
|
+
// Last closed first: each index was taken from the array as it was when THAT view closed, so undoing in reverse re-creates it.
|
|
106
|
+
let working = panels;
|
|
107
|
+
for (const { panel, index } of [...batch].reverse()) {
|
|
108
|
+
if (working.some((p) => p.id === panel.id))
|
|
109
|
+
continue;
|
|
110
|
+
// restorePanel is a pure spread and phonux never reads inside hostData, so the host's own reset runs
|
|
111
|
+
// here, once per panel, and its result is what every call below restores.
|
|
112
|
+
const restoringPanel = resetHostDataOnRestore ? withHostData(panel, resetHostDataOnRestore(panel.hostData, panel)) : panel;
|
|
113
|
+
if (panel.live) {
|
|
114
|
+
// This restore is one of the two "reappearance" paths that enters from the anchor's side -- mark it
|
|
115
|
+
// BEFORE revealPanelProactively's own setPanels call, same ordering reasoning as usePanelManager.ts's
|
|
116
|
+
// own markEnteringViaAnchor call site.
|
|
117
|
+
onRestoreLive?.(panel.id);
|
|
118
|
+
// Through the same room-making rule as opening a view: the live view farthest from it is parked if the row is full.
|
|
119
|
+
revealPanelProactively(working, (prev) => restorePanel(prev, restoringPanel, index, rootId));
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
setPanels((prev) => restorePanel(prev, restoringPanel, index, rootId));
|
|
123
|
+
}
|
|
124
|
+
working = restorePanel(working, restoringPanel, index, rootId);
|
|
125
|
+
}
|
|
126
|
+
// A restored id is no longer closing: a stale mark would slide a LATER park of it down instead of left. An
|
|
127
|
+
// id never restored keeps its mark, harmlessly: ids never repeat in a session (useProvidePanels.ts's nextWindowId).
|
|
128
|
+
setClosingIds((prev) => {
|
|
129
|
+
const ids = batch.map((e) => e.panel.id).filter((restoredId) => prev.has(restoredId));
|
|
130
|
+
if (ids.length === 0)
|
|
131
|
+
return prev;
|
|
132
|
+
const next = new Set(prev);
|
|
133
|
+
for (const restoredId of ids)
|
|
134
|
+
next.delete(restoredId);
|
|
135
|
+
return next;
|
|
136
|
+
});
|
|
137
|
+
onPanelsRestored?.(batch.map((e) => e.logId));
|
|
138
|
+
};
|
|
139
|
+
return { close, undoClose, recentlyClosed: closed.length > 0 ? closed.map((e) => e.panel) : null, closingIds };
|
|
140
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
2
|
+
import type { FormFactor } from '../hostApi.js';
|
|
3
|
+
import { type PanelWindow } from '../panelWindow.js';
|
|
4
|
+
export interface UsePanelManagerResult {
|
|
5
|
+
revealPanelProactively: (panelsNow: PanelWindow[], apply: (prev: PanelWindow[]) => PanelWindow[]) => void;
|
|
6
|
+
handleActivatePanel: (id: string) => void;
|
|
7
|
+
/**
|
|
8
|
+
* Removes `id` from the collapse-order stack if it is there. useProvidePanels.ts's `onAfterClose` calls it
|
|
9
|
+
* for whatever id usePanelClosing just closed, live or parked, so a closed panel -- or one later restored
|
|
10
|
+
* by `undoClose` -- never leaves a stale entry a later reveal could resurrect. A no-op for an id not on the
|
|
11
|
+
* stack.
|
|
12
|
+
*/
|
|
13
|
+
forgetCollapsed: (id: string) => void;
|
|
14
|
+
/**
|
|
15
|
+
* Given the panels array immediately AFTER a close, reveals the most recently parked panel that fits
|
|
16
|
+
* beside the live ones (the capacity RISE's own settle rule), or none; one that doesn't fit stays on the
|
|
17
|
+
* stack for a later close. With equal widths the most recent one fits exactly when any does. Also drops
|
|
18
|
+
* any stack entry that no longer points at a parked panel -- the backstop for `clearAll()`, which wipes
|
|
19
|
+
* panels without going through `forgetCollapsed`.
|
|
20
|
+
*/
|
|
21
|
+
revealMostRecentlyCollapsed: (panelsAfterClose: PanelWindow[]) => void;
|
|
22
|
+
/**
|
|
23
|
+
* Ids (re)entering `live` via `revealMostRecentlyCollapsed` or usePanelClosing.ts's `undoClose` of a LIVE
|
|
24
|
+
* panel (its `onRestoreLive`). PanelRow consults it only when `id` has no `enterSideById` entry: an
|
|
25
|
+
* explicit gesture/capacity-rise side always wins.
|
|
26
|
+
*/
|
|
27
|
+
enteringViaAnchorIds: ReadonlySet<string>;
|
|
28
|
+
/** Marks `id` as entering via the anchor formula -- see `enteringViaAnchorIds`'s own doc comment for who calls this and why. */
|
|
29
|
+
markEnteringViaAnchor: (id: string) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Drops any stale `enterSideById`/`exitRightIds` entry `id` picked up from an EARLIER park (ids cycle
|
|
32
|
+
* live/parked repeatedly over a session) -- every path that revives a panel via the anchor formula must
|
|
33
|
+
* call this FIRST, or a stale explicit side would win over the anchor formula (PanelRow's own
|
|
34
|
+
* precedence) and the revived panel would slide in from the wrong side.
|
|
35
|
+
*/
|
|
36
|
+
clearEnterSide: (id: string) => void;
|
|
37
|
+
/**
|
|
38
|
+
* The row gesture (wheel or arrow key, wired in `useRowScrollGesture.ts`): 'right' promotes the first parked
|
|
39
|
+
* panel after the current rightmost live panel and parks the LEFTMOST live one; 'left' is the mirror. One
|
|
40
|
+
* reveal per call, in one commit: when the revealed panel is wider than the one parked, the next live
|
|
41
|
+
* panels on the same side park too until the row settles, never a panel another action in the same update
|
|
42
|
+
* keeps live. With nothing parked on that side, does nothing (no `setPanels` at all).
|
|
43
|
+
*/
|
|
44
|
+
scrollRow: (direction: 'left' | 'right') => void;
|
|
45
|
+
/**
|
|
46
|
+
* Moves `id` one row-slot toward `direction`: move-by-one, not move-to-index, so no raw array position crosses
|
|
47
|
+
* the PanelsAPI boundary. See `movePanel` (panelWindow.ts) for the no-op contract.
|
|
48
|
+
*/
|
|
49
|
+
reorderPanel: (id: string, direction: 'left' | 'right') => void;
|
|
50
|
+
/**
|
|
51
|
+
* PanelsAPI.setFormFactor: panelWindow.ts's `setFormFactor` through setPanels. If the row no longer fits,
|
|
52
|
+
* the same update parks what the capacity reconcile's DROP would (`fewestParksThatSettle`), keeping `id` and
|
|
53
|
+
* every other panel acted on in this handler live, so no committed row overflows. False for a no-op (see
|
|
54
|
+
* `setFormFactor`), which changes nothing.
|
|
55
|
+
*/
|
|
56
|
+
resizePanel: (id: string, next: FormFactor) => boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Explicit enter sides set by `scrollRow` and the capacity-rise reconcile -- PanelRow's own
|
|
59
|
+
* `enterSideById` prop. Wins over `enteringViaAnchorIds`/`anchoredEnter`: those two describe "enters via
|
|
60
|
+
* the anchor formula" as a boolean, which cannot express "enters from the LEFT because a rightward
|
|
61
|
+
* gesture revealed it", the case this exists for.
|
|
62
|
+
*/
|
|
63
|
+
enterSideById: ReadonlyMap<string, 'left' | 'right'>;
|
|
64
|
+
/**
|
|
65
|
+
* Ids currently parking with an exit to the RIGHT; every other park defaults to 'left', so only this
|
|
66
|
+
* minority needs a mark. Set via `flushSync` BEFORE the parking `setPanels` call: AnimatePresence reads
|
|
67
|
+
* an exiting child's `exit` prop from its LAST render before it left `present`, and a plain `setState`
|
|
68
|
+
* here would batch with that commit instead of rendering "still present, but marked" first.
|
|
69
|
+
*/
|
|
70
|
+
exitRightIds: ReadonlySet<string>;
|
|
71
|
+
}
|
|
72
|
+
export declare function usePanelManager(panels: PanelWindow[], setPanels: Dispatch<SetStateAction<PanelWindow[]>>, storedMaxPanels: number | null, phoneWidth: number, containerWidth: number,
|
|
73
|
+
/** How many always-rendered, non-webview columns the row currently has (`count(!collapsible)` over the provider's own list, unless its host overrides it). */
|
|
74
|
+
fixedColumnCount: number, rootId: string): UsePanelManagerResult;
|