@vp-tw/dirwell 0.1.0-alpha.5 → 0.1.0-alpha.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @vp-tw/dirwell
2
2
 
3
+ ## 0.1.0-alpha.7
4
+
5
+ ### Minor Changes
6
+
7
+ - Make Crosswave categories configurable with ordered labels, decorative icons, overlapping extension/MIME filters, and exported default definitions. Infer filename types with the pinned Node-only MIME database; generated browser assets contain membership IDs without the database. Add a runnable custom-category example and configuration documentation.
8
+
9
+ ## 0.1.0-alpha.6
10
+
11
+ ### Minor Changes
12
+
13
+ - Keep Crosswave's background and controls alive while directory contents slide in opposite directions for entry and return. Generated navigation assets support SSG/MPA, custom URLs, static hosting, build adapters, and relative local HTML. Restore browsing state through history and handle cancelled, superseded, unavailable, incompatible, restricted, and timed-out loads with usable recovery controls.
14
+
3
15
  ## 0.1.0-alpha.5
4
16
 
5
17
  ### Patch Changes
@@ -12,3 +12,11 @@ in the published package, and in `src/vscode-icons/NOTICE.txt` in the source tre
12
12
  Generated sites include this notice as `vscode-icons-NOTICE.txt`. The default
13
13
  theme footer links to the [Ledger theme README](https://github.com/vp-tw/dirwell/blob/main/src/theme-default/README.md),
14
14
  which links to the source notice.
15
+
16
+ ## MIME classification
17
+
18
+ Crosswave uses [`mime` 4.1.0](https://github.com/broofa/mime), MIT-licensed,
19
+ Copyright (c) 2023 Robert Kieffer, for filename-based classification during Node
20
+ generation. It uses optimized MIME database mappings; no MIME database is emitted
21
+ to generated browser assets. The installed dependency includes its full MIT
22
+ license. MIME estimates do not validate file contents or change serving headers.
package/dist/bin.mjs CHANGED
@@ -97,7 +97,7 @@ async function stopDaemon(cwd) {
97
97
  }
98
98
  //#endregion
99
99
  //#region package.json
100
- var version = "0.1.0-alpha.5";
100
+ var version = "0.1.0-alpha.7";
101
101
  //#endregion
102
102
  //#region src/cli.ts
103
103
  const commonArguments = {
@@ -12,6 +12,442 @@
12
12
  if (reduced.matches || root.dataset.cwTransitions === "false") transition.skipTransition();
13
13
  });
14
14
  }
