cursedbelt 5.55.1 → 5.56.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/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +7 -0
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +14 -0
- package/dist/react/filter-rail/FilterRail.js +1 -1
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- package/dist/react/filter-rail/RailSection.js +2 -2
- package/dist/react/filter-rail/RailSection.js.map +1 -1
- package/dist/react/filter-rail/headerContextMenu.d.ts.map +1 -1
- package/dist/react/filter-rail/headerContextMenu.js +20 -1
- package/dist/react/filter-rail/headerContextMenu.js.map +1 -1
- package/dist/react/hooks/longPressContextMenu.d.ts +35 -0
- package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -0
- package/dist/react/hooks/longPressContextMenu.js +136 -0
- package/dist/react/hooks/longPressContextMenu.js.map +1 -0
- package/dist/react/hooks/useLongPressContextMenu.d.ts +4 -0
- package/dist/react/hooks/useLongPressContextMenu.d.ts.map +1 -0
- package/dist/react/hooks/useLongPressContextMenu.js +12 -0
- package/dist/react/hooks/useLongPressContextMenu.js.map +1 -0
- package/dist/react/hooks/useScrollMemory.d.ts +5 -0
- package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
- package/dist/react/hooks/useScrollMemory.js +48 -23
- package/dist/react/hooks/useScrollMemory.js.map +1 -1
- package/package.json +1 -1
- package/src/react/file-tree/FileTree.spec.tsx +19 -0
- package/src/react/file-tree/FileTree.tsx +8 -0
- package/src/react/file-tree/fileTree.css +14 -0
- package/src/react/filter-rail/FilterRail.tsx +1 -1
- package/src/react/filter-rail/RailSection.tsx +2 -2
- package/src/react/filter-rail/headerContextMenu.spec.tsx +52 -0
- package/src/react/filter-rail/headerContextMenu.ts +19 -1
- package/src/react/hooks/longPressContextMenu.spec.ts +102 -0
- package/src/react/hooks/longPressContextMenu.ts +135 -0
- package/src/react/hooks/useLongPressContextMenu.ts +15 -0
- package/src/react/hooks/useScrollMemory.spec.tsx +46 -0
- package/src/react/hooks/useScrollMemory.ts +47 -20
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A touch HOLD answers like a right-click: it dispatches a real `contextmenu` at the finger.
|
|
3
|
+
*
|
|
4
|
+
* Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make the
|
|
5
|
+
* menus popup, we need to make holding down on those spots pop up the menu on iphone and mobile
|
|
6
|
+
* viewers."* iOS Safari never fires `contextmenu` for a long-press, so every menu wired to it —
|
|
7
|
+
* the file tree's rows and blank space, the rail heading's view menu (show hidden folders) —
|
|
8
|
+
* was unreachable on an iPhone.
|
|
9
|
+
*
|
|
10
|
+
* 🔴 It DISPATCHES the event rather than calling a handler, so every listener already on the
|
|
11
|
+
* surface — a native one through a ref, React's `onContextMenu` on a descendant row — answers
|
|
12
|
+
* a hold exactly as it answers a right-click, and a new menu needs no touch code of its own.
|
|
13
|
+
*
|
|
14
|
+
* The rules, each one a failure it prevents:
|
|
15
|
+
* · touch and pen only — a mouse already has a right-click;
|
|
16
|
+
* · a finger that travels past {@link LONG_PRESS_SLOP_PX} is a scroll or a drag, not a hold;
|
|
17
|
+
* · a browser that fires its OWN `contextmenu` for a hold (Android Chrome) keeps it — the
|
|
18
|
+
* timer is dropped, so one hold never opens two menus; one that arrives AFTER ours fired is
|
|
19
|
+
* swallowed for the same reason;
|
|
20
|
+
* · the `click` the lift produces is swallowed at the window, before `useOutsidePress`'s
|
|
21
|
+
* document listener can read it as a press outside the menu that just opened and shut it;
|
|
22
|
+
* · `-webkit-touch-callout: none` while bound, so iOS does not put its own callout on top;
|
|
23
|
+
* · a press inside `ignore` (a selector) is not a hold — the file tree's grip, where a finger
|
|
24
|
+
* that stays down is about to drag.
|
|
25
|
+
*/
|
|
26
|
+
/*
|
|
27
|
+
* 🔴 No runtime import, React's included: a rail heading loads this module LAZILY (forge task
|
|
28
|
+
* 2791), and a chunk with dependencies drags a preload map into the chunk that imports it. The
|
|
29
|
+
* hook lives in `useLongPressContextMenu.ts`.
|
|
30
|
+
*/
|
|
31
|
+
/** How long a finger must stay down — iOS's own long-press duration. */
|
|
32
|
+
export const LONG_PRESS_MS = 500;
|
|
33
|
+
/** How far a finger may drift and still be holding still. */
|
|
34
|
+
export const LONG_PRESS_SLOP_PX = 8;
|
|
35
|
+
/** How long after a fired hold its lift's `click` and a late native menu are swallowed. */
|
|
36
|
+
const AFTERMATH_MS = 800;
|
|
37
|
+
/** Bind a hold-for-menu to `element`; returns the cleanup. */
|
|
38
|
+
export function bindLongPressContextMenu(element, { delayMs = LONG_PRESS_MS, ignore } = {}) {
|
|
39
|
+
let timer;
|
|
40
|
+
let pointerId = -1;
|
|
41
|
+
let startX = 0;
|
|
42
|
+
let startY = 0;
|
|
43
|
+
let target = null;
|
|
44
|
+
/** Until when a native `contextmenu` and a `click` are the aftermath of OUR menu. */
|
|
45
|
+
let firedUntil = 0;
|
|
46
|
+
let dispatching = false;
|
|
47
|
+
const cancel = () => {
|
|
48
|
+
if (timer !== undefined)
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
timer = undefined;
|
|
51
|
+
};
|
|
52
|
+
const swallowClick = (event) => {
|
|
53
|
+
window.removeEventListener('click', swallowClick, true);
|
|
54
|
+
if (Date.now() > firedUntil)
|
|
55
|
+
return;
|
|
56
|
+
event.preventDefault();
|
|
57
|
+
event.stopImmediatePropagation();
|
|
58
|
+
};
|
|
59
|
+
const fire = () => {
|
|
60
|
+
timer = undefined;
|
|
61
|
+
if (!(target instanceof Element) || !target.isConnected)
|
|
62
|
+
return;
|
|
63
|
+
firedUntil = Date.now() + AFTERMATH_MS;
|
|
64
|
+
window.addEventListener('click', swallowClick, true);
|
|
65
|
+
setTimeout(() => window.removeEventListener('click', swallowClick, true), AFTERMATH_MS);
|
|
66
|
+
dispatching = true;
|
|
67
|
+
try {
|
|
68
|
+
target.dispatchEvent(new MouseEvent('contextmenu', {
|
|
69
|
+
bubbles: true,
|
|
70
|
+
cancelable: true,
|
|
71
|
+
composed: true,
|
|
72
|
+
clientX: startX,
|
|
73
|
+
clientY: startY,
|
|
74
|
+
button: 2,
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
dispatching = false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const onDown = (event) => {
|
|
82
|
+
cancel();
|
|
83
|
+
if (event.pointerType === 'mouse' || !event.isPrimary)
|
|
84
|
+
return;
|
|
85
|
+
if (ignore && event.target instanceof Element && event.target.closest(ignore))
|
|
86
|
+
return;
|
|
87
|
+
pointerId = event.pointerId;
|
|
88
|
+
startX = event.clientX;
|
|
89
|
+
startY = event.clientY;
|
|
90
|
+
target = event.target;
|
|
91
|
+
timer = setTimeout(fire, delayMs);
|
|
92
|
+
};
|
|
93
|
+
const onMove = (event) => {
|
|
94
|
+
if (timer === undefined || event.pointerId !== pointerId)
|
|
95
|
+
return;
|
|
96
|
+
if (Math.hypot(event.clientX - startX, event.clientY - startY) > LONG_PRESS_SLOP_PX)
|
|
97
|
+
cancel();
|
|
98
|
+
};
|
|
99
|
+
const onEnd = (event) => {
|
|
100
|
+
if (event.pointerId === pointerId)
|
|
101
|
+
cancel();
|
|
102
|
+
};
|
|
103
|
+
const onNativeMenu = (event) => {
|
|
104
|
+
if (dispatching)
|
|
105
|
+
return;
|
|
106
|
+
if (timer !== undefined) {
|
|
107
|
+
cancel(); // the browser answered the hold itself — that is the one menu
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (Date.now() <= firedUntil) {
|
|
111
|
+
event.preventDefault();
|
|
112
|
+
event.stopImmediatePropagation();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const previousCallout = element.style.getPropertyValue('-webkit-touch-callout');
|
|
116
|
+
element.style.setProperty('-webkit-touch-callout', 'none');
|
|
117
|
+
element.addEventListener('pointerdown', onDown);
|
|
118
|
+
element.addEventListener('pointermove', onMove);
|
|
119
|
+
element.addEventListener('pointerup', onEnd);
|
|
120
|
+
element.addEventListener('pointercancel', onEnd);
|
|
121
|
+
element.addEventListener('contextmenu', onNativeMenu, true);
|
|
122
|
+
return () => {
|
|
123
|
+
cancel();
|
|
124
|
+
window.removeEventListener('click', swallowClick, true);
|
|
125
|
+
element.removeEventListener('pointerdown', onDown);
|
|
126
|
+
element.removeEventListener('pointermove', onMove);
|
|
127
|
+
element.removeEventListener('pointerup', onEnd);
|
|
128
|
+
element.removeEventListener('pointercancel', onEnd);
|
|
129
|
+
element.removeEventListener('contextmenu', onNativeMenu, true);
|
|
130
|
+
if (previousCallout)
|
|
131
|
+
element.style.setProperty('-webkit-touch-callout', previousCallout);
|
|
132
|
+
else
|
|
133
|
+
element.style.removeProperty('-webkit-touch-callout');
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
//# sourceMappingURL=longPressContextMenu.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"longPressContextMenu.js","sourceRoot":"","sources":["../../../src/react/hooks/longPressContextMenu.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/** {@link bindLongPressContextMenu} on a ref's element while `enabled`. */
|
|
3
|
+
export declare function useLongPressContextMenu(ref: RefObject<HTMLElement | null>, enabled?: boolean, ignore?: string): void;
|
|
4
|
+
//# sourceMappingURL=useLongPressContextMenu.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLongPressContextMenu.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useLongPressContextMenu.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAAa,MAAM,OAAO,CAAC;AAGlD,2EAA2E;AAC3E,wBAAgB,uBAAuB,CACrC,GAAG,EAAE,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,EAClC,OAAO,UAAO,EACd,MAAM,CAAC,EAAE,MAAM,GACd,IAAI,CAMN"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { bindLongPressContextMenu } from './longPressContextMenu.js';
|
|
3
|
+
/** {@link bindLongPressContextMenu} on a ref's element while `enabled`. */
|
|
4
|
+
export function useLongPressContextMenu(ref, enabled = true, ignore) {
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const element = ref.current;
|
|
7
|
+
if (!element || !enabled)
|
|
8
|
+
return;
|
|
9
|
+
return bindLongPressContextMenu(element, ignore ? { ignore } : {});
|
|
10
|
+
}, [ref, enabled, ignore]);
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=useLongPressContextMenu.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useLongPressContextMenu.js","sourceRoot":"","sources":["../../../src/react/hooks/useLongPressContextMenu.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,SAAS,EAAE,MAAM,OAAO,CAAC;AAClD,OAAO,EAAE,wBAAwB,EAAE,MAAM,2BAA2B,CAAC;AAErE,2EAA2E;AAC3E,MAAM,UAAU,uBAAuB,CACrC,GAAkC,EAClC,OAAO,GAAG,IAAI,EACd,MAAe;IAEf,SAAS,CAAC,GAAG,EAAE;QACb,MAAM,OAAO,GAAG,GAAG,CAAC,OAAO,CAAC;QAC5B,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO;YAAE,OAAO;QACjC,OAAO,wBAAwB,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACrE,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,CAAC,CAAC,CAAC;AAC7B,CAAC"}
|
|
@@ -27,6 +27,11 @@ import { type RefObject } from 'react';
|
|
|
27
27
|
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
28
28
|
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
29
29
|
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
30
|
+
* - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
|
|
31
|
+
* the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
|
|
32
|
+
* and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
|
|
33
|
+
* late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
|
|
34
|
+
* that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
|
|
30
35
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
36
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
37
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"useScrollMemory.d.ts","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,SAAS,EAA2B,MAAM,OAAO,CAAC;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,GAAE,mBAAwB,GAAG,IAAI,CA0BzF;AAwED,oGAAoG;AACpG,MAAM,MAAM,oBAAoB,GAAG,QAAQ,GAAG,SAAS,CAAC,WAAW,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,WAAW,GAAG,IAAI,CAAC,CAAC;AAEzG,MAAM,WAAW,mBAAmB;IAClC,mEAAmE;IACnE,QAAQ,CAAC,EAAE,oBAAoB,CAAC;IAChC;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmDD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAIzD"}
|
|
@@ -27,6 +27,11 @@ import { useLayoutEffect, useRef } from 'react';
|
|
|
27
27
|
* until {@link ScrollMemoryOptions.deadlineMs}. Keep the list's pages across the trip
|
|
28
28
|
* (`usePagedList` does, through the query cache) and it lands in the first frame.
|
|
29
29
|
* - **The viewer always wins**: a touch, wheel, key or pointer press while it waits cancels it.
|
|
30
|
+
* - **Landed means landed.** For {@link HOLD_MS} after the restore reaches its target, a scroll
|
|
31
|
+
* the viewer did not make is put back. 🔴 music's phone list came back from Lyrics on 12,343px
|
|
32
|
+
* and sat at 12,496px 800ms later, about one gate run in 17 under load (forge task 2784) — a
|
|
33
|
+
* late write by something on the page (a virtualizer settling, the browser's scroll anchoring)
|
|
34
|
+
* that no instrumented run could catch in the act. Whatever writes it, the viewer's place wins.
|
|
30
35
|
* - **A replaced URL is the same place**: when the location changes but the history entry does
|
|
31
36
|
* not (a filter written with `replaceState`), the scroll is left alone.
|
|
32
37
|
*
|
|
@@ -76,13 +81,17 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
76
81
|
// the partial offset on screen — leaving mid-restore (or StrictMode's rehearsal unmount) must
|
|
77
82
|
// not overwrite a deep position with a shallow one.
|
|
78
83
|
let offset = scroller.get();
|
|
79
|
-
|
|
84
|
+
/** True until the restore has landed or given up — while it is, `offset` stays the target. */
|
|
85
|
+
let restoring = false;
|
|
86
|
+
/** Stops the restore, or the hold after it, reporting nothing. */
|
|
87
|
+
let stopRestore = null;
|
|
80
88
|
if (!replacedInPlace) {
|
|
81
89
|
const target = recalled(id);
|
|
82
90
|
if (target > 0) {
|
|
83
91
|
offset = target;
|
|
84
|
-
|
|
85
|
-
|
|
92
|
+
restoring = true;
|
|
93
|
+
stopRestore = restore(scroller, target, deadlineMs, (outcome) => {
|
|
94
|
+
restoring = false;
|
|
86
95
|
if (outcome !== 'reached')
|
|
87
96
|
offset = scroller.get();
|
|
88
97
|
});
|
|
@@ -93,7 +102,7 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
93
102
|
}
|
|
94
103
|
}
|
|
95
104
|
const onScroll = () => {
|
|
96
|
-
if (!
|
|
105
|
+
if (!restoring)
|
|
97
106
|
offset = scroller.get();
|
|
98
107
|
};
|
|
99
108
|
const onHide = () => file(id, offset);
|
|
@@ -103,9 +112,9 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
103
112
|
location,
|
|
104
113
|
target: scroller.events,
|
|
105
114
|
end() {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
restoring = false;
|
|
116
|
+
stopRestore?.();
|
|
117
|
+
stopRestore = null;
|
|
109
118
|
scroller.events.removeEventListener('scroll', onScroll);
|
|
110
119
|
window.removeEventListener('pagehide', onHide);
|
|
111
120
|
file(id, offset);
|
|
@@ -113,6 +122,12 @@ function begin(scroller, location, last, deadlineMs) {
|
|
|
113
122
|
};
|
|
114
123
|
}
|
|
115
124
|
const RESTORE_DEADLINE_MS = 3_000;
|
|
125
|
+
/**
|
|
126
|
+
* How long a landed restore holds its target against a scroll the viewer did not make. Long
|
|
127
|
+
* enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
|
|
128
|
+
* load); short enough that an app's own jump a moment later is not undone.
|
|
129
|
+
*/
|
|
130
|
+
const HOLD_MS = 1_500;
|
|
116
131
|
const ENTRY_KEY = 'cbScrollEntry';
|
|
117
132
|
const STORE_KEY = 'cursedbelt:scroll-memory';
|
|
118
133
|
/** Entries kept in `sessionStorage` — a tab's history, not a cache; the oldest go first. */
|
|
@@ -195,39 +210,51 @@ function file(id, y) {
|
|
|
195
210
|
// Private mode, quota, blocked storage: memory still covers Back within this page load.
|
|
196
211
|
}
|
|
197
212
|
}
|
|
198
|
-
/**
|
|
213
|
+
/**
|
|
214
|
+
* Scroll to `target` once there is room for it, then HOLD it for {@link HOLD_MS}; returns a
|
|
215
|
+
* cancel that reports nothing. `settled` is called once, when it lands or gives up.
|
|
216
|
+
*/
|
|
199
217
|
function restore(scroller, target, deadlineMs, settled) {
|
|
200
218
|
const deadline = performance.now() + deadlineMs;
|
|
201
219
|
let frame = 0;
|
|
202
|
-
let
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
done = true;
|
|
220
|
+
let holdTimer;
|
|
221
|
+
let phase = 'waiting';
|
|
222
|
+
const finish = () => {
|
|
223
|
+
phase = 'done';
|
|
207
224
|
cancelAnimationFrame(frame);
|
|
225
|
+
clearTimeout(holdTimer);
|
|
208
226
|
for (const type of INPUT_EVENTS)
|
|
209
227
|
window.removeEventListener(type, onInput);
|
|
210
|
-
|
|
228
|
+
scroller.events.removeEventListener('scroll', onStray);
|
|
211
229
|
};
|
|
212
230
|
const onInput = () => {
|
|
213
|
-
|
|
231
|
+
const was = phase;
|
|
232
|
+
finish();
|
|
233
|
+
if (was === 'waiting')
|
|
214
234
|
settled('input');
|
|
215
235
|
};
|
|
236
|
+
// Landed, and something that is not the viewer moved it: put it back.
|
|
237
|
+
const onStray = () => {
|
|
238
|
+
if (phase === 'holding' && Math.abs(scroller.get() - target) > 1)
|
|
239
|
+
scroller.set(target);
|
|
240
|
+
};
|
|
216
241
|
const attempt = () => {
|
|
217
|
-
if (
|
|
242
|
+
if (phase !== 'waiting')
|
|
218
243
|
return;
|
|
219
244
|
const room = scroller.room();
|
|
220
245
|
if (room >= target) {
|
|
221
246
|
scroller.set(target);
|
|
222
|
-
|
|
223
|
-
|
|
247
|
+
phase = 'holding';
|
|
248
|
+
scroller.events.addEventListener('scroll', onStray, { passive: true });
|
|
249
|
+
holdTimer = setTimeout(finish, HOLD_MS);
|
|
250
|
+
settled('reached');
|
|
224
251
|
return;
|
|
225
252
|
}
|
|
226
253
|
// Short of it: go as far as the page allows — that is what asks an infinite list for more.
|
|
227
254
|
scroller.set(Math.max(0, room));
|
|
228
255
|
if (performance.now() >= deadline) {
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
finish();
|
|
257
|
+
settled('deadline');
|
|
231
258
|
}
|
|
232
259
|
else
|
|
233
260
|
frame = requestAnimationFrame(attempt);
|
|
@@ -235,8 +262,6 @@ function restore(scroller, target, deadlineMs, settled) {
|
|
|
235
262
|
for (const type of INPUT_EVENTS)
|
|
236
263
|
window.addEventListener(type, onInput, { passive: true });
|
|
237
264
|
attempt();
|
|
238
|
-
return
|
|
239
|
-
stop();
|
|
240
|
-
};
|
|
265
|
+
return finish;
|
|
241
266
|
}
|
|
242
267
|
//# sourceMappingURL=useScrollMemory.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"AAAA,OAAO,EAAkB,eAAe,EAAE,MAAM,EAAE,MAAM,OAAO,CAAC;AAEhE
|
|
1
|
+
{"version":3,"file":"useScrollMemory.js","sourceRoot":"","sources":["../../../src/react/hooks/useScrollMemory.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.56.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
|
|
@@ -38,6 +38,9 @@ const TREE: FileTreeNode[] = [
|
|
|
38
38
|
/** `SPRING_OPEN_MS` in `FileTree.tsx` — Finder's and Explorer's own figure. */
|
|
39
39
|
const SPRING = 600;
|
|
40
40
|
|
|
41
|
+
/** Past `LONG_PRESS_MS` (500) — a literal, so the spec stands without the binder. */
|
|
42
|
+
const HOLD = 550;
|
|
43
|
+
|
|
41
44
|
const tree = (props: Partial<React.ComponentProps<typeof FileTree>> = {}) =>
|
|
42
45
|
render(
|
|
43
46
|
<FileTree nodes={TREE} selectedId={null} onSelect={() => undefined} {...props} />,
|
|
@@ -1149,6 +1152,22 @@ describe('the keyboard, beyond walking', () => {
|
|
|
1149
1152
|
expect(opened).toEqual(['simpsons', 'simpsons/screencaps']);
|
|
1150
1153
|
});
|
|
1151
1154
|
|
|
1155
|
+
test('🔴 a finger HELD on a row opens its menu — iOS has no right-click', async () => {
|
|
1156
|
+
// Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make
|
|
1157
|
+
// the menus popup, we need to make holding down on those spots pop up the menu on iphone"*.
|
|
1158
|
+
const opened: Array<{ id: string; x: number }> = [];
|
|
1159
|
+
tree({ onRowMenu: (node, at) => opened.push({ id: node.id, x: at.x }) });
|
|
1160
|
+
fireEvent.pointerDown(screen.getByText('Dinos'), {
|
|
1161
|
+
pointerType: 'touch',
|
|
1162
|
+
pointerId: 9,
|
|
1163
|
+
isPrimary: true,
|
|
1164
|
+
clientX: 21,
|
|
1165
|
+
clientY: 4,
|
|
1166
|
+
});
|
|
1167
|
+
await new Promise((resolve) => setTimeout(resolve, HOLD));
|
|
1168
|
+
expect(opened).toEqual([{ id: 'dinos', x: 21 }]);
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1152
1171
|
test('🔴 the keyboard comes back from a rename instead of landing on <body>', () => {
|
|
1153
1172
|
// Committing unmounts the focused `<input>`, and a browser hands focus to `<body>` —
|
|
1154
1173
|
// which is not in the tree, so every key this component defines is gone until something
|
|
@@ -197,6 +197,7 @@ import {
|
|
|
197
197
|
import { useInView } from '../media/previewGate.js';
|
|
198
198
|
import { lazyPart } from '../components/DataTable/lazyPart.js';
|
|
199
199
|
import { applyFolderView, tagFilterActive } from './folderView.js';
|
|
200
|
+
import { useLongPressContextMenu } from '../hooks/useLongPressContextMenu.js';
|
|
200
201
|
import {
|
|
201
202
|
type FileTreeMenuItem,
|
|
202
203
|
FolderViewStrip,
|
|
@@ -1811,6 +1812,13 @@ export function FileTree({
|
|
|
1811
1812
|
|
|
1812
1813
|
/** Fetch the panels once the page is idle, if this tree has any menu to open. */
|
|
1813
1814
|
const hasMenus = wantsBlankMenu || Boolean(rowMenu);
|
|
1815
|
+
|
|
1816
|
+
/*
|
|
1817
|
+
* A finger held on a row or on the blank space opens the same menu a right-click does —
|
|
1818
|
+
* iOS has no right-click and never fires `contextmenu` for a hold (owner, 2026-10-04). The
|
|
1819
|
+
* grip is excluded: a finger held there is about to drag. See `longPressContextMenu.ts`.
|
|
1820
|
+
*/
|
|
1821
|
+
useLongPressContextMenu(scrollRef, hasMenus || Boolean(onRowMenu), '[data-cbft-grip]');
|
|
1814
1822
|
useEffect(() => {
|
|
1815
1823
|
if (!hasMenus) return;
|
|
1816
1824
|
const load = () => void TreePanel.preload().catch(() => {});
|
|
@@ -629,6 +629,20 @@
|
|
|
629
629
|
white-space: nowrap;
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
/*
|
|
633
|
+
* A finger held on a row opens its menu (`longPressContextMenu.ts`), so on a touch screen the
|
|
634
|
+
* hold must not ALSO start selecting the name under it. A coarse pointer only: a mouse may
|
|
635
|
+
* still drag-select a name to copy it.
|
|
636
|
+
*/
|
|
637
|
+
@media (pointer: coarse) {
|
|
638
|
+
.cbft-row,
|
|
639
|
+
.cbft-name,
|
|
640
|
+
.cbft-suffix {
|
|
641
|
+
-webkit-user-select: none;
|
|
642
|
+
user-select: none;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
632
646
|
/*
|
|
633
647
|
* A derived extension, a step quieter than the name — see `FileTreeNode.suffix`. Quieter
|
|
634
648
|
* because the name is what you scan FOR and the suffix is what you scan BY; at full
|
|
@@ -639,7 +639,7 @@ function SectionHeading({
|
|
|
639
639
|
}): ReactNode {
|
|
640
640
|
const ref = useHeaderContextMenu(onContextMenu);
|
|
641
641
|
return (
|
|
642
|
-
<div ref={ref} className='flex items-center justify-between gap-1 px-2 pt-1 pb-1'>
|
|
642
|
+
<div ref={ref} className='flex select-none items-center justify-between gap-1 px-2 pt-1 pb-1'>
|
|
643
643
|
<span className='truncate text-xs font-semibold uppercase tracking-wide text-muted-foreground'>
|
|
644
644
|
{label}
|
|
645
645
|
</span>
|
|
@@ -116,7 +116,7 @@ export function RailSection({
|
|
|
116
116
|
if (!collapsible)
|
|
117
117
|
return (
|
|
118
118
|
<section aria-labelledby={headingId} className={className} data-cb-rail-section='static'>
|
|
119
|
-
<div ref={headerRef} className={cn('flex items-center justify-between gap-1 px-2 pt-1 pb-1', headerClassName)}>
|
|
119
|
+
<div ref={headerRef} className={cn('flex select-none items-center justify-between gap-1 px-2 pt-1 pb-1', headerClassName)}>
|
|
120
120
|
<h2 className={cn('m-0', heading)} id={headingId}>
|
|
121
121
|
{label}
|
|
122
122
|
</h2>
|
|
@@ -131,7 +131,7 @@ export function RailSection({
|
|
|
131
131
|
// a sidebar would pay for its presence machinery in its first chunk.
|
|
132
132
|
return (
|
|
133
133
|
<section aria-labelledby={headingId} className={className} data-cb-rail-section={open ? 'open' : 'closed'}>
|
|
134
|
-
<div ref={headerRef} className={cn('flex items-center gap-1 pt-1 pb-1', headerClassName)}>
|
|
134
|
+
<div ref={headerRef} className={cn('flex select-none items-center gap-1 pt-1 pb-1', headerClassName)}>
|
|
135
135
|
<h2 className='m-0 flex min-w-0 flex-1'>
|
|
136
136
|
<button
|
|
137
137
|
// guardrails-ignore no-raw-action-button: the section's heading IS its toggle — a
|
|
@@ -9,6 +9,9 @@ import { RailSection } from './RailSection.js';
|
|
|
9
9
|
* bar of the file tree"*). Without the prop, the browser's menu stays.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
/** Past `LONG_PRESS_MS` (500) — a literal, so the spec stands without the binder. */
|
|
13
|
+
const HOLD = 550;
|
|
14
|
+
|
|
12
15
|
afterEach(cleanup);
|
|
13
16
|
|
|
14
17
|
const heading = (text: string) => screen.getByText(text).closest('div') as HTMLElement;
|
|
@@ -60,3 +63,52 @@ describe('a section heading\'s right-click', () => {
|
|
|
60
63
|
expect(fireEvent.contextMenu(heading('Files'))).toBe(true);
|
|
61
64
|
});
|
|
62
65
|
});
|
|
66
|
+
|
|
67
|
+
describe('a section heading held by a finger', () => {
|
|
68
|
+
test('🔴 a touch held on the heading opens its menu — iOS never fires contextmenu for a hold', async () => {
|
|
69
|
+
// Owner, 2026-10-04: *"on mobile I cannot hold control and click like I can on mac to make
|
|
70
|
+
// the menus popup"*. Collections' "Files" heading is where hidden folders are shown again.
|
|
71
|
+
const seen: number[] = [];
|
|
72
|
+
render(
|
|
73
|
+
<FilterRail
|
|
74
|
+
label='Library'
|
|
75
|
+
selected={null}
|
|
76
|
+
onSelect={() => undefined}
|
|
77
|
+
sections={[
|
|
78
|
+
{
|
|
79
|
+
id: 'files',
|
|
80
|
+
label: 'Files',
|
|
81
|
+
onHeaderContextMenu: (event) => {
|
|
82
|
+
event.preventDefault();
|
|
83
|
+
seen.push(event.clientX);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
]}
|
|
87
|
+
/>,
|
|
88
|
+
);
|
|
89
|
+
// The binder is a lazy chunk (forge task 2791) — let it land, as it does long before a hold.
|
|
90
|
+
await import('../hooks/longPressContextMenu.js');
|
|
91
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
92
|
+
fireEvent.pointerDown(screen.getByText('Files'), {
|
|
93
|
+
pointerType: 'touch',
|
|
94
|
+
pointerId: 3,
|
|
95
|
+
isPrimary: true,
|
|
96
|
+
clientX: 17,
|
|
97
|
+
});
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, HOLD));
|
|
99
|
+
expect(seen).toEqual([17]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('the hold binder stays off the entry', () => {
|
|
104
|
+
test('🔴 headerContextMenu imports longPressContextMenu lazily, never statically', async () => {
|
|
105
|
+
// Every rail loads this hook in its app's entry; a static import put the binder on roms's
|
|
106
|
+
// entry (+0.59 kB gzip) for a menu roms never passes (forge task 2791).
|
|
107
|
+
const source = await Bun.file(new URL('./headerContextMenu.ts', import.meta.url)).text();
|
|
108
|
+
expect(source).not.toMatch(/^import[^;]*longPressContextMenu/m);
|
|
109
|
+
expect(source).toContain("import('../hooks/longPressContextMenu.js')");
|
|
110
|
+
// …and the binder imports nothing at runtime, or its chunk's preload map lands in the entry.
|
|
111
|
+
const binder = await Bun.file(new URL('../hooks/longPressContextMenu.ts', import.meta.url)).text();
|
|
112
|
+
expect(binder).not.toMatch(/^import (?!type )/m);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
* heading is a label, not a control, and giving it a role to hang a handler on would announce
|
|
12
12
|
* it to a screen reader as something to press. The menu it opens is also reachable from the
|
|
13
13
|
* tree itself (its blank space, and Shift+F10 on a row), so nothing is pointer-only.
|
|
14
|
+
*
|
|
15
|
+
* A finger HELD on the heading opens it too — iOS has no right-click (owner, 2026-10-04: *"on
|
|
16
|
+
* mobile I cannot hold control and click … make holding down on those spots pop up the menu"*).
|
|
17
|
+
* See `longPressContextMenu.ts`.
|
|
18
|
+
*
|
|
19
|
+
* 🔴 The binder is IMPORTED LAZILY, only once a heading has a menu: every app with a rail loads
|
|
20
|
+
* this hook in its entry, and most (roms's library) never pass a handler — a static import put
|
|
21
|
+
* 0.59 kB gzip on roms's entry for a menu it does not have (forge task 2791). A hold takes
|
|
22
|
+
* 500ms, so the chunk is in long before a finger could need it.
|
|
14
23
|
*/
|
|
15
24
|
import { useEffect, useRef } from 'react';
|
|
16
25
|
|
|
@@ -31,7 +40,16 @@ export function useHeaderContextMenu(handler: HeaderContextMenuHandler | undefin
|
|
|
31
40
|
if (!el || !wanted) return;
|
|
32
41
|
const listener = (event: MouseEvent) => latest.current?.(event);
|
|
33
42
|
el.addEventListener('contextmenu', listener);
|
|
34
|
-
|
|
43
|
+
let unbindHold: (() => void) | undefined;
|
|
44
|
+
let gone = false;
|
|
45
|
+
void import('../hooks/longPressContextMenu.js').then(({ bindLongPressContextMenu }) => {
|
|
46
|
+
if (!gone) unbindHold = bindLongPressContextMenu(el);
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
gone = true;
|
|
50
|
+
el.removeEventListener('contextmenu', listener);
|
|
51
|
+
unbindHold?.();
|
|
52
|
+
};
|
|
35
53
|
}, [wanted]);
|
|
36
54
|
return ref;
|
|
37
55
|
}
|