cursedbelt 5.7.2 → 5.8.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 +53 -2
- package/dist/react/filter-rail/FilterRail.js.map +1 -1
- 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 +2 -2
- 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 +85 -2
- 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
|
@@ -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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.8.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.",
|
|
@@ -693,7 +693,7 @@
|
|
|
693
693
|
"@vidstack/react": "1.15.6",
|
|
694
694
|
"@vitejs/plugin-react": "^5.2.0",
|
|
695
695
|
"cursedbelt-server": "^4.13.0",
|
|
696
|
-
"cursedops": "^0.
|
|
696
|
+
"cursedops": "^0.8.1",
|
|
697
697
|
"diff": "^9.0.0",
|
|
698
698
|
"emoji-mart": "^5.6.0",
|
|
699
699
|
"hls.js": "^1.5.0",
|
|
@@ -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
|
+
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { ChevronsLeft, ChevronsRight } from 'lucide-react';
|
|
2
|
-
import type { DragEvent, ReactNode } from 'react';
|
|
2
|
+
import type { DragEvent, PointerEvent as ReactPointerEvent, ReactNode } from 'react';
|
|
3
|
+
import { useCallback, useRef, useState } from 'react';
|
|
3
4
|
import { ScrollArea } from '../components/ScrollArea.js';
|
|
4
5
|
import { cn } from '../lib/cn.js';
|
|
5
6
|
import { Tooltip } from '../components/Tooltip.js';
|
|
6
7
|
import { TAP_TARGET, TAP_TARGET_ALWAYS, TAP_TARGET_HEIGHT } from '../lib/tapTarget.js';
|
|
8
|
+
import { clampRailWidth, MAX_RAIL_PX, MIN_RAIL_PX, readRailWidth, writeRailWidth } from './railWidth.js';
|
|
7
9
|
|
|
8
10
|
export interface FilterRailItem {
|
|
9
11
|
/** Stable key, and the value handed to `onSelect`. */
|
|
@@ -180,6 +182,12 @@ export interface FilterRailProps {
|
|
|
180
182
|
footer?: ReactNode;
|
|
181
183
|
/** Expanded width. Default `w-56` (14rem). */
|
|
182
184
|
width?: string;
|
|
185
|
+
/**
|
|
186
|
+
* Let the person drag the rail's right edge to resize it, like VS Code's side bar, and
|
|
187
|
+
* remember the width under this key. Absent: a fixed `width`, exactly as before.
|
|
188
|
+
* ←/→ nudge it from the keyboard; a double-click puts it back to `width`. See `railWidth.ts`.
|
|
189
|
+
*/
|
|
190
|
+
resizeKey?: string;
|
|
183
191
|
className?: string;
|
|
184
192
|
}
|
|
185
193
|
|
|
@@ -238,18 +246,93 @@ export function FilterRail({
|
|
|
238
246
|
header,
|
|
239
247
|
footer,
|
|
240
248
|
width = 'w-56',
|
|
249
|
+
resizeKey,
|
|
241
250
|
className,
|
|
242
251
|
}: FilterRailProps) {
|
|
252
|
+
const navRef = useRef<HTMLElement | null>(null);
|
|
253
|
+
const [dragged, setDragged] = useState<number | null>(() =>
|
|
254
|
+
resizeKey ? readRailWidth(resizeKey) : null,
|
|
255
|
+
);
|
|
256
|
+
const resizing = Boolean(resizeKey) && !collapsed;
|
|
257
|
+
const onResizePointerDown = useCallback(
|
|
258
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
259
|
+
if (!resizeKey || event.button !== 0) return;
|
|
260
|
+
const handle = event.currentTarget;
|
|
261
|
+
const startX = event.clientX;
|
|
262
|
+
const startWidth = navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX;
|
|
263
|
+
let latest = startWidth;
|
|
264
|
+
handle.setPointerCapture?.(event.pointerId);
|
|
265
|
+
const move = (moveEvent: PointerEvent) => {
|
|
266
|
+
latest = clampRailWidth(startWidth + moveEvent.clientX - startX);
|
|
267
|
+
setDragged(latest);
|
|
268
|
+
};
|
|
269
|
+
const end = () => {
|
|
270
|
+
handle.releasePointerCapture?.(event.pointerId);
|
|
271
|
+
handle.removeEventListener('pointermove', move);
|
|
272
|
+
handle.removeEventListener('pointerup', end);
|
|
273
|
+
handle.removeEventListener('pointercancel', end);
|
|
274
|
+
// Remembered on RELEASE — a drag is one decision, not sixty a second.
|
|
275
|
+
writeRailWidth(resizeKey, latest);
|
|
276
|
+
};
|
|
277
|
+
handle.addEventListener('pointermove', move);
|
|
278
|
+
handle.addEventListener('pointerup', end);
|
|
279
|
+
handle.addEventListener('pointercancel', end);
|
|
280
|
+
event.preventDefault();
|
|
281
|
+
},
|
|
282
|
+
[resizeKey],
|
|
283
|
+
);
|
|
284
|
+
const onResizeKey = useCallback(
|
|
285
|
+
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
286
|
+
if (!resizeKey) return;
|
|
287
|
+
const step = event.shiftKey ? 64 : 16;
|
|
288
|
+
const delta = event.key === 'ArrowRight' ? step : event.key === 'ArrowLeft' ? -step : 0;
|
|
289
|
+
if (!delta) return;
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
const next = clampRailWidth(
|
|
292
|
+
(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX) + delta,
|
|
293
|
+
);
|
|
294
|
+
setDragged(next);
|
|
295
|
+
writeRailWidth(resizeKey, next);
|
|
296
|
+
},
|
|
297
|
+
[resizeKey, dragged],
|
|
298
|
+
);
|
|
243
299
|
return (
|
|
244
300
|
<nav
|
|
301
|
+
ref={navRef}
|
|
245
302
|
aria-label={label}
|
|
246
303
|
data-cb-filter-rail={collapsed ? 'collapsed' : 'expanded'}
|
|
247
304
|
className={cn(
|
|
248
305
|
'flex h-full min-h-0 shrink-0 flex-col border-r border-border bg-surface',
|
|
249
|
-
collapsed ? 'w-14' : width,
|
|
306
|
+
collapsed ? 'w-14' : dragged !== null && resizing ? undefined : width,
|
|
307
|
+
resizing && 'relative',
|
|
250
308
|
className,
|
|
251
309
|
)}
|
|
310
|
+
{...(resizing && dragged !== null ? { style: { width: dragged } } : {})}
|
|
252
311
|
>
|
|
312
|
+
{resizing ? (
|
|
313
|
+
// biome-ignore lint/a11y/useSemanticElements: an <hr> cannot take focus or a drag; a focusable separator is the ARIA splitter pattern.
|
|
314
|
+
<div
|
|
315
|
+
role='separator'
|
|
316
|
+
aria-orientation='vertical'
|
|
317
|
+
aria-label={`Resize ${label}`}
|
|
318
|
+
aria-valuemin={MIN_RAIL_PX}
|
|
319
|
+
aria-valuemax={MAX_RAIL_PX}
|
|
320
|
+
aria-valuenow={Math.round(dragged ?? navRef.current?.getBoundingClientRect().width ?? MIN_RAIL_PX)}
|
|
321
|
+
tabIndex={0}
|
|
322
|
+
data-cb-rail-resize=''
|
|
323
|
+
onPointerDown={onResizePointerDown}
|
|
324
|
+
onKeyDown={onResizeKey}
|
|
325
|
+
onDoubleClick={() => {
|
|
326
|
+
setDragged(null);
|
|
327
|
+
if (resizeKey) writeRailWidth(resizeKey, null);
|
|
328
|
+
}}
|
|
329
|
+
// A 1px edge you cannot hit is not a handle: the hotspot is 12px, centred on the
|
|
330
|
+
// border, and only the hairline tints — no layout shift, like `PanelSeparator`.
|
|
331
|
+
// Only utilities the stylesheet already ships, so the handle adds no CSS to any app
|
|
332
|
+
// (`fixtureAppCss.spec.ts` budgets that).
|
|
333
|
+
className='absolute inset-y-0 -right-1.5 z-20 w-3 cursor-col-resize touch-none bg-transparent transition-colors hover:bg-accent/20 active:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
|
334
|
+
/>
|
|
335
|
+
) : null}
|
|
253
336
|
{onCollapsedChange ? (
|
|
254
337
|
<div className={cn('flex flex-none items-center px-2 pt-2', collapsed && 'justify-center')}>
|
|
255
338
|
{!collapsed && <span className='flex-1' />}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide the person dragged a sidebar — the pure half, so the clamp is testable without a
|
|
3
|
+
* pointer.
|
|
4
|
+
*
|
|
5
|
+
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
6
|
+
* dragging to resize them like in vscode … in collections app I should be able to drag it to
|
|
7
|
+
* the right to see the full file names or shrink it by moving it to the left."*
|
|
8
|
+
*
|
|
9
|
+
* Remembered under a key the APP names (`FilterRail`'s `resizeKey`), for the reason
|
|
10
|
+
* `media/stageResize.ts` keys its heights: two rails on one origin must not resize each other.
|
|
11
|
+
* Announced through `preferenceSync`, so an app that keeps preferences on its server carries
|
|
12
|
+
* the width to every device like the rest of them.
|
|
13
|
+
*/
|
|
14
|
+
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
15
|
+
|
|
16
|
+
/** Narrower than this and a row is an icon and an ellipsis. */
|
|
17
|
+
export const MIN_RAIL_PX = 160;
|
|
18
|
+
/** Wider than this and the rail is eating the page it navigates. */
|
|
19
|
+
export const MAX_RAIL_PX = 640;
|
|
20
|
+
|
|
21
|
+
export const clampRailWidth = (px: number): number =>
|
|
22
|
+
Math.round(Math.min(MAX_RAIL_PX, Math.max(MIN_RAIL_PX, px)));
|
|
23
|
+
|
|
24
|
+
/** The remembered width, or null for "the rail's default". Wrapped: `localStorage` throws in
|
|
25
|
+
* a sandboxed iframe and in Safari's private mode. */
|
|
26
|
+
export function readRailWidth(key: string): number | null {
|
|
27
|
+
try {
|
|
28
|
+
const raw = globalThis.localStorage?.getItem(key);
|
|
29
|
+
if (raw == null) return null;
|
|
30
|
+
const stored = Number(raw);
|
|
31
|
+
return Number.isFinite(stored) && stored > 0 ? clampRailWidth(stored) : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Remember it, or forget it (`null` — the double-click reset). */
|
|
38
|
+
export function writeRailWidth(key: string, width: number | null): void {
|
|
39
|
+
const value = width === null ? null : String(clampRailWidth(width));
|
|
40
|
+
try {
|
|
41
|
+
if (value === null) globalThis.localStorage?.removeItem(key);
|
|
42
|
+
else globalThis.localStorage?.setItem(key, value);
|
|
43
|
+
} catch {
|
|
44
|
+
// A width that cannot be remembered is still a width that works this session.
|
|
45
|
+
}
|
|
46
|
+
announcePreference(key, value);
|
|
47
|
+
}
|
|
@@ -21,6 +21,10 @@ import { VirtualCardGrid, type VirtualCardGridProps } from '../VirtualCardGrid.j
|
|
|
21
21
|
*/
|
|
22
22
|
export const CBGD_META_HEIGHT = 67;
|
|
23
23
|
|
|
24
|
+
/** A tile with its caption turned off (`MediaGallery`'s `tileCaptions={false}`): only the
|
|
25
|
+
* photo tile's 1px border top and bottom remain below the media box. */
|
|
26
|
+
export const CBGD_BARE_TILE_HEIGHT = 2;
|
|
27
|
+
|
|
24
28
|
export interface VirtualMediaGridProps
|
|
25
29
|
extends Omit<VirtualCardGridProps, 'aspect' | 'cardExtraHeight'> {
|
|
26
30
|
/** Media aspect ratio (height/width). Default 9/16 (landscape). */
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
* thumbnails underneath" stays literally true at every point of the drag.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
19
|
+
|
|
18
20
|
/** Below this the stage is a letterbox sliver — no picture, all chrome. */
|
|
19
21
|
export const MIN_STAGE_PX = 180;
|
|
20
22
|
|
|
@@ -83,6 +85,7 @@ export function writeStageHeight(key: string, height: number | null): void {
|
|
|
83
85
|
try {
|
|
84
86
|
if (height === null) globalThis.localStorage?.removeItem(key);
|
|
85
87
|
else globalThis.localStorage?.setItem(key, String(Math.round(height)));
|
|
88
|
+
announcePreference(key, height === null ? null : String(Math.round(height)));
|
|
86
89
|
} catch {
|
|
87
90
|
// A size that cannot be remembered is still a size that works this session.
|
|
88
91
|
}
|
|
@@ -76,6 +76,7 @@ import {
|
|
|
76
76
|
useCheckboxSelection,
|
|
77
77
|
} from '../hooks/useCheckboxSelection.js';
|
|
78
78
|
import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell.js';
|
|
79
|
+
import { CBGD_BARE_TILE_HEIGHT } from '../media/VirtualMediaGrid.js';
|
|
79
80
|
import { useHlsSource } from '../media/hlsSource.js';
|
|
80
81
|
import {
|
|
81
82
|
isRecoverableFault,
|
|
@@ -651,6 +652,7 @@ function GalleryTile({
|
|
|
651
652
|
autoPreview,
|
|
652
653
|
measure,
|
|
653
654
|
stageMark,
|
|
655
|
+
caption = true,
|
|
654
656
|
}: {
|
|
655
657
|
item: MediaGalleryItem;
|
|
656
658
|
cache: MediaCache;
|
|
@@ -663,6 +665,8 @@ function GalleryTile({
|
|
|
663
665
|
measure?: TileMeasure;
|
|
664
666
|
/** See {@link TileShell}'s `stageMark` — the label for the tile that is ON the stage. */
|
|
665
667
|
stageMark?: string;
|
|
668
|
+
/** Draw the name/size/date under the picture — see {@link MediaGalleryProps.tileCaptions}. */
|
|
669
|
+
caption?: boolean;
|
|
666
670
|
}) {
|
|
667
671
|
/*
|
|
668
672
|
* 🔴 ALWAYS wrapped, even with no selection and no drag.
|
|
@@ -686,6 +690,7 @@ function GalleryTile({
|
|
|
686
690
|
onOpen={onOpen}
|
|
687
691
|
autoPreview={autoPreview}
|
|
688
692
|
measure={measure}
|
|
693
|
+
caption={caption}
|
|
689
694
|
/>
|
|
690
695
|
</TileShell>
|
|
691
696
|
);
|
|
@@ -757,12 +762,14 @@ function RawGalleryTile({
|
|
|
757
762
|
onOpen,
|
|
758
763
|
autoPreview,
|
|
759
764
|
measure,
|
|
765
|
+
caption = true,
|
|
760
766
|
}: {
|
|
761
767
|
item: MediaGalleryItem;
|
|
762
768
|
cache: MediaCache;
|
|
763
769
|
onOpen: () => void;
|
|
764
770
|
autoPreview?: boolean;
|
|
765
771
|
measure?: TileMeasure;
|
|
772
|
+
caption?: boolean;
|
|
766
773
|
}) {
|
|
767
774
|
const category = galleryItemCategory(item);
|
|
768
775
|
const { media } = useItemMedia(item, cache);
|
|
@@ -788,7 +795,7 @@ function RawGalleryTile({
|
|
|
788
795
|
// whose detail view is upright, which reads as the fix not having worked.
|
|
789
796
|
{...(item.orientation ? { orientation: item.orientation } : {})}
|
|
790
797
|
>
|
|
791
|
-
<GalleryMeta item={item} measure={measure} />
|
|
798
|
+
{caption ? <GalleryMeta item={item} measure={measure} /> : null}
|
|
792
799
|
</HoverScrubVideoThumb>
|
|
793
800
|
);
|
|
794
801
|
}
|
|
@@ -835,7 +842,7 @@ function RawGalleryTile({
|
|
|
835
842
|
/>
|
|
836
843
|
</div>
|
|
837
844
|
)}
|
|
838
|
-
<GalleryMeta item={item} measure={measure} />
|
|
845
|
+
{caption ? <GalleryMeta item={item} measure={measure} /> : null}
|
|
839
846
|
</button>
|
|
840
847
|
);
|
|
841
848
|
}
|
|
@@ -1505,6 +1512,17 @@ export interface MediaGalleryProps {
|
|
|
1505
1512
|
previewAll?: boolean;
|
|
1506
1513
|
/** Told when the app-owned toggle changes — see {@link MediaGalleryProps.previewAll}. */
|
|
1507
1514
|
onPreviewAllChange?: (next: boolean) => void;
|
|
1515
|
+
/**
|
|
1516
|
+
* The name, size and date under every tile. `false` leaves the picture alone on the card, and
|
|
1517
|
+
* the row shrinks by exactly the caption's height, so more of the grid fits on screen.
|
|
1518
|
+
*
|
|
1519
|
+
* Owner, collections, 2026-09-21: *"I want a way to see more of the media files sometimes …
|
|
1520
|
+
* add a button to toggle hiding and showing all the text under the media gallery images."*
|
|
1521
|
+
* CONTROLLED by the app for the same reason `previewAll` is — the button lives in the app's
|
|
1522
|
+
* own chrome. The name is still the tile's accessible label, so nothing is lost to a reader.
|
|
1523
|
+
* Default `true`: every existing consumer keeps its captions.
|
|
1524
|
+
*/
|
|
1525
|
+
tileCaptions?: boolean;
|
|
1508
1526
|
/** Whether there is anything to preview, so an app's own button can hide itself the way the
|
|
1509
1527
|
* header pill does — a toggle that provably does nothing is worse than an absent one. */
|
|
1510
1528
|
onPreviewableChange?: (hasVideo: boolean) => void;
|
|
@@ -1799,6 +1817,7 @@ export function MediaGallery({
|
|
|
1799
1817
|
headerExtra,
|
|
1800
1818
|
chrome = 'header',
|
|
1801
1819
|
previewAll,
|
|
1820
|
+
tileCaptions = true,
|
|
1802
1821
|
onPreviewAllChange,
|
|
1803
1822
|
onPreviewableChange,
|
|
1804
1823
|
viewMode: controlledViewMode,
|
|
@@ -2577,6 +2596,7 @@ export function MediaGallery({
|
|
|
2577
2596
|
<>
|
|
2578
2597
|
{capabilityDialogs}
|
|
2579
2598
|
<DenseGalleryDetail
|
|
2599
|
+
{...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
|
|
2580
2600
|
fullStage={fullStage}
|
|
2581
2601
|
onFullStageChange={setFullStage}
|
|
2582
2602
|
/* 🔴 A VIDEO enters browser fullscreen from its own player, so the frame must not also
|
|
@@ -3119,6 +3139,7 @@ export function MediaGallery({
|
|
|
3119
3139
|
// file is the same grid, and a tile that told you its length on one screen and
|
|
3120
3140
|
// its size on the other would be two tiles.
|
|
3121
3141
|
measure={meta?.tileMeasure || undefined}
|
|
3142
|
+
caption={tileCaptions}
|
|
3122
3143
|
// The one tile the stage is showing. Clicking it re-selects what is already
|
|
3123
3144
|
// open, which `select` makes a no-op change — it stays a live tile rather than
|
|
3124
3145
|
// a dead one, because a tile you cannot press reads as broken.
|
|
@@ -3141,6 +3162,7 @@ export function MediaGallery({
|
|
|
3141
3162
|
about separately. */}
|
|
3142
3163
|
{capabilityDialogs}
|
|
3143
3164
|
<DenseGalleryShell<MediaGalleryOrder>
|
|
3165
|
+
{...(tileCaptions ? {} : { rowExtraHeight: CBGD_BARE_TILE_HEIGHT })}
|
|
3144
3166
|
title={title}
|
|
3145
3167
|
order={{
|
|
3146
3168
|
value: order,
|
|
@@ -3317,6 +3339,7 @@ export function MediaGallery({
|
|
|
3317
3339
|
drag={tileDrag(item)}
|
|
3318
3340
|
autoPreview={thumbAutoplay}
|
|
3319
3341
|
measure={meta?.tileMeasure || undefined}
|
|
3342
|
+
caption={tileCaptions}
|
|
3320
3343
|
/>
|
|
3321
3344
|
) : null;
|
|
3322
3345
|
}}
|
|
@@ -59,6 +59,10 @@ export {
|
|
|
59
59
|
writeThumbAutoplay,
|
|
60
60
|
writeViewMode,
|
|
61
61
|
} from './playbackPreferences.js';
|
|
62
|
+
// Server-side memory for the above, for an app that opts in — see `preferenceSync.ts`.
|
|
63
|
+
export { onPreferenceWrite, seedPreferences } from './preferenceSync.js';
|
|
64
|
+
export { STAGE_VOLUME_KEY } from './playbackPreferences.js';
|
|
65
|
+
export { FOCUS_HEIGHT_KEY, STAGE_HEIGHT_KEY } from '../media/stageResize.js';
|
|
62
66
|
// The run algebra — pure, so an app that drives its own player (music's queue, roms' reel)
|
|
63
67
|
// can share the shuffle bag and the wrapping stepper without mounting the gallery.
|
|
64
68
|
export {
|