15
+ // Included in the theme's private runtime closure, never installed as a global API.
16
+ function createCrosswaveNavigator({ main, reduced, getState, commit }) {
17
+ const rootUrl = new URL(main.dataset.cwRoot, document.baseURI);
18
+ const initialUrl = new URL(location.href);
19
+ initialUrl.hash = "";
20
+ const local = initialUrl.protocol === "file:";
21
+ const owner = rootUrl.href;
22
+ const scopePath = rootUrl.pathname.endsWith("/")
23
+ ? rootUrl.pathname
24
+ : new URL(".", rootUrl).pathname;
25
+ let current = {
26
+ url: initialUrl.href,
27
+ asset: new URL(main.dataset.cwPage, document.baseURI).href,
28
+ path: main.dataset.cwPath,
29
+ };
30
+ const recent = new Map();
31
+ const historyViews = new Map();
32
+ const savedEntry = history.state?.dirwellCrosswave;
33
+ let index =
34
+ savedEntry?.owner === owner && Number.isInteger(savedEntry.index) ? savedEntry.index : 0,
35
+ request = 0,
36
+ pending,
37
+ animations = [];
38
+ const notice = document.createElement("div");
39
+ notice.className = "cw-navigation-notice";
40
+ notice.setAttribute("role", "status");
41
+ notice.hidden = true;
42
+ document.body.append(notice);
43
+ const allowed = (url) =>
44
+ url.protocol === rootUrl.protocol &&
45
+ url.host === rootUrl.host &&
46
+ url.pathname.startsWith(scopePath);
47
+ const absoluteLinks = (scope, base) => {
48
+ for (const link of scope.querySelectorAll("a[href]")) {
49
+ const url = new URL(link.getAttribute("href"), base);
50
+ if (local && url.pathname.endsWith("/")) url.pathname += "index.html";
51
+ link.href = url.href;
52
+ }
53
+ for (const link of scope.querySelectorAll("[data-cw-page]"))
54
+ link.dataset.cwPage = new URL(link.dataset.cwPage, base).href;
55
+ };
56
+ absoluteLinks(document, document.baseURI);
57
+ const ownsCurrentEntry = () => {
58
+ const entry = history.state?.dirwellCrosswave;
59
+ return entry?.owner === owner && entry.index === index && entry.route.path === current.path;
60
+ };
61
+ const state = (route = current, position = index, view = getState()) => ({
62
+ ...history.state,
63
+ dirwellCrosswave: { owner, route, index: position, view },
64
+ });
65
+ try {
66
+ history.replaceState(state(), "");
67
+ } catch {
68
+ return;
69
+ } // Restricted hosts retain ordinary links and native transitions.
70
+
71
+ const stopAnimations = () => {
72
+ for (const { animation, layer } of animations) {
73
+ animation.cancel();
74
+ layer?.remove();
75
+ }
76
+ animations = [];
77
+ };
78
+ const show = (text, actions = []) => {
79
+ notice.replaceChildren(document.createTextNode(text));
80
+ for (const { label, run, href } of actions) {
81
+ const action = document.createElement(href ? "a" : "button");
82
+ action.textContent = label;
83
+ if (href) action.href = href;
84
+ else {
85
+ action.type = "button";
86
+ action.addEventListener("click", run);
87
+ }
88
+ notice.append(action);
89
+ }
90
+ notice.hidden = false;
91
+ };
92
+ const load = (route, signal) =>
93
+ new Promise((resolve, reject) => {
94
+ const asset = new URL(route.asset);
95
+ if (
96
+ !allowed(asset) ||
97
+ !/^crosswave-page-[a-f0-9]{24}\.js$/.test(asset.pathname.split("/").at(-1))
98
+ ) {
99
+ reject(new Error("Unsupported folder data"));
100
+ return;
101
+ }
102
+ const script = document.createElement("script");
103
+ script.async = true;
104
+ // Revalidate on every visit: a watcher may have rebuilt this directory.
105
+ asset.searchParams.set("cw-visit", String(Date.now()));
106
+ script.src = asset.href;
107
+ let payload;
108
+ const received = (event) => {
109
+ if (document.currentScript !== script) return;
110
+ const data = event.detail;
111
+ if (
112
+ data?.version === 1 &&
113
+ data.id === asset.pathname.split("/").at(-1) &&
114
+ data.path === route.path &&
115
+ typeof data.html === "string" &&
116
+ typeof data.outputName === "string" &&
117
+ (data.baseHref === null || typeof data.baseHref === "string")
118
+ )
119
+ payload = data;
120
+ };
121
+ const cleanup = () => {
122
+ clearTimeout(timer);
123
+ removeEventListener("dirwell:crosswave-page", received);
124
+ signal.removeEventListener("abort", aborted);
125
+ script.remove();
126
+ script.onload = script.onerror = null;
127
+ };
128
+ const fail = (error) => {
129
+ cleanup();
130
+ reject(error);
131
+ };
132
+ const aborted = () => fail(new DOMException("Navigation superseded", "AbortError"));
133
+ const timer = setTimeout(() => fail(new Error("Folder loading timed out")), 8000);
134
+ script.onerror = () => fail(new Error("Folder data is unavailable"));
135
+ script.onload = () => {
136
+ cleanup();
137
+ try {
138
+ if (!payload) {
139
+ reject(new Error("Folder data is incompatible"));
140
+ return;
141
+ }
142
+ const doc = new DOMParser().parseFromString(payload.html, "text/html");
143
+ const required = [
144
+ "[data-cw-root]",
145
+ ".cw-files",
146
+ ".cw-directory",
147
+ ".cw-location",
148
+ ".cw-stage > h1",
149
+ ".cw-directory > :first-child",
150
+ ".cw-empty",
151
+ ];
152
+ if (required.some((selector) => !doc.querySelector(selector))) {
153
+ reject(new Error("Folder data is incomplete"));
154
+ return;
155
+ }
156
+ if (
157
+ doc.querySelector("[data-cw-root]").dataset.cwCategoryConfig !==
158
+ main.dataset.cwCategoryConfig
159
+ )
160
+ throw new Error("Folder category configuration changed; open normally to reload");
161
+ const url = new URL(route.url);
162
+ if (local && url.pathname.endsWith("/")) url.pathname += payload.outputName;
163
+ const base = new URL(payload.baseHref ?? ".", url);
164
+ absoluteLinks(doc, base);
165
+ resolve({ doc, route: { ...route, url: url.href } });
166
+ } catch (error) {
167
+ reject(error);
168
+ }
169
+ };
170
+ addEventListener("dirwell:crosswave-page", received);
171
+ signal.addEventListener("abort", aborted, { once: true });
172
+ try {
173
+ document.head.append(script);
174
+ } catch (error) {
175
+ fail(error);
176
+ }
177
+ });
178
+ const address = (route) => {
179
+ if (!local) return route.url;
180
+ const url = new URL(initialUrl);
181
+ const relative = (value) => new URL(value).pathname.slice(scopePath.length);
182
+ url.hash = `cw=${encodeURIComponent(relative(route.url))}&cw-data=${encodeURIComponent(relative(route.asset))}&cw-path=${encodeURIComponent(route.path)}`;
183
+ return url.href;
184
+ };
185
+ const snapshot = (element) => {
186
+ const rect = element.getBoundingClientRect();
187
+ const viewportBottom = Math.min(
188
+ innerHeight,
189
+ document.querySelector(".cw-bottom").getBoundingClientRect().top,
190
+ );
191
+ const clone = element.cloneNode(false);
192
+ if (element.matches(".cw-browser")) {
193
+ clone.append(element.querySelector(".cw-list-head").cloneNode(true));
194
+ const source = element.querySelector(".cw-list-scroll");
195
+ const scroll = source.cloneNode(false),
196
+ list = element.querySelector(".cw-files").cloneNode(false);
197
+ const filesRect = element.querySelector(".cw-files").getBoundingClientRect();
198
+ const sourceRect = source.getBoundingClientRect();
199
+ const visibleTop = Math.max(0, sourceRect.top);
200
+ const visibleBottom = Math.min(viewportBottom, sourceRect.bottom);
201
+ list.style.cssText = `position:relative;height:${source.scrollHeight}px`;
202
+ // Clone visible rows only; large directories must not duplicate their entire DOM for motion.
203
+ for (const row of element.querySelectorAll(".cw-row:not([hidden])")) {
204
+ const rowRect = row.getBoundingClientRect();
205
+ if (rowRect.bottom < visibleTop) continue;
206
+ if (rowRect.top > visibleBottom) break;
207
+ const copy = row.cloneNode(true);
208
+ copy.style.cssText = `position:absolute;left:0;right:0;top:${rowRect.top - filesRect.top}px;width:100%`;
209
+ list.append(copy);
210
+ }
211
+ scroll.append(list, element.querySelector(".cw-empty").cloneNode(true));
212
+ clone.append(scroll);
213
+ clone._scroll = { element: scroll, top: source.scrollTop };
214
+ } else clone.replaceChildren(...[...element.childNodes].map((node) => node.cloneNode(true)));
215
+ for (const node of [clone, ...clone.querySelectorAll("*")]) {
216
+ for (const name of node.getAttributeNames())
217
+ if (name.startsWith("data-cw-")) node.removeAttribute(name);
218
+ }
219
+ clone.removeAttribute("id");
220
+ clone.querySelectorAll("[id]").forEach((node) => node.removeAttribute("id"));
221
+ clone.setAttribute("aria-hidden", "true");
222
+ clone.inert = true;
223
+ clone.classList.add("cw-transition-layer");
224
+ clone.style.cssText = `position:fixed;left:${rect.left}px;top:${rect.top}px;width:${rect.width}px;height:${rect.height}px;margin:0;z-index:3;pointer-events:none;view-transition-name:none`;
225
+ clone.style.overflow = "hidden";
226
+ clone.style.clipPath = `inset(${Math.max(0, -rect.top)}px 0 ${Math.max(0, rect.bottom - viewportBottom)}px 0)`;
227
+ document.body.append(clone);
228
+ if (clone._scroll) clone._scroll.element.scrollTop = clone._scroll.top;
229
+ return clone;
230
+ };
231
+ const exchange = (loaded, view, direction, writeHistory, position) => {
232
+ const moving =
233
+ !reduced.matches &&
234
+ document.documentElement.dataset.cwTransitions !== "false" &&
235
+ typeof main.animate === "function";
236
+ stopAnimations();
237
+ const elements = [
238
+ document.querySelector(".cw-browser"),
239
+ document.querySelector(".cw-detail"),
240
+ ];
241
+ const layers = moving
242
+ ? elements.map((element) =>
243
+ element.hidden || !element.getBoundingClientRect().height ? null : snapshot(element),
244
+ )
245
+ : [];
246
+ try {
247
+ if (writeHistory) {
248
+ // Save the view at departure, including edits made while a request was pending.
249
+ if (ownsCurrentEntry()) history.replaceState(state(), "");
250
+ history.pushState(state(loaded.route, position, view ?? {}), "", address(loaded.route));
251
+ for (const key of historyViews.keys())
252
+ if (key > position || key < position - history.length + 1) historyViews.delete(key);
253
+ }
254
+ commit(loaded.doc, view);
255
+ current = loaded.route;
256
+ index = position;
257
+ historyViews.set(index, { path: current.path, view: getState() });
258
+ try {
259
+ history.replaceState(state(), "");
260
+ } catch {}
261
+ } catch (error) {
262
+ layers.forEach((layer) => layer?.remove());
263
+ throw error;
264
+ }
265
+ if (!moving) return;
266
+ try {
267
+ elements.forEach((element, i) => {
268
+ const layer = layers[i];
269
+ if (layer) {
270
+ const animation = layer.animate(
271
+ [
272
+ { opacity: 1, transform: "translateX(0)" },
273
+ { opacity: 0, transform: `translateX(${-direction * 24}px)` },
274
+ ],
275
+ { duration: 140, easing: "ease-out" },
276
+ );
277
+ animations.push({ animation, layer });
278
+ animation.finished.then(
279
+ () => layer.remove(),
280
+ () => layer.remove(),
281
+ );
282
+ }
283
+ if (!element.hidden) {
284
+ const animation = element.animate(
285
+ [
286
+ { opacity: 0.25, transform: `translateX(${direction * 28}px)` },
287
+ { opacity: 1, transform: "translateX(0)" },
288
+ ],
289
+ { duration: 240, easing: "cubic-bezier(.16,1,.3,1)" },
290
+ );
291
+ animations.push({ animation });
292
+ animation.finished.catch(() => {});
293
+ }
294
+ });
295
+ } catch {
296
+ stopAnimations();
297
+ layers.forEach((layer) => layer?.remove());
298
+ }
299
+ };
300
+ const navigate = async (route, { view, pop = false, position = index + 1 } = {}) => {
301
+ historyViews.set(index, { path: current.path, view: getState() });
302
+ if (pop && historyViews.get(position)?.path === route.path)
303
+ view = historyViews.get(position).view;
304
+ const ticket = ++request;
305
+ pending?.abort();
306
+ const controller = new AbortController();
307
+ pending = controller;
308
+ if (!pop) {
309
+ const entry = history.state?.dirwellCrosswave;
310
+ position =
311
+ (entry?.owner === owner && Number.isInteger(entry.index) ? entry.index : index) + 1;
312
+ recent.delete(current.path);
313
+ recent.set(current.path, getState());
314
+ if (recent.size > 32) recent.delete(recent.keys().next().value);
315
+ if (view === undefined) view = recent.get(route.path);
316
+ try {
317
+ if (ownsCurrentEntry()) history.replaceState(state(), "");
318
+ } catch {
319
+ location.assign(route.url);
320
+ return;
321
+ }
322
+ }
323
+ const direction = pop
324
+ ? Math.sign(position - index) || 1
325
+ : current.path && (current.path.startsWith(`${route.path}/`) || route.path === "")
326
+ ? -1
327
+ : 1;
328
+ main.setAttribute("aria-busy", "true");
329
+ const delay = setTimeout(() => {
330
+ if (ticket === request)
331
+ show("Loading folder…", [{ label: "Cancel", run: () => controller.abort() }]);
332
+ }, 120);
333
+ try {
334
+ const loaded = await load(route, controller.signal);
335
+ if (ticket !== request || controller.signal.aborted) return;
336
+ exchange(loaded, view, direction, !pop, position);
337
+ notice.hidden = true;
338
+ } catch (error) {
339
+ if (ticket !== request) return;
340
+ if (pop || !ownsCurrentEntry()) {
341
+ // The browser has already changed its address: reload that exact destination on failure.
342
+ if (local) location.replace(route.url);
343
+ else location.reload();
344
+ return;
345
+ }
346
+ if (error.name === "AbortError") {
347
+ notice.hidden = true;
348
+ return;
349
+ }
350
+ show("Could not open this folder. Your current files are still available.", [
351
+ { label: "Retry", run: () => navigate(route, { view }) },
352
+ { label: "Open normally", href: route.url },
353
+ ]);
354
+ } finally {
355
+ clearTimeout(delay);
356
+ if (ticket === request) {
357
+ main.removeAttribute("aria-busy");
358
+ pending = null;
359
+ }
360
+ }
361
+ };
362
+ document.addEventListener("click", (event) => {
363
+ if (event.target.closest?.(".cw-skip")) {
364
+ event.preventDefault();
365
+ const panel = document.querySelector("#cw-panel");
366
+ panel.tabIndex = -1;
367
+ panel.focus({ preventScroll: true });
368
+ return;
369
+ }
370
+ const link = event.target.closest?.("a[data-cw-navigation][data-cw-page]");
371
+ if (
372
+ !link ||
373
+ event.defaultPrevented ||
374
+ event.button !== 0 ||
375
+ event.metaKey ||
376
+ event.ctrlKey ||
377
+ event.shiftKey ||
378
+ event.altKey ||
379
+ link.target ||
380
+ link.hasAttribute("download")
381
+ )
382
+ return;
383
+ const url = new URL(link.href),
384
+ asset = new URL(link.dataset.cwPage);
385
+ if (!allowed(url) || !allowed(asset) || url.hash) return;
386
+ event.preventDefault();
387
+ if (link.dataset.cwPath === current.path) {
388
+ pending?.abort();
389
+ notice.hidden = true;
390
+ return;
391
+ }
392
+ navigate({ url: url.href, asset: asset.href, path: link.dataset.cwPath });
393
+ });
394
+ document.addEventListener("keydown", (event) => {
395
+ if (
396
+ event.key === "Escape" &&
397
+ !event.isComposing &&
398
+ !event.altKey &&
399
+ !event.ctrlKey &&
400
+ !event.metaKey &&
401
+ !event.target.matches?.("input,textarea,select,[contenteditable]")
402
+ ) {
403
+ pending?.abort();
404
+ notice.hidden = true;
405
+ }
406
+ });
407
+ addEventListener("popstate", (event) => {
408
+ const entry = event.state?.dirwellCrosswave;
409
+ if (entry?.owner !== owner || !allowed(new URL(entry.route.url))) {
410
+ location.reload();
411
+ return;
412
+ }
413
+ navigate(entry.route, { view: entry.view, pop: true, position: entry.index });
414
+ });
415
+ addEventListener("pagehide", () => {
416
+ ++request;
417
+ pending?.abort();
418
+ main.removeAttribute("aria-busy");
419
+ notice.hidden = true;
420
+ stopAnimations();
421
+ });
422
+ reduced.addEventListener("change", () => {
423
+ if (reduced.matches) stopAnimations();
424
+ });
425
+ if (local && initialUrl.href !== location.href) {
426
+ const hash = new URLSearchParams(location.hash.slice(1));
427
+ if (hash.has("cw") && hash.has("cw-data") && hash.has("cw-path")) {
428
+ const url = new URL(hash.get("cw"), rootUrl),
429
+ asset = new URL(hash.get("cw-data"), rootUrl);
430
+ if (allowed(url) && allowed(asset))
431
+ navigate(
432
+ { url: url.href, asset: asset.href, path: hash.get("cw-path") },
433
+ {
434
+ pop: true,
435
+ position: index,
436
+ view: savedEntry?.owner === owner ? savedEntry.view : undefined,
437
+ },
438
+ );
439
+ }
440
+ }
441
+ return {
442
+ cancel() {
443
+ if (!pending) return false;
444
+ pending.abort();
445
+ notice.hidden = true;
446
+ return true;
447
+ },
448
+ };
449
+ }
450
+
15
451
  document.addEventListener("DOMContentLoaded", () => {
16
452
  const main = document.querySelector("[data-cw-root]");
17
453
  if (!main) return;
@@ -40,22 +476,20 @@
40
476
  const savedColor = read("color");
41
477
  if (Object.hasOwn(colors, savedColor)) root.dataset.cwColor = savedColor;
42
478
  let paused = root.dataset.cwMotion === "false" || read("paused") === "true";
43
- const rows = [...document.querySelectorAll("[data-cw-entry]")];
479
+ let rows = [...document.querySelectorAll("[data-cw-entry]")];
44
480
  const tabs = [...document.querySelectorAll("[data-cw-category]")];
45
481
  const search = document.querySelector("#cw-search");
46
482
  const clear = document.querySelector("[data-cw-clear]");
47
483
  const empty = document.querySelector("[data-cw-empty]");
48
484
  const status = document.querySelector("[data-cw-status]");
49
485
  const detail = document.querySelector(".cw-detail");
50
- const icons = new Map(tabs.map((tab) => [tab.dataset.cwCategory, tab.querySelector("svg")]));
51
- const linkIcon = rows.find((row) => row.dataset.icon === "link")?.querySelector("svg");
52
- if (linkIcon) icons.set("link", linkIcon);
53
- let category = "all",
486
+ const belongs = (row, id) => row.dataset.cwCategories.split(" ").includes(id);
487
+ let category = tabs[0].dataset.cwCategory,
54
488
  selected = null,
55
489
  composing = false,
56
490
  searchTimer;
57
491
  const visible = () => rows.filter((row) => !row.hidden);
58
- const select = (row, focus = false) => {
492
+ const select = (row, focus = false, reveal = true) => {
59
493
  if (selected) selected.classList.remove("cw-selected");
60
494
  selected = row;
61
495
  if (!row) {
@@ -74,7 +508,7 @@
74
508
  document.querySelector(`[data-cw-detail-${field}]`).textContent = row.dataset[data];
75
509
  const host = document.querySelector("[data-cw-detail-icon]");
76
510
  host.replaceChildren();
77
- const icon = icons.get(row.dataset.icon);
511
+ const icon = row.querySelector(".cw-entry-icon svg");
78
512
  if (icon) host.append(icon.cloneNode(true));
79
513
  const link = row.querySelector("a");
80
514
  const open = document.querySelector("[data-cw-open]");
@@ -90,16 +524,24 @@
90
524
  open.target = "_blank";
91
525
  open.rel = "noopener";
92
526
  open.removeAttribute("data-cw-navigation");
527
+ delete open.dataset.cwPage;
528
+ delete open.dataset.cwPath;
93
529
  } else {
94
530
  open.removeAttribute("target");
95
531
  open.removeAttribute("rel");
96
532
  open.setAttribute("data-cw-navigation", "");
533
+ open.dataset.cwPage = link.dataset.cwPage;
534
+ open.dataset.cwPath = link.dataset.cwPath;
97
535
  }
98
536
  }
99
537
  detail.hidden = false;
100
538
  if (focus) {
101
539
  link?.focus({ preventScroll: true });
102
- row.scrollIntoView({ block: "nearest", behavior: reduced.matches ? "instant" : "smooth" });
540
+ if (reveal)
541
+ row.scrollIntoView({
542
+ block: "nearest",
543
+ behavior: reduced.matches ? "instant" : "smooth",
544
+ });
103
545
  }
104
546
  };
105
547
  const applyFilter = () => {
@@ -107,7 +549,7 @@
107
549
  let count = 0;
108
550
  for (const row of rows) {
109
551
  row.hidden =
110
- (category !== "all" && row.dataset.category !== category) ||
552
+ !belongs(row, category) ||
111
553
  !`${row.dataset.name} ${row.dataset.path}`.toLocaleLowerCase().includes(query);
112
554
  if (!row.hidden) count++;
113
555
  }
@@ -123,7 +565,7 @@
123
565
  clear.hidden = !search.value;
124
566
  if (!selected || selected.hidden) select(visible()[0] ?? null);
125
567
  };
126
- const setCategory = (next, focus = false) => {
568
+ const setCategory = (next, focus = false, animate = true) => {
127
569
  category = next;
128
570
  for (const tab of tabs) {
129
571
  const active = tab.dataset.cwCategory === next;
@@ -146,9 +588,11 @@
146
588
  selected = null;
147
589
  rows.forEach((row) => row.classList.remove("cw-selected"));
148
590
  applyFilter();
149
- main.classList.remove("cw-category-changing");
150
- void main.offsetWidth;
151
- main.classList.add("cw-category-changing");
591
+ if (animate) {
592
+ main.classList.remove("cw-category-changing");
593
+ void main.offsetWidth;
594
+ main.classList.add("cw-category-changing");
595
+ }
152
596
  };
153
597
  const moveCategory = (direction) => {
154
598
  const index = tabs.findIndex((tab) => tab.dataset.cwCategory === category);
@@ -163,18 +607,17 @@
163
607
  };
164
608
  for (const tab of tabs) {
165
609
  tab.addEventListener("click", () => setCategory(tab.dataset.cwCategory));
166
- const count =
167
- tab.dataset.cwCategory === "all"
168
- ? rows.length
169
- : rows.filter((row) => row.dataset.category === tab.dataset.cwCategory).length;
610
+ const count = rows.filter((row) => belongs(row, tab.dataset.cwCategory)).length;
170
611
  tab.querySelector("small").textContent = String(count);
171
612
  }
172
- for (const row of rows) {
173
- row.addEventListener("pointerenter", (event) => {
174
- if (event.pointerType !== "touch") select(row);
175
- });
176
- row.addEventListener("focusin", () => select(row));
177
- }
613
+ main.addEventListener("pointerover", (event) => {
614
+ const row = event.target.closest?.("[data-cw-entry]");
615
+ if (row && event.pointerType !== "touch") select(row);
616
+ });
617
+ main.addEventListener("focusin", (event) => {
618
+ const row = event.target.closest?.("[data-cw-entry]");
619
+ if (row) select(row);
620
+ });
178
621
  search.addEventListener("compositionstart", () => {
179
622
  composing = true;
180
623
  clearTimeout(searchTimer);
@@ -215,7 +658,10 @@
215
658
  "Your browser blocked the new tab. Press Enter or click the file to open it.";
216
659
  }
217
660
  };
218
- const back = () => document.querySelector("[data-cw-parent]")?.click();
661
+ let navigation;
662
+ const back = () => {
663
+ if (!navigation?.cancel()) document.querySelector("[data-cw-parent]")?.click();
664
+ };
219
665
  const editing = () =>
220
666
  document.activeElement?.matches("input,textarea,select,[contenteditable=true]");
221
667
  document.addEventListener("keydown", (event) => {
@@ -317,7 +763,7 @@
317
763
  document.querySelector("#cw-wave").addEventListener("cw-wave-fallback", updateMotion);
318
764
  reduced.addEventListener("change", updateMotion);
319
765
  updateMotion();
320
- setCategory("all");
766
+ setCategory(tabs[0].dataset.cwCategory);
321
767
  const scroll = document.querySelector(".cw-list-scroll");
322
768
  const fitList = () => {
323
769
  const footer = document.querySelector(".cw-bottom");
@@ -342,6 +788,58 @@
342
788
  addEventListener("resize", fitList);
343
789
  fitList();
344
790
 
791
+ navigation = createCrosswaveNavigator({
792
+ main,
793
+ reduced,
794
+ getState: () => ({
795
+ category,
796
+ query: search.value,
797
+ selected: selected?.dataset.path,
798
+ scroll: scroll.scrollTop,
799
+ }),
800
+ commit: (doc, view) => {
801
+ clearTimeout(searchTimer);
802
+ composing = false;
803
+ document.title = doc.title;
804
+ document
805
+ .querySelector(".cw-location")
806
+ .replaceChildren(...doc.querySelector(".cw-location").childNodes);
807
+ document.querySelector(".cw-stage > h1").textContent =
808
+ doc.querySelector(".cw-stage > h1").textContent;
809
+ document
810
+ .querySelector(".cw-directory")
811
+ .firstElementChild.replaceWith(doc.querySelector(".cw-directory").firstElementChild);
812
+ document
813
+ .querySelector(".cw-files")
814
+ .replaceChildren(...doc.querySelector(".cw-files").childNodes);
815
+ main.dataset.cwPath = doc.querySelector("[data-cw-root]").dataset.cwPath;
816
+ rows = [...main.querySelectorAll("[data-cw-entry]")];
817
+ for (const tab of tabs)
818
+ tab.querySelector("small").textContent = String(
819
+ rows.filter((row) => belongs(row, tab.dataset.cwCategory)).length,
820
+ );
821
+ search.value = view?.query ?? "";
822
+ setCategory(
823
+ tabs.some((tab) => tab.dataset.cwCategory === view?.category)
824
+ ? view.category
825
+ : tabs[0].dataset.cwCategory,
826
+ false,
827
+ false,
828
+ );
829
+ main.classList.remove("cw-category-changing");
830
+ const row = rows.find((row) => !row.hidden && row.dataset.path === view?.selected);
831
+ select(row ?? visible()[0] ?? null, true, false);
832
+ if (!selected?.querySelector("a")) {
833
+ document.querySelector("#cw-panel").tabIndex = -1;
834
+ document.querySelector("#cw-panel").focus({ preventScroll: true });
835
+ }
836
+ scroll.scrollTop = view?.scroll ?? 0;
837
+ status.className = "cw-sr";
838
+ document.querySelector(".cw-skip").href = `${location.href.split("#")[0]}#cw-panel`;
839
+ // Persistent ResizeObservers update geometry after the browser lays out the new content.
840
+ },
841
+ });
842
+
345
843
  // VueUse's useGamepad demonstrates event-driven connection and rAF polling.
346
844
  // Read fresh standard-mapping snapshots; controls here are original and framework-free.
347
845
  let padFrame = 0,