cursedbelt 5.7.3 → 5.9.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 +11 -1
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +4 -4
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/filter-rail/FilterRail.d.ts +7 -1
- package/dist/react/filter-rail/FilterRail.d.ts.map +1 -1
- package/dist/react/filter-rail/FilterRail.js +6 -2
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- package/dist/react/filter-rail/index.d.ts +1 -0
- package/dist/react/filter-rail/index.d.ts.map +1 -1
- package/dist/react/filter-rail/index.js +1 -0
- package/dist/react/filter-rail/index.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +63 -0
- package/dist/react/filter-rail/railResize.d.ts.map +1 -0
- package/dist/react/filter-rail/railResize.js +79 -0
- package/dist/react/filter-rail/railResize.js.map +1 -0
- package/dist/react/filter-rail/railWidth.d.ts +11 -0
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -0
- package/dist/react/filter-rail/railWidth.js +48 -0
- package/dist/react/filter-rail/railWidth.js.map +1 -0
- package/dist/react/media/VirtualMediaGrid.d.ts +3 -0
- package/dist/react/media/VirtualMediaGrid.d.ts.map +1 -1
- package/dist/react/media/VirtualMediaGrid.js +3 -0
- package/dist/react/media/VirtualMediaGrid.js.map +1 -1
- package/dist/react/media/stageResize.d.ts.map +1 -1
- package/dist/react/media/stageResize.js +2 -0
- package/dist/react/media/stageResize.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +11 -10
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/index.d.ts +3 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +4 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.d.ts +7 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +22 -2
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/preferenceBus.d.ts +11 -0
- package/dist/react/media-gallery/preferenceBus.d.ts.map +1 -0
- package/dist/react/media-gallery/preferenceBus.js +20 -0
- package/dist/react/media-gallery/preferenceBus.js.map +1 -0
- package/dist/react/media-gallery/preferenceSync.d.ts +11 -0
- package/dist/react/media-gallery/preferenceSync.d.ts.map +1 -0
- package/dist/react/media-gallery/preferenceSync.js +64 -0
- package/dist/react/media-gallery/preferenceSync.js.map +1 -0
- package/dist/styles-areas/filter-rail.css +2 -2
- package/package.json +1 -1
- package/src/react/file-tree/FileTree.spec.tsx +19 -0
- package/src/react/file-tree/FileTree.tsx +20 -1
- package/src/react/filter-rail/FilterRail.spec.tsx +45 -0
- package/src/react/filter-rail/FilterRail.tsx +16 -1
- package/src/react/filter-rail/index.ts +1 -0
- package/src/react/filter-rail/railResize.spec.tsx +92 -0
- package/src/react/filter-rail/railResize.tsx +156 -0
- package/src/react/filter-rail/railWidth.ts +47 -0
- package/src/react/media/VirtualMediaGrid.tsx +4 -0
- package/src/react/media/stageResize.ts +3 -0
- package/src/react/media-gallery/MediaGallery.tsx +25 -2
- package/src/react/media-gallery/index.ts +4 -0
- package/src/react/media-gallery/playbackPreferences.ts +26 -2
- package/src/react/media-gallery/preferenceBus.ts +28 -0
- package/src/react/media-gallery/preferenceSync.spec.ts +74 -0
- package/src/react/media-gallery/preferenceSync.ts +60 -0
- package/src/styles-areas/filter-rail.css +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"playbackPreferences.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/playbackPreferences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;
|
|
1
|
+
{"version":3,"file":"playbackPreferences.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/playbackPreferences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAIH,wDAAwD;AACxD,MAAM,MAAM,mBAAmB;AAC7B,8EAA8E;AAC5E,MAAM;AACR,oCAAoC;GAClC,QAAQ;AACV,sFAAsF;GACpF,MAAM;AACR,wFAAwF;GACtF,KAAK;AACP,2FAA2F;GACzF,SAAS,CAAC;AAEd,eAAO,MAAM,sBAAsB,EAAE,mBAAmB,EAMvD,CAAC;AAEF,oEAAoE;AACpE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CACtC,mBAAmB,EACnB;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAmB/C,CAAC;AAIF;;;;;;GAMG;AACH,wBAAgB,eAAe,IAAI,mBAAmB,CASrD;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI,CAOpE;AAID;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,uCAAwC,CAAC;AAEzE,4FAA4F;AAC5F,eAAO,MAAM,mBAAmB,IAAI,CAAC;AAIrC;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,IAAI,MAAM,CAQvC;AAED,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAOrD;AAMD;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,IAAI,OAAO,CAMtC;AAED,wBAAgB,aAAa,CAAC,MAAM,EAAE,OAAO,GAAG,IAAI,CAOnD;AAMD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,iBAAiB,IAAI,OAAO,CAM3C;AAED,wBAAgB,kBAAkB,CAAC,EAAE,EAAE,OAAO,GAAG,IAAI,CAOpD;AAED,uEAAuE;AACvE,eAAO,MAAM,eAAe,GAAI,SAAS,mBAAmB,KAAG,mBAGlD,CAAC;AAEd;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,eAAe,GAAG,MAAM,GAAG,OAAO,CAAC;AAI/C,wBAAgB,YAAY,IAAI,eAAe,CAM9C;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAOzD;AAID;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,MAAM,WAAW,aAAa;IAC5B,2DAA2D;IAC3D,MAAM,EAAE,MAAM,CAAC;IACf,6CAA6C;IAC7C,KAAK,EAAE,OAAO,CAAC;CAChB;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,aAA0C,CAAC;AAEhF;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB,gBAAgB,CAAC;AAE/C,2FAA2F;AAC3F,eAAO,MAAM,gBAAgB,sBAAsB,CAAC;AAKpD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,aAAa,GAAG,IAAI,CAKnE;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,IAAI,CAK1E;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,GAAG,aAAa,CAS3F;AAED,6FAA6F;AAC7F,eAAO,MAAM,kBAAkB,GAAI,KAAK,MAAM,KAAG,OACM,CAAC"}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* The advancing rules live in {@link ./playbackRun}, which is pure and tested; this module
|
|
29
29
|
* is only the value, its labels and its storage.
|
|
30
30
|
*/
|
|
31
|
+
import { announcePreference } from './preferenceBus.js';
|
|
31
32
|
export const PLAYBACK_END_BEHAVIORS = [
|
|
32
33
|
'stop',
|
|
33
34
|
'repeat',
|
|
@@ -77,6 +78,7 @@ export function readPlaybackEnd() {
|
|
|
77
78
|
export function writePlaybackEnd(behavior) {
|
|
78
79
|
try {
|
|
79
80
|
globalThis.localStorage?.setItem(STORAGE_KEY, behavior);
|
|
81
|
+
announcePreference(STORAGE_KEY, behavior);
|
|
80
82
|
}
|
|
81
83
|
catch {
|
|
82
84
|
// A preference that cannot be remembered is still a preference that works this session.
|
|
@@ -121,6 +123,7 @@ export function readImageDwell() {
|
|
|
121
123
|
export function writeImageDwell(seconds) {
|
|
122
124
|
try {
|
|
123
125
|
globalThis.localStorage?.setItem(DWELL_KEY, String(seconds));
|
|
126
|
+
announcePreference(DWELL_KEY, String(seconds));
|
|
124
127
|
}
|
|
125
128
|
catch {
|
|
126
129
|
// Unremembered is still usable this session.
|
|
@@ -150,6 +153,7 @@ export function readPinStage() {
|
|
|
150
153
|
export function writePinStage(pinned) {
|
|
151
154
|
try {
|
|
152
155
|
globalThis.localStorage?.setItem(PIN_KEY, pinned ? '1' : '0');
|
|
156
|
+
announcePreference(PIN_KEY, pinned ? '1' : '0');
|
|
153
157
|
}
|
|
154
158
|
catch {
|
|
155
159
|
// Unremembered is still usable this session.
|
|
@@ -186,6 +190,7 @@ export function readThumbAutoplay() {
|
|
|
186
190
|
export function writeThumbAutoplay(on) {
|
|
187
191
|
try {
|
|
188
192
|
globalThis.localStorage?.setItem(THUMB_AUTOPLAY_KEY, on ? '1' : '0');
|
|
193
|
+
announcePreference(THUMB_AUTOPLAY_KEY, on ? '1' : '0');
|
|
189
194
|
}
|
|
190
195
|
catch {
|
|
191
196
|
// Unremembered is still usable this session.
|
|
@@ -205,6 +210,7 @@ export function readViewMode() {
|
|
|
205
210
|
export function writeViewMode(mode) {
|
|
206
211
|
try {
|
|
207
212
|
globalThis.localStorage?.setItem(VIEW_MODE_KEY, mode);
|
|
213
|
+
announcePreference(VIEW_MODE_KEY, mode);
|
|
208
214
|
}
|
|
209
215
|
catch {
|
|
210
216
|
// Unremembered is still usable this session.
|
|
@@ -245,11 +251,25 @@ export function readSessionVolume(key) {
|
|
|
245
251
|
}
|
|
246
252
|
/** Remember it for the rest of this PAGE. Never longer — see {@link SessionVolume}. */
|
|
247
253
|
export function writeSessionVolume(key, value) {
|
|
248
|
-
|
|
254
|
+
const held = writeSessionVolumeSilently(key, value);
|
|
255
|
+
// Announced for an app that keeps it longer than the page — see `preferenceSync.ts`. This
|
|
256
|
+
// module still never does; the owner's 09-14 rule holds for every app that does not opt in.
|
|
257
|
+
announcePreference(key, JSON.stringify(held));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The same write without the announcement — what `seedPreferences` uses to put a stored level
|
|
261
|
+
* back, so restoring it is never reported as the person changing it.
|
|
262
|
+
*/
|
|
263
|
+
export function writeSessionVolumeSilently(key, value) {
|
|
264
|
+
const held = {
|
|
249
265
|
// Clamped here rather than at every call site: this lands on `HTMLMediaElement.volume`,
|
|
250
266
|
// which throws `IndexSizeError` outside 0–1.
|
|
251
267
|
volume: Math.min(1, Math.max(0, value.volume)),
|
|
252
268
|
muted: value.muted,
|
|
253
|
-
}
|
|
269
|
+
};
|
|
270
|
+
sessionVolumes.set(key, held);
|
|
271
|
+
return { ...held };
|
|
254
272
|
}
|
|
273
|
+
/** Is `key` one of the in-memory session volumes rather than a `localStorage` preference? */
|
|
274
|
+
export const isSessionVolumeKey = (key) => key === STAGE_VOLUME_KEY || key === PLAYER_VOLUME_KEY;
|
|
255
275
|
//# sourceMappingURL=playbackPreferences.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"playbackPreferences.js","sourceRoot":"","sources":["../../../src/react/media-gallery/playbackPreferences.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;
|
|
1
|
+
{"version":3,"file":"playbackPreferences.js","sourceRoot":"","sources":["../../../src/react/media-gallery/playbackPreferences.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The listener set behind `preferenceSync.ts`, in a module of its own so that the writers
|
|
3
|
+
* (`playbackPreferences.ts`, `media/stageResize.ts`) and the seeder can both import it
|
|
4
|
+
* without importing each other. See `preferenceSync.ts` for what it is for.
|
|
5
|
+
*/
|
|
6
|
+
export type PreferenceListener = (key: string, value: string | null) => void;
|
|
7
|
+
/** Hear every preference this package remembers. Returns the unsubscribe. */
|
|
8
|
+
export declare function onPreferenceWrite(listener: PreferenceListener): () => void;
|
|
9
|
+
/** Called by each writer in this package — not part of the public surface. */
|
|
10
|
+
export declare function announcePreference(key: string, value: string | null): void;
|
|
11
|
+
//# sourceMappingURL=preferenceBus.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferenceBus.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceBus.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,MAAM,kBAAkB,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,KAAK,IAAI,CAAC;AAI7E,6EAA6E;AAC7E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,kBAAkB,GAAG,MAAM,IAAI,CAK1E;AAED,8EAA8E;AAC9E,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI,CAQ1E"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const listeners = new Set();
|
|
2
|
+
/** Hear every preference this package remembers. Returns the unsubscribe. */
|
|
3
|
+
export function onPreferenceWrite(listener) {
|
|
4
|
+
listeners.add(listener);
|
|
5
|
+
return () => {
|
|
6
|
+
listeners.delete(listener);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Called by each writer in this package — not part of the public surface. */
|
|
10
|
+
export function announcePreference(key, value) {
|
|
11
|
+
for (const listener of listeners) {
|
|
12
|
+
try {
|
|
13
|
+
listener(key, value);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// A listener that throws must not take a volume slider down with it.
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
//# sourceMappingURL=preferenceBus.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferenceBus.js","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceBus.ts"],"names":[],"mappings":"AAOA,MAAM,SAAS,GAAG,IAAI,GAAG,EAAsB,CAAC;AAEhD,6EAA6E;AAC7E,MAAM,UAAU,iBAAiB,CAAC,QAA4B;IAC5D,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IACxB,OAAO,GAAG,EAAE;QACV,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC7B,CAAC,CAAC;AACJ,CAAC;AAED,8EAA8E;AAC9E,MAAM,UAAU,kBAAkB,CAAC,GAAW,EAAE,KAAoB;IAClE,KAAK,MAAM,QAAQ,IAAI,SAAS,EAAE,CAAC;QACjC,IAAI,CAAC;YACH,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACvB,CAAC;QAAC,MAAM,CAAC;YACP,qEAAqE;QACvE,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { onPreferenceWrite, type PreferenceListener } from './preferenceBus.js';
|
|
2
|
+
/**
|
|
3
|
+
* Put stored values back where this package reads them from, without announcing them.
|
|
4
|
+
*
|
|
5
|
+
* A session-volume key goes into page memory (where every player reads its opening level);
|
|
6
|
+
* everything else goes into `localStorage` under the same key its reader uses. `null`
|
|
7
|
+
* forgets. Wrapped for the reason every storage access here is — `localStorage` throws in a
|
|
8
|
+
* sandboxed iframe and Safari's private mode.
|
|
9
|
+
*/
|
|
10
|
+
export declare function seedPreferences(values: Readonly<Record<string, string | null>>): void;
|
|
11
|
+
//# sourceMappingURL=preferenceSync.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferenceSync.d.ts","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceSync.ts"],"names":[],"mappings":"AA6BA,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAEhF;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC,GAAG,IAAI,CAoBrF"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A viewer preference that follows the PERSON, not the browser — the seam an app opts into.
|
|
3
|
+
*
|
|
4
|
+
* Every preference in this package is remembered on one device: `localStorage` for ways of
|
|
5
|
+
* working (the stage height, preview-all, the end behaviour), page memory for loudness
|
|
6
|
+
* (`playbackPreferences.ts` → `SessionVolume`). That is the right default for a shared
|
|
7
|
+
* component, because only the APP knows who is signed in and where it could keep a value.
|
|
8
|
+
*
|
|
9
|
+
* Owner, collections, 2026-09-21: *"remember my settings in the db and utilize them when I
|
|
10
|
+
* sign back in on any device … It should use debouncing for settings that may change a lot."*
|
|
11
|
+
*
|
|
12
|
+
* So this module is two verbs and nothing else:
|
|
13
|
+
*
|
|
14
|
+
* · {@link onPreferenceWrite} — every writer in this package announces what it just
|
|
15
|
+
* remembered, as `(key, value)`. An app that wants server-side memory listens, filters
|
|
16
|
+
* the keys it cares about, and debounces its own writes (a volume slider announces on
|
|
17
|
+
* every tick; the debounce is the app's, because the app owns the request).
|
|
18
|
+
* · {@link seedPreferences} — the app hands back what it stored, once it knows who is
|
|
19
|
+
* signed in, and each value lands in the SAME place the package reads it from. Seeding
|
|
20
|
+
* announces nothing, so a restore never echoes back to the server as a change.
|
|
21
|
+
*
|
|
22
|
+
* 🔴 An app that never calls either behaves exactly as before — nothing here writes on its
|
|
23
|
+
* own, and a listener set with no listeners is a loop over nothing.
|
|
24
|
+
*
|
|
25
|
+
* Values are strings (or `null` for "forget it"), because that is what the underlying stores
|
|
26
|
+
* hold; a session volume travels as its JSON, `{"volume":0.4,"muted":false}`.
|
|
27
|
+
*/
|
|
28
|
+
import { isSessionVolumeKey, writeSessionVolumeSilently } from './playbackPreferences.js';
|
|
29
|
+
export { onPreferenceWrite } from './preferenceBus.js';
|
|
30
|
+
/**
|
|
31
|
+
* Put stored values back where this package reads them from, without announcing them.
|
|
32
|
+
*
|
|
33
|
+
* A session-volume key goes into page memory (where every player reads its opening level);
|
|
34
|
+
* everything else goes into `localStorage` under the same key its reader uses. `null`
|
|
35
|
+
* forgets. Wrapped for the reason every storage access here is — `localStorage` throws in a
|
|
36
|
+
* sandboxed iframe and Safari's private mode.
|
|
37
|
+
*/
|
|
38
|
+
export function seedPreferences(values) {
|
|
39
|
+
for (const [key, value] of Object.entries(values)) {
|
|
40
|
+
if (isSessionVolumeKey(key)) {
|
|
41
|
+
if (value === null)
|
|
42
|
+
continue;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(value);
|
|
45
|
+
if (typeof parsed.volume === 'number' && typeof parsed.muted === 'boolean')
|
|
46
|
+
writeSessionVolumeSilently(key, { volume: parsed.volume, muted: parsed.muted });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// A malformed stored volume is no stored volume.
|
|
50
|
+
}
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if (value === null)
|
|
55
|
+
globalThis.localStorage?.removeItem(key);
|
|
56
|
+
else
|
|
57
|
+
globalThis.localStorage?.setItem(key, value);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Unremembered is still usable this session.
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
//# sourceMappingURL=preferenceSync.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"preferenceSync.js","sourceRoot":"","sources":["../../../src/react/media-gallery/preferenceSync.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,kBAAkB,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AAE1F,OAAO,EAAE,iBAAiB,EAA2B,MAAM,oBAAoB,CAAC;AAEhF;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAAC,MAA+C;IAC7E,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;QAClD,IAAI,kBAAkB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC5B,IAAI,KAAK,KAAK,IAAI;gBAAE,SAAS;YAC7B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAA0C,CAAC;gBAC1E,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,SAAS;oBACxE,0BAA0B,CAAC,GAAG,EAAE,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC;YACpF,CAAC;YAAC,MAAM,CAAC;gBACP,iDAAiD;YACnD,CAAC;YACD,SAAS;QACX,CAAC;QACD,IAAI,CAAC;YACH,IAAI,KAAK,KAAK,IAAI;gBAAE,UAAU,CAAC,YAAY,EAAE,UAAU,CAAC,GAAG,CAAC,CAAC;;gBACxD,UAAU,CAAC,YAAY,EAAE,OAAO,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QACpD,CAAC;QAAC,MAAM,CAAC;YACP,6CAA6C;QAC/C,CAAC;IACH,CAAC;AACH,CAAC"}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* after an app's responsive variant (task 2124). An app that imports every area gets exactly
|
|
19
19
|
* what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
|
|
20
20
|
* src/stylesAreas.spec.ts, which is also what fails when this file is stale.
|
|
21
|
-
*
|
|
21
|
+
* 33 candidate(s).
|
|
22
22
|
*
|
|
23
23
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
24
24
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -30,4 +30,4 @@
|
|
|
30
30
|
@source inline("opacity-100 outline-2 outline-accent outline-dashed outline-offset-[-2px] pb-1");
|
|
31
31
|
@source inline("place-items-center pointer-coarse:[&_button]:min-h-11 pointer-coarse:[&_button]:min-w-11");
|
|
32
32
|
@source inline("pointer-coarse:[&_button]:size-11 pt-1 self-stretch shadow-[inset_3px_0_0_0_var(--accent)] w-14");
|
|
33
|
-
@source inline("w-56");
|
|
33
|
+
@source inline("w-3 w-56");
|
package/package.json
CHANGED
|
@@ -106,6 +106,25 @@ describe('drawing', () => {
|
|
|
106
106
|
expect(img.getAttribute('alt')).toBe('');
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
test('a turned picture is drawn turned — `thumbnailTransform`', () => {
|
|
110
|
+
// Owner, collections, 2026-09-21: a video rotated and saved should show that rotation on
|
|
111
|
+
// its thumbnail too. Absent for every other row, so nothing else changes.
|
|
112
|
+
const { container } = render(
|
|
113
|
+
<FileTree
|
|
114
|
+
nodes={[
|
|
115
|
+
{ id: 'v', name: 'sideways.mov', kind: 'file', thumbnailUrl: '/t/v.jpg' },
|
|
116
|
+
{ id: 'p', name: 'upright.jpg', kind: 'file', thumbnailUrl: '/t/p.jpg' },
|
|
117
|
+
]}
|
|
118
|
+
selectedId={null}
|
|
119
|
+
onSelect={() => undefined}
|
|
120
|
+
thumbnailTransform={(node) => (node.id === 'v' ? 'rotate(90deg)' : undefined)}
|
|
121
|
+
/>,
|
|
122
|
+
);
|
|
123
|
+
const [turned, plain] = [...container.querySelectorAll('img.cbft-thumb')] as HTMLImageElement[];
|
|
124
|
+
expect(turned?.style.transform).toBe('rotate(90deg)');
|
|
125
|
+
expect(plain?.getAttribute('style')).toBeNull();
|
|
126
|
+
});
|
|
127
|
+
|
|
109
128
|
test('🔴 a thumbnail that 404s falls back to the type icon, ONCE, and never retries', () => {
|
|
110
129
|
// A derived thumbnail's absence is the normal "not rendered yet" state, not an error —
|
|
111
130
|
// so the honest picture is the type icon, never a broken-image glyph. Re-pointing `src`
|
|
@@ -543,6 +543,16 @@ export interface FileTreeProps {
|
|
|
543
543
|
renderTools?: (node: FileTreeNode) => ReactNode;
|
|
544
544
|
/** Leading glyph. Defaults to a folder/file mark. */
|
|
545
545
|
renderIcon?: (node: FileTreeNode) => ReactNode;
|
|
546
|
+
/**
|
|
547
|
+
* A CSS transform for a row's thumbnail — how the app says a picture is stored turned.
|
|
548
|
+
*
|
|
549
|
+
* Owner, collections, 2026-09-21: *"when I rotate and save a video it should use the same
|
|
550
|
+
* rotation choice for the thumbnail"*. The grid tile already turns by `orientation`; a row's
|
|
551
|
+
* glyph showing the same film sideways beside it is one file drawn two ways. A prop rather
|
|
552
|
+
* than a node field because the node shape is `cursedbelt-core`'s and a turn is a rendering
|
|
553
|
+
* fact. The glyph is square and `object-fit: cover`, so a turn needs no rescale.
|
|
554
|
+
*/
|
|
555
|
+
thumbnailTransform?: (node: FileTreeNode) => string | undefined;
|
|
546
556
|
/** Extra content after the name — a lock glyph, a chip. */
|
|
547
557
|
renderAccessory?: (node: FileTreeNode) => ReactNode;
|
|
548
558
|
|
|
@@ -660,10 +670,12 @@ function RowGlyph({
|
|
|
660
670
|
node,
|
|
661
671
|
open,
|
|
662
672
|
renderIcon,
|
|
673
|
+
transform,
|
|
663
674
|
}: {
|
|
664
675
|
node: FileTreeNode;
|
|
665
676
|
open: boolean;
|
|
666
677
|
renderIcon?: (node: FileTreeNode) => ReactNode;
|
|
678
|
+
transform?: string | undefined;
|
|
667
679
|
}): ReactNode {
|
|
668
680
|
const [failed, setFailed] = useState<string | null>(null);
|
|
669
681
|
const icon = renderIcon ? renderIcon(node) : <DefaultIcon node={node} open={open} />;
|
|
@@ -687,6 +699,7 @@ function RowGlyph({
|
|
|
687
699
|
decoding='async'
|
|
688
700
|
draggable={false}
|
|
689
701
|
onError={() => setFailed(src)}
|
|
702
|
+
{...(transform ? { style: { transform } } : {})}
|
|
690
703
|
/>
|
|
691
704
|
);
|
|
692
705
|
}
|
|
@@ -729,6 +742,7 @@ export function FileTree({
|
|
|
729
742
|
onRowMenu,
|
|
730
743
|
renderTools,
|
|
731
744
|
renderIcon,
|
|
745
|
+
thumbnailTransform,
|
|
732
746
|
renderAccessory,
|
|
733
747
|
onDropExternal,
|
|
734
748
|
acceptsExternalDrag,
|
|
@@ -2333,7 +2347,12 @@ export function FileTree({
|
|
|
2333
2347
|
twisty with `expandable: false`, which every PINNED copy does.
|
|
2334
2348
|
*/
|
|
2335
2349
|
<span className='cbft-slot' aria-hidden='true'>
|
|
2336
|
-
<RowGlyph
|
|
2350
|
+
<RowGlyph
|
|
2351
|
+
node={node}
|
|
2352
|
+
open={row.expanded}
|
|
2353
|
+
{...(renderIcon ? { renderIcon } : {})}
|
|
2354
|
+
transform={thumbnailTransform?.(node)}
|
|
2355
|
+
/>
|
|
2337
2356
|
</span>
|
|
2338
2357
|
)}
|
|
2339
2358
|
|
|
@@ -547,3 +547,48 @@ describe('FilterRail — tree rows', () => {
|
|
|
547
547
|
expect(container.querySelector('[data-cb-rail-row-depth]')).toBeNull();
|
|
548
548
|
});
|
|
549
549
|
});
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 🔴 Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
553
|
+
* dragging to resize them like in vscode."* The drag itself needs layout happy-dom does not
|
|
554
|
+
* have; the keyboard path runs the same clamp and the same write, so it is what proves the
|
|
555
|
+
* width moves, is remembered, and resets.
|
|
556
|
+
*/
|
|
557
|
+
describe('FilterRail — resizeKey', () => {
|
|
558
|
+
afterEach(() => globalThis.localStorage?.clear());
|
|
559
|
+
const props = { sections: SECTIONS, selected: null, onSelect: () => {}, label: 'Folders' };
|
|
560
|
+
|
|
561
|
+
test('absent by default — no handle, the fixed width class', () => {
|
|
562
|
+
const { container } = render(<FilterRail {...props} />);
|
|
563
|
+
expect(container.querySelector('[data-cb-rail-resize]')).toBeNull();
|
|
564
|
+
expect(container.querySelector('nav')?.className).toContain('w-56');
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
test('→ widens it and remembers the width; a double-click puts it back', () => {
|
|
568
|
+
globalThis.localStorage?.setItem('test:rail', '300');
|
|
569
|
+
const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
570
|
+
const nav = container.querySelector('nav') as HTMLElement;
|
|
571
|
+
expect(nav.style.width).toBe('300px');
|
|
572
|
+
const handle = container.querySelector('[data-cb-rail-resize]') as HTMLElement;
|
|
573
|
+
fireEvent.keyDown(handle, { key: 'ArrowRight' });
|
|
574
|
+
expect(nav.style.width).toBe('316px');
|
|
575
|
+
expect(globalThis.localStorage?.getItem('test:rail')).toBe('316');
|
|
576
|
+
fireEvent.doubleClick(handle);
|
|
577
|
+
expect(nav.style.width).toBe('');
|
|
578
|
+
expect(nav.className).toContain('w-56');
|
|
579
|
+
expect(globalThis.localStorage?.getItem('test:rail')).toBeNull();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test('the width is clamped — it cannot be dragged into a sliver', () => {
|
|
583
|
+
globalThis.localStorage?.setItem('test:rail', '20');
|
|
584
|
+
const { container } = render(<FilterRail {...props} resizeKey='test:rail' />);
|
|
585
|
+
expect((container.querySelector('nav') as HTMLElement).style.width).toBe('160px');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test('no handle while collapsed', () => {
|
|
589
|
+
const { container } = render(
|
|
590
|
+
<FilterRail {...props} resizeKey='test:rail' collapsed onCollapsedChange={() => {}} />,
|
|
591
|
+
);
|
|
592
|
+
expect(container.querySelector('[data-cb-rail-resize]')).toBeNull();
|
|
593
|
+
});
|
|
594
|
+
});
|
|
@@ -4,6 +4,7 @@ import { ScrollArea } from '../components/ScrollArea.js';
|
|
|
4
4
|
import { cn } from '../lib/cn.js';
|
|
5
5
|
import { Tooltip } from '../components/Tooltip.js';
|
|
6
6
|
import { TAP_TARGET, TAP_TARGET_ALWAYS, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
7
|
+
import { RailResizeHandle, useRailResize } from './railResize.js';
|
|
7
8
|
|
|
8
9
|
export interface FilterRailItem {
|
|
9
10
|
/** Stable key, and the value handed to `onSelect`. */
|
|
@@ -180,6 +181,12 @@ export interface FilterRailProps {
|
|
|
180
181
|
footer?: ReactNode;
|
|
181
182
|
/** Expanded width. Default `w-56` (14rem). */
|
|
182
183
|
width?: string;
|
|
184
|
+
/**
|
|
185
|
+
* Let the person drag the rail's right edge to resize it, like VS Code's side bar, and
|
|
186
|
+
* remember the width under this key. Absent: a fixed `width`, exactly as before.
|
|
187
|
+
* ←/→ nudge it from the keyboard; a double-click puts it back to `width`. See `railWidth.ts`.
|
|
188
|
+
*/
|
|
189
|
+
resizeKey?: string;
|
|
183
190
|
className?: string;
|
|
184
191
|
}
|
|
185
192
|
|
|
@@ -238,18 +245,26 @@ export function FilterRail({
|
|
|
238
245
|
header,
|
|
239
246
|
footer,
|
|
240
247
|
width = 'w-56',
|
|
248
|
+
resizeKey,
|
|
241
249
|
className,
|
|
242
250
|
}: FilterRailProps) {
|
|
251
|
+
// The drag, the keys, the clamp, the storage and the reset are `railResize.tsx`, shared with
|
|
252
|
+
// every sidebar that is not a FilterRail (music's LibraryRail).
|
|
253
|
+
const rail = useRailResize({ resizeKey, label, enabled: !collapsed });
|
|
243
254
|
return (
|
|
244
255
|
<nav
|
|
256
|
+
ref={rail.railRef}
|
|
245
257
|
aria-label={label}
|
|
246
258
|
data-cb-filter-rail={collapsed ? 'collapsed' : 'expanded'}
|
|
247
259
|
className={cn(
|
|
248
260
|
'flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface',
|
|
249
|
-
collapsed ? 'w-14' : width,
|
|
261
|
+
collapsed ? 'w-14' : rail.width !== null ? undefined : width,
|
|
262
|
+
rail.active && 'relative',
|
|
250
263
|
className,
|
|
251
264
|
)}
|
|
265
|
+
{...(rail.style ? { style: rail.style } : {})}
|
|
252
266
|
>
|
|
267
|
+
<RailResizeHandle rail={rail} />
|
|
253
268
|
{onCollapsedChange ? (
|
|
254
269
|
<div className={cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center')}>
|
|
255
270
|
{!collapsed && <span className='flex-1' />}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { cleanup, fireEvent, render } from '@testing-library/react';
|
|
3
|
+
import { RailResizeHandle, useRailResize } from './railResize.js';
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
cleanup();
|
|
7
|
+
globalThis.localStorage?.clear();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A sidebar that is NOT a FilterRail — music's `LibraryRail` shape: its own element, its own
|
|
12
|
+
* default width in CSS, the shared handle inside. This is the seam every such rail passes
|
|
13
|
+
* through, so it is where the gesture is proved.
|
|
14
|
+
*/
|
|
15
|
+
function Sidebar({ resizeKey, enabled }: { resizeKey?: string; enabled?: boolean }) {
|
|
16
|
+
const rail = useRailResize<HTMLElement>({ resizeKey, label: 'Library', enabled });
|
|
17
|
+
return (
|
|
18
|
+
<aside ref={rail.railRef} data-width={rail.width ?? 'default'} style={rail.style} className='relative'>
|
|
19
|
+
<RailResizeHandle rail={rail} />
|
|
20
|
+
</aside>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const parts = (container: HTMLElement) => ({
|
|
25
|
+
aside: container.querySelector('aside') as HTMLElement,
|
|
26
|
+
handle: container.querySelector('[data-cb-rail-resize]') as HTMLElement | null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('useRailResize + RailResizeHandle', () => {
|
|
30
|
+
test('no key — no handle, no width', () => {
|
|
31
|
+
const { aside, handle } = parts(render(<Sidebar />).container);
|
|
32
|
+
expect(handle).toBeNull();
|
|
33
|
+
expect(aside.style.width).toBe('');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a pointer drag widens by the distance moved and is remembered on release, not before', () => {
|
|
37
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
38
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
39
|
+
expect(aside.style.width).toBe('300px');
|
|
40
|
+
// happy-dom lays nothing out, so the drag starts from a measured 0 — the delta is the point.
|
|
41
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 0, clientX: 100, pointerId: 1 });
|
|
42
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 350, pointerId: 1 });
|
|
43
|
+
expect(aside.style.width).toBe('250px');
|
|
44
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('300');
|
|
45
|
+
fireEvent.pointerUp(handle as HTMLElement, { clientX: 350, pointerId: 1 });
|
|
46
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('250');
|
|
47
|
+
// Released: further moves do nothing.
|
|
48
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 600, pointerId: 1 });
|
|
49
|
+
expect(aside.style.width).toBe('250px');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a drag past either bound stops at it', () => {
|
|
53
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
54
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 0, clientX: 0, pointerId: 1 });
|
|
55
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 5000, pointerId: 1 });
|
|
56
|
+
expect(aside.style.width).toBe('640px');
|
|
57
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 10, pointerId: 1 });
|
|
58
|
+
expect(aside.style.width).toBe('160px');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('a right-button press is not a drag', () => {
|
|
62
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
63
|
+
fireEvent.pointerDown(handle as HTMLElement, { button: 2, clientX: 0, pointerId: 1 });
|
|
64
|
+
fireEvent.pointerMove(handle as HTMLElement, { clientX: 400, pointerId: 1 });
|
|
65
|
+
expect(aside.style.width).toBe('');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('←/→ step it (Shift for more), announce it, and a double-click forgets it', () => {
|
|
69
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
70
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' />).container);
|
|
71
|
+
const h = handle as HTMLElement;
|
|
72
|
+
expect(h.getAttribute('role')).toBe('separator');
|
|
73
|
+
expect(h.getAttribute('aria-label')).toBe('Resize Library');
|
|
74
|
+
fireEvent.keyDown(h, { key: 'ArrowLeft' });
|
|
75
|
+
expect(aside.style.width).toBe('284px');
|
|
76
|
+
fireEvent.keyDown(h, { key: 'ArrowRight', shiftKey: true });
|
|
77
|
+
expect(aside.style.width).toBe('348px');
|
|
78
|
+
expect(h.getAttribute('aria-valuenow')).toBe('348');
|
|
79
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBe('348');
|
|
80
|
+
fireEvent.doubleClick(h);
|
|
81
|
+
expect(aside.style.width).toBe('');
|
|
82
|
+
expect(aside.dataset.width).toBe('default');
|
|
83
|
+
expect(globalThis.localStorage?.getItem('t:rail')).toBeNull();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('disabled (a collapsed rail) — no handle, and the stored width is not applied', () => {
|
|
87
|
+
globalThis.localStorage?.setItem('t:rail', '300');
|
|
88
|
+
const { aside, handle } = parts(render(<Sidebar resizeKey='t:rail' enabled={false} />).container);
|
|
89
|
+
expect(handle).toBeNull();
|
|
90
|
+
expect(aside.style.width).toBe('');
|
|
91
|
+
});
|
|
92
|
+
});
|