clay-server 5.0.0-beta.2 → 5.0.0-beta.3
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/lib/daemon.js +9 -0
- package/lib/new-session-default.js +45 -0
- package/lib/project-connection.js +4 -2
- package/lib/project-session-creation.js +107 -0
- package/lib/project-session-delete.js +21 -3
- package/lib/project-session-folder-delete.js +169 -0
- package/lib/project-session-folders.js +272 -0
- package/lib/project-sessions.js +46 -26
- package/lib/project.js +2 -0
- package/lib/public/app.js +10 -0
- package/lib/public/css/filebrowser.css +3 -3
- package/lib/public/css/mates.css +1 -1
- package/lib/public/css/mobile-nav.css +0 -128
- package/lib/public/css/session-folders.css +383 -0
- package/lib/public/css/sidebar.css +28 -545
- package/lib/public/index.html +3 -21
- package/lib/public/modules/app-favicon.js +5 -4
- package/lib/public/modules/app-messages.js +13 -0
- package/lib/public/modules/input.js +3 -0
- package/lib/public/modules/project-switcher.js +4 -4
- package/lib/public/modules/session-create-form.js +428 -0
- package/lib/public/modules/session-create.js +338 -0
- package/lib/public/modules/session-folder-collapse.js +192 -0
- package/lib/public/modules/session-folder-context.js +215 -0
- package/lib/public/modules/session-folder-delete.js +313 -0
- package/lib/public/modules/session-folder-dialogs.js +173 -0
- package/lib/public/modules/session-folder-dnd.js +214 -0
- package/lib/public/modules/session-folder-draft.js +184 -0
- package/lib/public/modules/session-folder-layout.js +133 -0
- package/lib/public/modules/session-folder-toolbar.js +185 -0
- package/lib/public/modules/session-folder-view-menu.js +187 -0
- package/lib/public/modules/session-folders.js +95 -0
- package/lib/public/modules/session-list-search.js +108 -0
- package/lib/public/modules/session-presence-ui.js +44 -0
- package/lib/public/modules/sidebar-mobile.js +38 -182
- package/lib/public/modules/sidebar-session-folders-ui.js +382 -0
- package/lib/public/modules/sidebar-sessions.js +141 -718
- package/lib/public/modules/sidebar.js +10 -2
- package/lib/public/modules/terminal.js +2 -2
- package/lib/public/modules/tool-palette.js +37 -219
- package/lib/public/style.css +1 -0
- package/lib/server.js +4 -0
- package/lib/session-folders.js +414 -0
- package/lib/sessions.js +7 -5
- package/lib/users-session-folder-preferences.js +69 -0
- package/lib/users.js +6 -0
- package/lib/ws-schema.js +11 -0
- package/package.json +1 -1
- package/lib/public/modules/session-creation-cta.js +0 -46
- package/lib/public/modules/tool-palette-overlays.js +0 -159
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Inline session creation for the project sidebar.
|
|
2
|
+
//
|
|
3
|
+
// Every real folder header (custom folders and Unfiled, not the Favorites tag) has a quiet
|
|
4
|
+
// "+ New session" button that opens one draft row directly under that header:
|
|
5
|
+
// provider icon, a compact provider picker and icon Cancel; the same header
|
|
6
|
+
// action that opened the draft becomes Create. Only the
|
|
7
|
+
// provider is chosen; the model and effort stay whatever that provider's
|
|
8
|
+
// existing defaults produce. The installed providers and the project default
|
|
9
|
+
// come from the server; the saved project default is preselected, and a
|
|
10
|
+
// default changes only through the explicit action beside an available
|
|
11
|
+
// provider. All mutable state lives in the store (sessionCreate,
|
|
12
|
+
// sessionCreateOptions, sessionCreateLocks, sessionCreateReveal); the DOM is
|
|
13
|
+
// built in session-create-form.js.
|
|
14
|
+
|
|
15
|
+
import { store } from './store.js';
|
|
16
|
+
import { getWs } from './ws-ref.js';
|
|
17
|
+
import { showToast } from './utils.js';
|
|
18
|
+
import { resolvePreferredVendor } from './default-vendor.js';
|
|
19
|
+
import { sendFolderOp, currentFolderState } from './session-folders.js';
|
|
20
|
+
|
|
21
|
+
// Ids are unique without any module-level counter.
|
|
22
|
+
function nextId(prefix) {
|
|
23
|
+
return prefix + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 10);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
var PENDING_TIMEOUT_MS = 30000;
|
|
27
|
+
|
|
28
|
+
function send(message) {
|
|
29
|
+
var ws = getWs();
|
|
30
|
+
if (!ws || !store.get('connected')) return false;
|
|
31
|
+
try {
|
|
32
|
+
ws.send(JSON.stringify(Object.assign({ slug: store.get('currentSlug') }, message)));
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createState() {
|
|
40
|
+
return store.get('sessionCreate') || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function patch(change) {
|
|
44
|
+
var current = createState();
|
|
45
|
+
if (current) store.set({ sessionCreate: Object.assign({}, current, change) });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function createOptions() {
|
|
49
|
+
return store.get('sessionCreateOptions') || { loaded: false, vendors: [], projectDefault: null, canSetProjectDefault: false, error: "", requestId: null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function installedVendorIds() {
|
|
53
|
+
return createOptions().vendors.filter(function (v) { return v.installed; }).map(function (v) { return v.id; });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Opening and closing ---
|
|
57
|
+
|
|
58
|
+
export function openSessionCreate(folderId) {
|
|
59
|
+
var current = createState();
|
|
60
|
+
// A creation in flight keeps its row: its outcome must always find it.
|
|
61
|
+
if (current && current.phase === "pending") return;
|
|
62
|
+
if (current && current.folderId === folderId) {
|
|
63
|
+
patch({ wantFocus: true, focusKey: "vendor", scroll: true });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
var options = createOptions();
|
|
67
|
+
store.set({ sessionCreate: {
|
|
68
|
+
folderId: folderId, vendor: options.loaded ? preferredVendor(options) : "", phase: "ready", requestId: null, timer: null, error: "", notice: "",
|
|
69
|
+
defaultRequestId: null, defaultSaving: false, defaultVendor: null, focusKey: "vendor", wantFocus: false, scroll: true, restoreFocusKey: null,
|
|
70
|
+
openedAt: Date.now(),
|
|
71
|
+
} });
|
|
72
|
+
requestOptions();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function closeSessionCreate() {
|
|
76
|
+
var current = createState();
|
|
77
|
+
if (!current || current.phase === "pending") return;
|
|
78
|
+
store.set({ sessionCreate: null });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Providers ---
|
|
82
|
+
|
|
83
|
+
function requestOptions() {
|
|
84
|
+
var requestId = nextId("sco-");
|
|
85
|
+
store.set({ sessionCreateOptions: Object.assign({}, createOptions(), { requestId: requestId, error: "" }) });
|
|
86
|
+
if (!send({ type: "new_session_options_get", requestId: requestId })) {
|
|
87
|
+
store.set({ sessionCreateOptions: Object.assign({}, createOptions(), { requestId: null, error: "Not connected. Reconnect to choose a provider." }) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function retryOptions() {
|
|
92
|
+
requestOptions();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function preferredVendor(options) {
|
|
96
|
+
var installed = options.vendors.filter(function (v) { return v.installed; }).map(function (v) { return v.id; });
|
|
97
|
+
if (options.projectDefault && installed.indexOf(options.projectDefault) !== -1) return options.projectDefault;
|
|
98
|
+
return resolvePreferredVendor(installed) || "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function handleOptions(msg) {
|
|
102
|
+
var current = createOptions();
|
|
103
|
+
// Only the newest outstanding request is accepted; anything older or
|
|
104
|
+
// unsolicited is ignored.
|
|
105
|
+
if (!msg.requestId || msg.requestId !== current.requestId) return;
|
|
106
|
+
if (msg.slug && msg.slug !== store.get('currentSlug')) return;
|
|
107
|
+
var next = Object.assign({}, current, {
|
|
108
|
+
loaded: true, requestId: null, error: msg.error || "",
|
|
109
|
+
vendors: Array.isArray(msg.vendors) ? msg.vendors : current.vendors,
|
|
110
|
+
projectDefault: msg.projectDefault || null,
|
|
111
|
+
canSetProjectDefault: msg.canSetProjectDefault === true,
|
|
112
|
+
});
|
|
113
|
+
store.set({ sessionCreateOptions: next });
|
|
114
|
+
var form = createState();
|
|
115
|
+
if (form && (!form.vendor || installedVendorIds().indexOf(form.vendor) === -1)) patch({ vendor: preferredVendor(next) });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function chooseVendor(vendor) {
|
|
119
|
+
var form = createState();
|
|
120
|
+
if (!form || form.phase === "pending") return;
|
|
121
|
+
if (installedVendorIds().indexOf(vendor) === -1) return;
|
|
122
|
+
patch({ vendor: vendor, notice: "", error: "", menuOpen: false });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The provider dropdown's open state and highlighted item live in the store.
|
|
126
|
+
export function setCreateMenu(open, index) {
|
|
127
|
+
var form = createState();
|
|
128
|
+
if (!form || form.phase === "pending") return;
|
|
129
|
+
patch({ menuOpen: open === true, menuIndex: typeof index === "number" ? index : (form.menuIndex || 0), menuFocus: open === true });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- Create ---
|
|
133
|
+
|
|
134
|
+
var LOCKED_KEYS = ["lastVendor", "currentVendor", "currentModel", "currentModels", "vendorSelectionLocked", "sessionVendorBound"];
|
|
135
|
+
var MAX_LOCKS = 4;
|
|
136
|
+
|
|
137
|
+
// The composer state a creation overwrote optimistically, kept in the store so
|
|
138
|
+
// a failed creation can put it back. Bounded, and dropped on disconnect, close
|
|
139
|
+
// and project or DM switches.
|
|
140
|
+
function rememberLock(requestId, optimisticVendor) {
|
|
141
|
+
var saved = { activeSessionId: store.get('activeSessionId'), optimisticVendor: optimisticVendor, values: {} };
|
|
142
|
+
for (var i = 0; i < LOCKED_KEYS.length; i++) saved.values[LOCKED_KEYS[i]] = store.get(LOCKED_KEYS[i]);
|
|
143
|
+
var locks = Object.assign({}, store.get('sessionCreateLocks') || {});
|
|
144
|
+
var ids = Object.keys(locks);
|
|
145
|
+
while (ids.length >= MAX_LOCKS) delete locks[ids.shift()];
|
|
146
|
+
locks[requestId] = saved;
|
|
147
|
+
store.set({ sessionCreateLocks: locks });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function forgetLock(requestId) {
|
|
151
|
+
var locks = Object.assign({}, store.get('sessionCreateLocks') || {});
|
|
152
|
+
if (!locks[requestId]) return null;
|
|
153
|
+
var saved = locks[requestId];
|
|
154
|
+
delete locks[requestId];
|
|
155
|
+
store.set({ sessionCreateLocks: locks });
|
|
156
|
+
return saved;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function submitSessionCreate(startNewSession) {
|
|
160
|
+
var form = createState();
|
|
161
|
+
if (!form || form.phase === "pending") return false;
|
|
162
|
+
if (!form.vendor || installedVendorIds().indexOf(form.vendor) === -1) {
|
|
163
|
+
patch({ error: "Choose an installed provider first.", focusKey: "vendor", wantFocus: true });
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
var requestId = nextId("scn-");
|
|
167
|
+
var extra = { forceNew: true, requestId: requestId, folderSlug: store.get('currentSlug'), folderId: form.folderId === "unfiled" ? null : form.folderId };
|
|
168
|
+
rememberLock(requestId, form.vendor);
|
|
169
|
+
var timer = setTimeout(function () { onPendingTimeout(requestId); }, PENDING_TIMEOUT_MS);
|
|
170
|
+
patch({ phase: "pending", requestId: requestId, error: "", notice: "", timer: timer });
|
|
171
|
+
if (!startNewSession(form.vendor, extra)) {
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
forgetLock(requestId);
|
|
174
|
+
patch({ phase: "ready", requestId: null, timer: null, error: "Not connected. Reconnect and try again." });
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function onPendingTimeout(requestId) {
|
|
181
|
+
var form = createState();
|
|
182
|
+
if (!form || form.requestId !== requestId || form.phase !== "pending") return;
|
|
183
|
+
forgetLock(requestId);
|
|
184
|
+
patch({ phase: "ready", requestId: null, timer: null, error: "No response yet. The session may already have been created; check the list first." });
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Puts the composer back only while it still reflects this creation; if the
|
|
188
|
+
// user has since moved to another session, that session's state wins.
|
|
189
|
+
function restoreLock(requestId) {
|
|
190
|
+
var saved = forgetLock(requestId);
|
|
191
|
+
if (!saved) return;
|
|
192
|
+
if (store.get('activeSessionId') !== saved.activeSessionId || store.get('currentVendor') !== saved.optimisticVendor) return;
|
|
193
|
+
store.set(saved.values);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function handleNewSessionResult(msg) {
|
|
197
|
+
var form = createState();
|
|
198
|
+
if (!form || !msg.requestId || msg.requestId !== form.requestId) return false;
|
|
199
|
+
clearTimeout(form.timer);
|
|
200
|
+
if (!msg.ok) {
|
|
201
|
+
restoreLock(msg.requestId);
|
|
202
|
+
patch({ phase: "ready", requestId: null, timer: null, error: msg.error || "The session could not be created.", focusKey: "create", wantFocus: true });
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
forgetLock(msg.requestId);
|
|
206
|
+
// When filing failed the session really is in Unfiled, so that is what to reveal.
|
|
207
|
+
var destination = msg.folderPlaced === false ? "unfiled" : (msg.folderId || form.folderId || "unfiled");
|
|
208
|
+
if (msg.folderPlaced === false) showToast("The session was created but could not be filed in that folder. It is in Unfiled.", "warn");
|
|
209
|
+
store.set({ sessionCreate: null });
|
|
210
|
+
revealCreatedSession(destination, msg.sessionId);
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// --- Revealing the new session ---
|
|
215
|
+
|
|
216
|
+
// Called for every project-sidebar creation (including ones without a folder,
|
|
217
|
+
// such as the /clear command): the destination is expanded and the new session
|
|
218
|
+
// scrolled into view once the server acknowledges it or it becomes active.
|
|
219
|
+
export function expectCreatedSession(folderId) {
|
|
220
|
+
store.set({ sessionCreateReveal: { folderId: folderId || "unfiled", sessionId: null, until: Date.now() + 15000, attempts: 0 } });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function revealCreatedSession(folderId, sessionId) {
|
|
224
|
+
var reveal = store.get('sessionCreateReveal') || {};
|
|
225
|
+
store.set({ sessionCreateReveal: { folderId: folderId || reveal.folderId || "unfiled", sessionId: sessionId, until: Date.now() + 15000, attempts: 0 } });
|
|
226
|
+
expandDestination(folderId || reveal.folderId || "unfiled");
|
|
227
|
+
scheduleReveal();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function expandDestination(folderId) {
|
|
231
|
+
var state = currentFolderState();
|
|
232
|
+
if (state.collapsed && state.collapsed[folderId] === true) sendFolderOp({ op: "set_collapsed", containerKey: folderId, collapsed: false });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function visibleRow(sessionId) {
|
|
236
|
+
var rows = document.querySelectorAll('.session-item[data-session-id="' + sessionId + '"]');
|
|
237
|
+
for (var i = 0; i < rows.length; i++) if (rows[i].getClientRects().length) return rows[i];
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function scheduleReveal() {
|
|
242
|
+
setTimeout(tryReveal, 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function tryReveal() {
|
|
246
|
+
var reveal = store.get('sessionCreateReveal');
|
|
247
|
+
if (!reveal || !reveal.sessionId) return;
|
|
248
|
+
if (Date.now() > reveal.until || reveal.attempts > 40) { store.set({ sessionCreateReveal: null }); return; }
|
|
249
|
+
var row = visibleRow(reveal.sessionId);
|
|
250
|
+
if (row) {
|
|
251
|
+
if (row.scrollIntoView) row.scrollIntoView({ block: "nearest" });
|
|
252
|
+
store.set({ sessionCreateReveal: null });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
store.set({ sessionCreateReveal: Object.assign({}, reveal, { attempts: reveal.attempts + 1 }) });
|
|
256
|
+
setTimeout(tryReveal, 60);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// --- Project default (provider only) ---
|
|
260
|
+
|
|
261
|
+
export function saveProjectDefault(vendor) {
|
|
262
|
+
var form = createState();
|
|
263
|
+
if (!form || form.phase === "pending" || form.defaultSaving || installedVendorIds().indexOf(vendor) === -1) return false;
|
|
264
|
+
var requestId = nextId("scd-");
|
|
265
|
+
patch({ defaultSaving: true, defaultRequestId: requestId, defaultVendor: vendor, error: "", notice: "" });
|
|
266
|
+
if (!send({ type: "new_session_default_set", requestId: requestId, vendor: vendor })) {
|
|
267
|
+
patch({ defaultSaving: false, defaultRequestId: null, defaultVendor: null, error: "Not connected. Reconnect and try again." });
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function handleDefaultResult(msg) {
|
|
274
|
+
var form = createState();
|
|
275
|
+
if (!form || !msg.requestId || msg.requestId !== form.defaultRequestId) return;
|
|
276
|
+
if (!msg.ok) { patch({ defaultSaving: false, defaultRequestId: null, defaultVendor: null, error: msg.error || "The project default could not be saved." }); return; }
|
|
277
|
+
store.set({ sessionCreateOptions: Object.assign({}, createOptions(), { projectDefault: msg.projectDefault || null }) });
|
|
278
|
+
patch({ defaultSaving: false, defaultRequestId: null, defaultVendor: null, error: "", notice: "Saved as this project's default." });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// --- Messages and lifecycle ---
|
|
282
|
+
|
|
283
|
+
export function handleSessionCreateMessage(msg) {
|
|
284
|
+
if (msg.type === "new_session_options") { handleOptions(msg); return true; }
|
|
285
|
+
if (msg.type === "new_session_default_result") { handleDefaultResult(msg); return true; }
|
|
286
|
+
if (msg.type === "new_session_project_default") {
|
|
287
|
+
if (!msg.slug || msg.slug === store.get('currentSlug')) store.set({ sessionCreateOptions: Object.assign({}, createOptions(), { projectDefault: msg.projectDefault || null }) });
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
return false;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Requests that were in flight are gone; nothing may stay waiting forever.
|
|
294
|
+
function onDisconnect(form) {
|
|
295
|
+
store.set({ sessionCreateLocks: {} });
|
|
296
|
+
var options = createOptions();
|
|
297
|
+
if (options.requestId) store.set({ sessionCreateOptions: Object.assign({}, options, { requestId: null, error: "Connection lost. Reconnect to load providers." }) });
|
|
298
|
+
if (!form) return;
|
|
299
|
+
if (form.timer) clearTimeout(form.timer);
|
|
300
|
+
if (form.phase === "pending") {
|
|
301
|
+
patch({ phase: "ready", requestId: null, timer: null, error: "Connection lost. The session may already have been created; check the list first." });
|
|
302
|
+
}
|
|
303
|
+
if (form.defaultSaving) patch({ defaultSaving: false, defaultRequestId: null, defaultVendor: null, error: "Connection lost before the project default was confirmed." });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function clearAll() {
|
|
307
|
+
var form = createState();
|
|
308
|
+
if (form && form.timer) clearTimeout(form.timer);
|
|
309
|
+
store.set({ sessionCreateLocks: {}, sessionCreate: null, sessionCreateOptions: null, sessionCreateReveal: null });
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
store.subscribe(function (state, previous) {
|
|
313
|
+
if (state.currentSlug !== previous.currentSlug || state.dmMode !== previous.dmMode) {
|
|
314
|
+
if (state.sessionCreate || state.sessionCreateOptions || state.sessionCreateReveal) clearAll();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
var form = state.sessionCreate;
|
|
318
|
+
if (previous.connected && !state.connected) onDisconnect(form);
|
|
319
|
+
// The providers are requested again after a reconnect.
|
|
320
|
+
if (!previous.connected && state.connected && form) requestOptions();
|
|
321
|
+
// Folder removed while its row was open: say so instead of letting a later
|
|
322
|
+
// create fail, and never silently file the session elsewhere.
|
|
323
|
+
if (form && state.sessionFolders !== previous.sessionFolders && form.folderId !== "unfiled") {
|
|
324
|
+
var folders = currentFolderState().folders;
|
|
325
|
+
var known = false;
|
|
326
|
+
for (var i = 0; i < folders.length; i++) if (folders[i].id === form.folderId) known = true;
|
|
327
|
+
if (!known && state.sessionFolders && form.phase !== "pending") {
|
|
328
|
+
store.set({ sessionCreate: null });
|
|
329
|
+
showToast("That folder was removed, so the new session row was closed.", "warn");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
var reveal = state.sessionCreateReveal;
|
|
333
|
+
if (reveal && !reveal.sessionId && state.activeSessionId !== previous.activeSessionId && state.activeSessionId != null && Date.now() < reveal.until) {
|
|
334
|
+
revealCreatedSession(reveal.folderId, state.activeSessionId);
|
|
335
|
+
} else if (reveal && reveal.sessionId && state.sessionFolderLayouts !== previous.sessionFolderLayouts) {
|
|
336
|
+
scheduleReveal();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Measured folder-body expand/collapse motion. Only direct user toggles create
|
|
2
|
+
// animation records; initial snapshots and ordinary rerenders settle instantly.
|
|
3
|
+
|
|
4
|
+
import { store } from './store.js';
|
|
5
|
+
|
|
6
|
+
var MIN_DURATION_MS = 180;
|
|
7
|
+
var MAX_DURATION_MS = 480;
|
|
8
|
+
var LONG_DISTANCE_START_PX = 120;
|
|
9
|
+
var MS_PER_EXTRA_PX = 0.32;
|
|
10
|
+
var EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
|
11
|
+
|
|
12
|
+
function keyFor(surface, folderId) {
|
|
13
|
+
return surface + ":" + folderId;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function records() {
|
|
17
|
+
return store.get('sessionFolderCollapseAnimations') || {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function putRecord(key, record) {
|
|
21
|
+
var next = Object.assign({}, records());
|
|
22
|
+
if (record) next[key] = record;
|
|
23
|
+
else delete next[key];
|
|
24
|
+
store.set({ sessionFolderCollapseAnimations: next });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function currentRecord(key, token) {
|
|
28
|
+
var record = records()[key];
|
|
29
|
+
return record && (!token || record.token === token) ? record : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function reducedMotion() {
|
|
33
|
+
return typeof window !== "undefined" && window.matchMedia &&
|
|
34
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function cancelBodyAnimation(body) {
|
|
38
|
+
var animations = body.getAnimations ? body.getAnimations() : [];
|
|
39
|
+
for (var i = 0; i < animations.length; i++) animations[i].cancel();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function durationForDistance(distance) {
|
|
43
|
+
var extra = Math.max(0, distance - LONG_DISTANCE_START_PX) * MS_PER_EXTRA_PX;
|
|
44
|
+
return Math.round(Math.min(MAX_DURATION_MS, MIN_DURATION_MS + extra));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scrollContainerFor(element) {
|
|
48
|
+
var node = element && element.parentElement;
|
|
49
|
+
while (node && node !== document.body) {
|
|
50
|
+
var style = window.getComputedStyle(node);
|
|
51
|
+
if (/(auto|scroll|overlay)/.test(style.overflowY) && node.scrollHeight > node.clientHeight) return node;
|
|
52
|
+
node = node.parentElement;
|
|
53
|
+
}
|
|
54
|
+
return document.scrollingElement;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function captureScrollPosition(element) {
|
|
58
|
+
var scroller = scrollContainerFor(element);
|
|
59
|
+
return scroller ? scroller.scrollTop : null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function restoreScrollPosition(record, element) {
|
|
63
|
+
if (typeof record.scrollTop !== "number") return;
|
|
64
|
+
var scroller = scrollContainerFor(element);
|
|
65
|
+
if (scroller) scroller.scrollTop = record.scrollTop;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function cleanBody(body) {
|
|
69
|
+
cancelBodyAnimation(body);
|
|
70
|
+
body.classList.remove("folder-collapse-animating");
|
|
71
|
+
body.style.height = "";
|
|
72
|
+
body.style.overflow = "";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function setAccessibleState(root, body, toggle, open) {
|
|
76
|
+
toggle.setAttribute("aria-expanded", String(open));
|
|
77
|
+
root.classList.toggle("collapsed", !open);
|
|
78
|
+
if (open) {
|
|
79
|
+
body.hidden = false;
|
|
80
|
+
body.removeAttribute("aria-hidden");
|
|
81
|
+
body.removeAttribute("inert");
|
|
82
|
+
body.inert = false;
|
|
83
|
+
} else {
|
|
84
|
+
if (body.contains(document.activeElement)) toggle.focus({ preventScroll: true });
|
|
85
|
+
body.setAttribute("aria-hidden", "true");
|
|
86
|
+
body.setAttribute("inert", "");
|
|
87
|
+
body.inert = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function finish(key, token, root, body, toggle, open) {
|
|
92
|
+
if (currentRecord(key, token)) putRecord(key, null);
|
|
93
|
+
cleanBody(body);
|
|
94
|
+
setAccessibleState(root, body, toggle, open);
|
|
95
|
+
body.hidden = !open;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function animateRecord(key, record, root, body, toggle) {
|
|
99
|
+
if (!body.isConnected || !currentRecord(key, record.token)) return;
|
|
100
|
+
cleanBody(body);
|
|
101
|
+
body.hidden = false;
|
|
102
|
+
var start = typeof record.height === "number" ? record.height : body.getBoundingClientRect().height;
|
|
103
|
+
body.style.height = "";
|
|
104
|
+
var end = record.open ? body.getBoundingClientRect().height : 0;
|
|
105
|
+
body.style.height = start + "px";
|
|
106
|
+
body.style.overflow = "hidden";
|
|
107
|
+
body.classList.add("folder-collapse-animating");
|
|
108
|
+
setAccessibleState(root, body, toggle, record.open);
|
|
109
|
+
var distance = Math.abs(end - start);
|
|
110
|
+
if (!record.endsAt) {
|
|
111
|
+
record = Object.assign({}, record, { endsAt: Date.now() + durationForDistance(distance) });
|
|
112
|
+
putRecord(key, record);
|
|
113
|
+
}
|
|
114
|
+
var remaining = Math.max(1, record.endsAt - Date.now());
|
|
115
|
+
if (reducedMotion() || !body.animate || Math.abs(end - start) < 0.5) {
|
|
116
|
+
finish(key, record.token, root, body, toggle, record.open);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
var animation = body.animate(
|
|
120
|
+
[{ height: start + "px" }, { height: end + "px" }],
|
|
121
|
+
{ duration: remaining, easing: EASING, fill: "forwards" }
|
|
122
|
+
);
|
|
123
|
+
animation.finished.then(function () {
|
|
124
|
+
finish(key, record.token, root, body, toggle, record.open);
|
|
125
|
+
}).catch(function () {});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function toggleFolderCollapse(root, body, toggle, surface, folderId, commit) {
|
|
129
|
+
var key = keyFor(surface, folderId);
|
|
130
|
+
var open = toggle.getAttribute("aria-expanded") !== "true";
|
|
131
|
+
var start = body.hidden ? 0 : body.getBoundingClientRect().height;
|
|
132
|
+
cancelBodyAnimation(body);
|
|
133
|
+
if (open) body.hidden = false;
|
|
134
|
+
var record = {
|
|
135
|
+
token: Date.now() + ":" + Math.random(),
|
|
136
|
+
open: open,
|
|
137
|
+
height: start,
|
|
138
|
+
endsAt: 0,
|
|
139
|
+
scrollTop: captureScrollPosition(toggle),
|
|
140
|
+
};
|
|
141
|
+
putRecord(key, record);
|
|
142
|
+
commit(!open);
|
|
143
|
+
animateRecord(key, record, root, body, toggle);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function restoreFolderCollapse(root, body, toggle, surface, folderId, collapsed, forceOpen) {
|
|
147
|
+
var key = keyFor(surface, folderId);
|
|
148
|
+
var record = currentRecord(key);
|
|
149
|
+
cleanBody(body);
|
|
150
|
+
if (forceOpen) {
|
|
151
|
+
if (record) putRecord(key, null);
|
|
152
|
+
setAccessibleState(root, body, toggle, true);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (!record) {
|
|
156
|
+
setAccessibleState(root, body, toggle, !collapsed);
|
|
157
|
+
body.hidden = collapsed;
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
setAccessibleState(root, body, toggle, record.open);
|
|
161
|
+
body.style.height = record.height + "px";
|
|
162
|
+
body.style.overflow = "hidden";
|
|
163
|
+
body.classList.add("folder-collapse-animating");
|
|
164
|
+
queueMicrotask(function () {
|
|
165
|
+
var live = currentRecord(key, record.token);
|
|
166
|
+
if (live) {
|
|
167
|
+
restoreScrollPosition(live, toggle);
|
|
168
|
+
animateRecord(key, live, root, body, toggle);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function captureFolderCollapseAnimations(root) {
|
|
174
|
+
if (!root || !root.querySelectorAll) return;
|
|
175
|
+
var bodies = root.querySelectorAll(".session-folder-body.folder-collapse-animating");
|
|
176
|
+
var next = Object.assign({}, records());
|
|
177
|
+
var changed = false;
|
|
178
|
+
for (var i = 0; i < bodies.length; i++) {
|
|
179
|
+
var body = bodies[i];
|
|
180
|
+
var folder = body.closest(".session-folder");
|
|
181
|
+
if (!folder) continue;
|
|
182
|
+
var surface = folder.classList.contains("is-mobile") ? "mobile" : "desktop";
|
|
183
|
+
var key = keyFor(surface, folder.dataset.folderId);
|
|
184
|
+
if (!next[key]) continue;
|
|
185
|
+
next[key] = Object.assign({}, next[key], {
|
|
186
|
+
height: body.getBoundingClientRect().height,
|
|
187
|
+
scrollTop: captureScrollPosition(body),
|
|
188
|
+
});
|
|
189
|
+
changed = true;
|
|
190
|
+
}
|
|
191
|
+
if (changed) store.set({ sessionFolderCollapseAnimations: next });
|
|
192
|
+
}
|