cursedbelt 5.96.0 → 5.97.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/components/AppShell.js +6 -1
- package/dist/react/components/AppShell.js.map +1 -1
- package/dist/react/components/ConfirmDialog.d.ts.map +1 -1
- package/dist/react/components/ConfirmDialog.js +1 -1
- package/dist/react/components/ConfirmDialog.js.map +1 -1
- package/dist/react/components/PaneSplit.d.ts +11 -2
- package/dist/react/components/PaneSplit.d.ts.map +1 -1
- package/dist/react/components/PaneSplit.js +12 -11
- package/dist/react/components/PaneSplit.js.map +1 -1
- package/dist/react/components/PanelGrid.d.ts +14 -8
- package/dist/react/components/PanelGrid.d.ts.map +1 -1
- package/dist/react/components/PanelGrid.js +10 -3
- package/dist/react/components/PanelGrid.js.map +1 -1
- package/dist/react/components/SplitPane.d.ts +3 -9
- package/dist/react/components/SplitPane.d.ts.map +1 -1
- package/dist/react/components/SplitPane.js +0 -3
- package/dist/react/components/SplitPane.js.map +1 -1
- package/dist/react/components/TagsEditor.d.ts +15 -0
- package/dist/react/components/TagsEditor.d.ts.map +1 -0
- package/dist/react/components/TagsEditor.js +93 -0
- package/dist/react/components/TagsEditor.js.map +1 -0
- package/dist/react/components/TagsPromptDialog.d.ts +25 -0
- package/dist/react/components/TagsPromptDialog.d.ts.map +1 -0
- package/dist/react/components/TagsPromptDialog.js +38 -0
- package/dist/react/components/TagsPromptDialog.js.map +1 -0
- package/dist/react/components/paneSplit.css +8 -0
- package/dist/react/file-tree/FolderViewMenus.d.ts.map +1 -1
- package/dist/react/file-tree/FolderViewMenus.js +2 -0
- package/dist/react/file-tree/FolderViewMenus.js.map +1 -1
- package/dist/react/file-tree/useFolderView.d.ts +11 -1
- package/dist/react/file-tree/useFolderView.d.ts.map +1 -1
- package/dist/react/file-tree/useFolderView.js +3 -2
- package/dist/react/file-tree/useFolderView.js.map +1 -1
- package/dist/react/filter-rail/railResize.d.ts +16 -5
- package/dist/react/filter-rail/railResize.d.ts.map +1 -1
- package/dist/react/filter-rail/railResize.js +27 -9
- package/dist/react/filter-rail/railResize.js.map +1 -1
- package/dist/react/filter-rail/railWidth.d.ts +10 -5
- package/dist/react/filter-rail/railWidth.d.ts.map +1 -1
- package/dist/react/filter-rail/railWidth.js +13 -8
- package/dist/react/filter-rail/railWidth.js.map +1 -1
- package/dist/react/hooks/longPressContextMenu.d.ts +10 -1
- package/dist/react/hooks/longPressContextMenu.d.ts.map +1 -1
- package/dist/react/hooks/longPressContextMenu.js +37 -3
- package/dist/react/hooks/longPressContextMenu.js.map +1 -1
- package/dist/react/index.d.ts +4 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/paneReach.d.ts +36 -0
- package/dist/react/lib/paneReach.d.ts.map +1 -0
- package/dist/react/lib/paneReach.js +72 -0
- package/dist/react/lib/paneReach.js.map +1 -0
- package/dist/react/lib/tagList.d.ts +16 -0
- package/dist/react/lib/tagList.d.ts.map +1 -0
- package/dist/react/lib/tagList.js +30 -0
- package/dist/react/lib/tagList.js.map +1 -0
- package/dist/react/master-detail/MasterDetail.d.ts +6 -3
- package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
- package/dist/react/master-detail/MasterDetail.js +3 -2
- package/dist/react/master-detail/MasterDetail.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts +8 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +10 -1
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +3 -1
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +10 -5
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +3 -1
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +14 -6
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +3 -1
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +14 -6
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +3 -1
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +13 -5
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +27 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +39 -0
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/videoEnhance.d.ts +1 -1
- package/dist/react/media/videoEnhance.js +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +4 -4
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/tagsPrompt.d.ts +15 -0
- package/dist/react/tagsPrompt.d.ts.map +1 -0
- package/dist/react/tagsPrompt.js +30 -0
- package/dist/react/tagsPrompt.js.map +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/fields.css +4 -4
- package/dist/styles-areas/overlays.css +3 -2
- package/dist/styles-utilities.css +7 -0
- package/dist/testing/phoneRules.js +3 -3
- package/dist/testing/phoneRules.js.map +1 -1
- package/dist/theme.css +13 -0
- package/package.json +8 -1
- package/scripts/guardrailsEnforce.spec.ts +25 -0
- package/scripts/guardrailsEnforce.ts +61 -0
- package/scripts/styleAreas.ts +5 -0
- package/src/looks.spec.ts +34 -0
- package/src/react/components/AppShell.spec.tsx +23 -0
- package/src/react/components/AppShell.tsx +6 -1
- package/src/react/components/ConfirmDialog.spec.tsx +41 -0
- package/src/react/components/ConfirmDialog.tsx +12 -2
- package/src/react/components/PaneSplit.spec.tsx +26 -3
- package/src/react/components/PaneSplit.tsx +26 -13
- package/src/react/components/PanelGrid.tsx +16 -11
- package/src/react/components/SplitPane.spec.tsx +10 -5
- package/src/react/components/SplitPane.tsx +3 -12
- package/src/react/components/TagsEditor.tsx +140 -0
- package/src/react/components/TagsPromptDialog.spec.tsx +74 -0
- package/src/react/components/TagsPromptDialog.tsx +108 -0
- package/src/react/components/paneSplit.css +8 -0
- package/src/react/file-tree/FolderViewMenus.tsx +1 -0
- package/src/react/file-tree/folderViewTree.spec.tsx +15 -0
- package/src/react/file-tree/useFolderView.ts +13 -2
- package/src/react/filter-rail/FilterRail.spec.tsx +7 -3
- package/src/react/filter-rail/railResize.spec.tsx +27 -5
- package/src/react/filter-rail/railResize.tsx +46 -15
- package/src/react/filter-rail/railWidth.ts +13 -8
- package/src/react/hooks/longPressContextMenu.spec.ts +50 -0
- package/src/react/hooks/longPressContextMenu.ts +39 -3
- package/src/react/index.ts +4 -0
- package/src/react/lib/paneReach.spec.ts +58 -0
- package/src/react/lib/paneReach.ts +65 -0
- package/src/react/lib/tagList.spec.ts +19 -0
- package/src/react/lib/tagList.ts +29 -0
- package/src/react/master-detail/MasterDetail.tsx +10 -5
- package/src/react/media/FocusViewer.spec.tsx +19 -0
- package/src/react/media/FocusViewer.tsx +17 -0
- package/src/react/media/FrameGrabber.tsx +12 -5
- package/src/react/media/VideoChapterEditor.tsx +16 -6
- package/src/react/media/VideoCutEditor.tsx +17 -6
- package/src/react/media/VideoTurner.tsx +14 -5
- package/src/react/media/editorVideoSource.spec.tsx +71 -0
- package/src/react/media/hlsSource.ts +43 -0
- package/src/react/media/videoEnhance.ts +1 -1
- package/src/react/media-gallery/MediaGallery.tsx +4 -0
- package/src/react/tagsPrompt.tsx +33 -0
- package/src/styles-areas/fields.css +4 -4
- package/src/styles-areas/overlays.css +3 -2
- package/src/styles-utilities.css +7 -0
- package/src/testing/phoneRules.ts +2 -2
- package/src/theme.css +13 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* How wide the person dragged a sidebar — the pure half, so the
|
|
2
|
+
* How wide the person dragged a sidebar — the pure half, so the storage is testable without a
|
|
3
3
|
* pointer.
|
|
4
4
|
*
|
|
5
5
|
* Owner, 2026-09-24: *"the cb filetree sidebars should allow grabbing their right edge and
|
|
@@ -13,22 +13,27 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { announcePreference } from '../media-gallery/preferenceBus.js';
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Narrower than this and a row is an icon and an ellipsis — so it is the SNAP, not a floor:
|
|
18
|
+
* dragged below half of it the rail shuts (width 0), between half and all of it it holds here
|
|
19
|
+
* (`lib/paneReach.ts`). There is no maximum: the owner, 2026-10-27, *"let me drag them as far as
|
|
20
|
+
* I want including shutting a pane completely"* — a rail grows until the page beside it is shut.
|
|
21
|
+
*/
|
|
17
22
|
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
23
|
|
|
24
|
+
/** A width as stored: whole pixels, and snapped at the bottom like a drag (0 is a shut rail), so a
|
|
25
|
+
* sliver written by an older build or by hand is never drawn. No top — `maxWidth` holds it to its row. */
|
|
21
26
|
export const clampRailWidth = (px: number): number =>
|
|
22
|
-
Math.round(Math.
|
|
27
|
+
px < MIN_RAIL_PX / 2 ? 0 : Math.round(Math.max(MIN_RAIL_PX, px));
|
|
23
28
|
|
|
24
|
-
/** The remembered width, or null for "the rail's default". Wrapped: `localStorage`
|
|
25
|
-
* a sandboxed iframe and in Safari's private mode. */
|
|
29
|
+
/** The remembered width (0 = shut), or null for "the rail's default". Wrapped: `localStorage`
|
|
30
|
+
* throws in a sandboxed iframe and in Safari's private mode. */
|
|
26
31
|
export function readRailWidth(key: string): number | null {
|
|
27
32
|
try {
|
|
28
33
|
const raw = globalThis.localStorage?.getItem(key);
|
|
29
34
|
if (raw == null) return null;
|
|
30
35
|
const stored = Number(raw);
|
|
31
|
-
return Number.isFinite(stored) && stored
|
|
36
|
+
return raw.trim() !== '' && Number.isFinite(stored) && stored >= 0 ? clampRailWidth(stored) : null;
|
|
32
37
|
} catch {
|
|
33
38
|
return null;
|
|
34
39
|
}
|
|
@@ -61,6 +61,56 @@ describe('bindLongPressContextMenu', () => {
|
|
|
61
61
|
expect(menus).toEqual([]);
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
+
// The owner, 2026-10-27 (music): a tap that OPENED a folder popped the heading's view menu
|
|
65
|
+
// half a second later — the lift never bubbled through the bound element.
|
|
66
|
+
test('🔴 a lift heard anywhere ends the press — a node re-rendered away, a capture elsewhere', () => {
|
|
67
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
68
|
+
fireEvent.pointerUp(document.body, touch);
|
|
69
|
+
jest.advanceTimersByTime(LONG_PRESS_MS * 2);
|
|
70
|
+
|
|
71
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
72
|
+
fireEvent.pointerCancel(window, touch);
|
|
73
|
+
jest.advanceTimersByTime(LONG_PRESS_MS * 2);
|
|
74
|
+
expect(menus).toEqual([]);
|
|
75
|
+
|
|
76
|
+
// A lift of ANOTHER finger is not this press ending.
|
|
77
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
78
|
+
fireEvent.pointerUp(document.body, { ...touch, pointerId: 99 });
|
|
79
|
+
jest.advanceTimersByTime(LONG_PRESS_MS);
|
|
80
|
+
expect(menus.length).toBe(1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('🔴 a tap’s click, or a scroll, during the press is not a hold', () => {
|
|
84
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
85
|
+
fireEvent.click(document.body);
|
|
86
|
+
jest.advanceTimersByTime(LONG_PRESS_MS * 2);
|
|
87
|
+
|
|
88
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
89
|
+
fireEvent.scroll(document.body);
|
|
90
|
+
jest.advanceTimersByTime(LONG_PRESS_MS * 2);
|
|
91
|
+
expect(menus).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('🔴 when what was pressed has moved out from under the finger, it is not a hold on it', () => {
|
|
95
|
+
const elsewhere = document.createElement('p');
|
|
96
|
+
document.body.append(elsewhere);
|
|
97
|
+
const was = document.elementFromPoint;
|
|
98
|
+
try {
|
|
99
|
+
document.elementFromPoint = () => elsewhere;
|
|
100
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
101
|
+
jest.advanceTimersByTime(LONG_PRESS_MS);
|
|
102
|
+
expect(menus).toEqual([]);
|
|
103
|
+
|
|
104
|
+
document.elementFromPoint = () => child;
|
|
105
|
+
fireEvent.pointerDown(child, { ...touch, clientX: 5, clientY: 5 });
|
|
106
|
+
jest.advanceTimersByTime(LONG_PRESS_MS);
|
|
107
|
+
expect(menus.length).toBe(1);
|
|
108
|
+
} finally {
|
|
109
|
+
document.elementFromPoint = was;
|
|
110
|
+
elsewhere.remove();
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
64
114
|
test('the lift after a hold does not click what it was held on', () => {
|
|
65
115
|
let clicks = 0;
|
|
66
116
|
child.addEventListener('click', () => clicks++);
|
|
@@ -21,7 +21,16 @@
|
|
|
21
21
|
* document listener can read it as a press outside the menu that just opened and shut it;
|
|
22
22
|
* · `-webkit-touch-callout: none` while bound, so iOS does not put its own callout on top;
|
|
23
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
|
|
24
|
+
* that stays down is about to drag;
|
|
25
|
+
* · 🔴 a press that ENDED is never a hold, wherever its end was heard. The owner, 2026-10-27, on
|
|
26
|
+
* music: *"clicking open folder is blocking the view with a popup that says 'Show hidden
|
|
27
|
+
* folders' and 'Collapse all folders' … clicking a folder to open or close it should not
|
|
28
|
+
* popup a menu."* A tap that opens a folder re-renders what was under the finger, and a lift
|
|
29
|
+
* can then reach a node that is gone, or an element that captured the pointer, without ever
|
|
30
|
+
* bubbling through the bound one — so the timer outlived the tap and fired the heading's view
|
|
31
|
+
* menu half a second later. So while a hold is pending the lift, the cancel, a scroll and the
|
|
32
|
+
* tap's own `click` are heard at the WINDOW, and the hold fires only if what was pressed is
|
|
33
|
+
* still under the finger (a layout that moved under a resting finger is not a hold on it).
|
|
25
34
|
*/
|
|
26
35
|
/*
|
|
27
36
|
* 🔴 No runtime import, React's included: a rail heading loads this module LAZILY (forge task
|
|
@@ -50,11 +59,36 @@ export function bindLongPressContextMenu(
|
|
|
50
59
|
let firedUntil = 0;
|
|
51
60
|
let dispatching = false;
|
|
52
61
|
|
|
62
|
+
/** The window-wide ears of a pending hold — bound at the press, dropped with the timer. */
|
|
63
|
+
const onWindowEnd = (event: Event) => {
|
|
64
|
+
// Another finger lifting is not this press ending.
|
|
65
|
+
const id = (event as PointerEvent).pointerId;
|
|
66
|
+
if (id !== undefined && id !== pointerId) return;
|
|
67
|
+
cancel();
|
|
68
|
+
};
|
|
69
|
+
const onWindowTap = () => cancel();
|
|
70
|
+
const listen = (on: boolean) => {
|
|
71
|
+
const method = on ? 'addEventListener' : 'removeEventListener';
|
|
72
|
+
window[method]('pointerup', onWindowEnd, true);
|
|
73
|
+
window[method]('pointercancel', onWindowEnd, true);
|
|
74
|
+
window[method]('click', onWindowTap, true);
|
|
75
|
+
window[method]('scroll', onWindowTap, true);
|
|
76
|
+
};
|
|
77
|
+
|
|
53
78
|
const cancel = () => {
|
|
54
|
-
if (timer !== undefined)
|
|
79
|
+
if (timer !== undefined) {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
listen(false);
|
|
82
|
+
}
|
|
55
83
|
timer = undefined;
|
|
56
84
|
};
|
|
57
85
|
|
|
86
|
+
/** Is what was pressed still what the finger rests on? `null` (no layout to ask) is a yes. */
|
|
87
|
+
const stillUnderFinger = (pressed: Element): boolean => {
|
|
88
|
+
const at = globalThis.document?.elementFromPoint?.(startX, startY);
|
|
89
|
+
return !at || pressed.contains(at) || at.contains(pressed);
|
|
90
|
+
};
|
|
91
|
+
|
|
58
92
|
const swallowClick = (event: MouseEvent) => {
|
|
59
93
|
window.removeEventListener('click', swallowClick, true);
|
|
60
94
|
if (Date.now() > firedUntil) return;
|
|
@@ -64,7 +98,8 @@ export function bindLongPressContextMenu(
|
|
|
64
98
|
|
|
65
99
|
const fire = () => {
|
|
66
100
|
timer = undefined;
|
|
67
|
-
|
|
101
|
+
listen(false);
|
|
102
|
+
if (!(target instanceof Element) || !target.isConnected || !stillUnderFinger(target)) return;
|
|
68
103
|
firedUntil = Date.now() + AFTERMATH_MS;
|
|
69
104
|
window.addEventListener('click', swallowClick, true);
|
|
70
105
|
setTimeout(() => window.removeEventListener('click', swallowClick, true), AFTERMATH_MS);
|
|
@@ -94,6 +129,7 @@ export function bindLongPressContextMenu(
|
|
|
94
129
|
startY = event.clientY;
|
|
95
130
|
target = event.target;
|
|
96
131
|
timer = setTimeout(fire, delayMs);
|
|
132
|
+
listen(true);
|
|
97
133
|
};
|
|
98
134
|
const onMove = (event: PointerEvent) => {
|
|
99
135
|
if (timer === undefined || event.pointerId !== pointerId) return;
|
package/src/react/index.ts
CHANGED
|
@@ -674,6 +674,7 @@ export {
|
|
|
674
674
|
type SplitPaneItem,
|
|
675
675
|
type SplitPaneProps,
|
|
676
676
|
} from './components/SplitPane.js';
|
|
677
|
+
export { reachSize, stepSize } from './lib/paneReach.js';
|
|
677
678
|
export { Sparkline, type SparklineProps } from './components/Sparkline.js';
|
|
678
679
|
export { StatStrip, type StatStripItem, type StatStripProps } from './components/StatStrip.js';
|
|
679
680
|
export {
|
|
@@ -736,6 +737,9 @@ export {
|
|
|
736
737
|
} from './components/sharing/types.js';
|
|
737
738
|
export { type TabItem, Tabs, type TabsProps } from './components/Tabs.js';
|
|
738
739
|
export { TagsInput, type TagsInputProps } from './components/TagsInput.js';
|
|
740
|
+
export { TagsEditor, type TagsEditorProps } from './components/TagsEditor.js';
|
|
741
|
+
export { TagsPromptDialog, type TagsPromptDialogProps } from './components/TagsPromptDialog.js';
|
|
742
|
+
export { mergeTagLists, parseTagList, tagListText } from './lib/tagList.js';
|
|
739
743
|
export { TagStrip, type TagStripProps, type TagStripTag } from './components/TagStrip.js';
|
|
740
744
|
export {
|
|
741
745
|
Tooltip,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { reachSize, stepSize } from './paneReach.js';
|
|
3
|
+
|
|
4
|
+
// The owner, 2026-10-27: every divider goes as far as the hand takes it, shutting a pane included.
|
|
5
|
+
describe('reachSize — where a pointer drag lands', () => {
|
|
6
|
+
test('has no maximum: the first pane can take the whole axis', () => {
|
|
7
|
+
expect(reachSize(1000, 160, 1000)).toBe(1000);
|
|
8
|
+
expect(reachSize(1400, 160, 1000)).toBe(1000);
|
|
9
|
+
expect(reachSize(0.99, 0.12, 1)).toBe(1);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('shuts a pane dragged below half its minimum, either side of the seam', () => {
|
|
13
|
+
expect(reachSize(79, 160, 1000)).toBe(0);
|
|
14
|
+
expect(reachSize(-40, 160, 1000)).toBe(0);
|
|
15
|
+
expect(reachSize(921, 160, 1000)).toBe(1000);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('holds at the minimum between half and all of it, so no pane is a sliver', () => {
|
|
19
|
+
expect(reachSize(100, 160, 1000)).toBe(160);
|
|
20
|
+
expect(reachSize(900, 160, 1000)).toBe(840);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('passes every size between the two minimums through untouched', () => {
|
|
24
|
+
expect(reachSize(500, 160, 1000)).toBe(500);
|
|
25
|
+
expect(reachSize(160, 160, 1000)).toBe(160);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('a minimum larger than half the axis shrinks to half, so both panes still fit', () => {
|
|
29
|
+
expect(reachSize(300, 800, 1000)).toBe(500);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('a zero-width axis gives nothing rather than NaN or Infinity', () => {
|
|
33
|
+
expect(reachSize(10, 160, 0)).toBe(0);
|
|
34
|
+
expect(reachSize(Number.NaN, 160, 1000)).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('stepSize — where an arrow key lands', () => {
|
|
39
|
+
test('a step that would cross the minimum stops on it, and the next one shuts', () => {
|
|
40
|
+
expect(stepSize(170, -16, 160, 1000)).toBe(160);
|
|
41
|
+
expect(stepSize(160, -16, 160, 1000)).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the first step out of shut opens at the minimum, not a sliver', () => {
|
|
45
|
+
expect(stepSize(0, 16, 160, 1000)).toBe(160);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('the far end shuts the other pane the same way, and opens it the same way', () => {
|
|
49
|
+
expect(stepSize(830, 16, 160, 1000)).toBe(840);
|
|
50
|
+
expect(stepSize(840, 16, 160, 1000)).toBe(1000);
|
|
51
|
+
expect(stepSize(1000, -16, 160, 1000)).toBe(840);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('works in shares as well as pixels, through float noise', () => {
|
|
55
|
+
expect(stepSize(0.12000000001, -0.02, 0.12, 1)).toBe(0);
|
|
56
|
+
expect(stepSize(0.5, 0.02, 0.12, 1)).toBeCloseTo(0.52);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How far a divider may be dragged — the ONE rule every resizable split in cursedbelt obeys.
|
|
3
|
+
*
|
|
4
|
+
* The owner, 2026-10-27: *"For all dividers … that I can drag to size the panes, let me drag them
|
|
5
|
+
* as far as I want including shutting a pane completely … I don't want any panes to block me."*
|
|
6
|
+
* Station's Queue was the worked example: its task list stopped at 36% of the page, so the titles
|
|
7
|
+
* it was opened to read stayed cut off however hard the divider was pulled.
|
|
8
|
+
*
|
|
9
|
+
* So a pane has no MAXIMUM, ever — a pane grows until its neighbor is shut — and its minimum is
|
|
10
|
+
* not a wall but a snap, VS Code's sash and `react-resizable-panels`' collapsible panel alike:
|
|
11
|
+
*
|
|
12
|
+
* - dragged below HALF its minimum, a pane SHUTS (size 0);
|
|
13
|
+
* - between half and the whole minimum it HOLDS at the minimum, so a pane is never drawn as an
|
|
14
|
+
* unreadable sliver and a shut is a deliberate pull, not a twitch;
|
|
15
|
+
* - the same at the far end, for the pane on the other side of the seam.
|
|
16
|
+
*
|
|
17
|
+
* A shut pane opens again the way it closed: drag the seam back out (it is still there, at the
|
|
18
|
+
* edge), or step it with an arrow key. `PanelGrid`/`SplitPane` get the same rule from the library
|
|
19
|
+
* (every leaf is `collapsible`); `PaneSplit` and `useRailResize` draw their own seams and call
|
|
20
|
+
* these. An app that draws a seam of its own calls them too — guardrails' `no-hand-rolled-divider`
|
|
21
|
+
* refuses one that does not.
|
|
22
|
+
*
|
|
23
|
+
* Units are the caller's — pixels or a share of 1 — as long as all three agree.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** Below this share of `total`, two sizes are the same size (a float's last bits are not a gap). */
|
|
27
|
+
const EPSILON = 1e-4;
|
|
28
|
+
|
|
29
|
+
/** The snap a pane gets, never more than half the axis — two minimums must fit side by side. */
|
|
30
|
+
function snapOf(min: number, total: number): number {
|
|
31
|
+
return Math.max(0, Math.min(min, total / 2));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Where a POINTER drag lands: the size the pointer asks for, with the snap applied at both ends.
|
|
36
|
+
* `size` is the first pane's (the one before the seam); the second pane is `total - size`.
|
|
37
|
+
*/
|
|
38
|
+
export function reachSize(size: number, min: number, total: number): number {
|
|
39
|
+
if (!(total > 0) || !Number.isFinite(size)) return 0;
|
|
40
|
+
const snap = snapOf(min, total);
|
|
41
|
+
if (size < snap / 2) return 0;
|
|
42
|
+
if (size < snap) return snap;
|
|
43
|
+
if (size > total - snap / 2) return total;
|
|
44
|
+
if (size > total - snap) return total - snap;
|
|
45
|
+
return size;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Where a KEYBOARD step lands. A step that would cross a minimum stops ON it, and the next step
|
|
50
|
+
* the same way shuts the pane — so every size, including shut, is reachable from the keys, and
|
|
51
|
+
* a shut pane's first step out opens it at its minimum, not a sliver.
|
|
52
|
+
*/
|
|
53
|
+
export function stepSize(size: number, delta: number, min: number, total: number): number {
|
|
54
|
+
if (!(total > 0) || !Number.isFinite(size) || delta === 0) return size;
|
|
55
|
+
const snap = snapOf(min, total);
|
|
56
|
+
const near = (a: number, b: number) => Math.abs(a - b) <= total * EPSILON;
|
|
57
|
+
if (delta < 0) {
|
|
58
|
+
if (near(size, total)) return total - snap;
|
|
59
|
+
if (size <= snap || near(size, snap)) return 0;
|
|
60
|
+
return Math.max(snap, size + delta);
|
|
61
|
+
}
|
|
62
|
+
if (near(size, 0)) return snap;
|
|
63
|
+
if (size >= total - snap || near(size, total - snap)) return total;
|
|
64
|
+
return Math.min(total - snap, size + delta);
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mergeTagLists, parseTagList, tagListText } from './tagList.js';
|
|
3
|
+
|
|
4
|
+
describe('tags on the clipboard', () => {
|
|
5
|
+
test('out as a comma line, back from commas, semicolons or lines — trimmed, each once', () => {
|
|
6
|
+
expect(tagListText(['beach', 'dog', 'golden hour'])).toBe('beach, dog, golden hour');
|
|
7
|
+
expect(parseTagList('beach, dog, golden hour')).toEqual(['beach', 'dog', 'golden hour']);
|
|
8
|
+
expect(parseTagList('Beach\ndog; Beach \r\n\n')).toEqual(['Beach', 'dog']);
|
|
9
|
+
expect(parseTagList(' ')).toEqual([]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("the app's own spelling rule decides what counts as the same tag", () => {
|
|
13
|
+
expect(parseTagList('Beach\ndogs; beach', (raw) => raw.trim().toLowerCase().replace(/s$/, ''))).toEqual(['beach', 'dog']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('a paste adds what the item does not have yet, in the pasted order', () => {
|
|
17
|
+
expect(mergeTagLists(['dog'], ['beach', 'dog', 'sunset'])).toEqual(['dog', 'beach', 'sunset']);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tags on the clipboard — one spelling for both directions (forge task 3773 in photos; lifted for
|
|
3
|
+
* music's "copying tags like photos app does", forge task 3812). Tags go out as `a, b, c` and come
|
|
4
|
+
* back from that, from one per line, or from a mix, so what one item's tags copied as pastes onto
|
|
5
|
+
* another, and so does a list typed in a note.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The tags as a line a person can paste anywhere. */
|
|
9
|
+
export const tagListText = (tags: readonly string[]): string => tags.join(', ');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tags in a pasted text — split on commas, semicolons and line breaks, each passed through
|
|
13
|
+
* `normalize` (the app's own spelling rule; trimming by default), empties dropped, each once, in order.
|
|
14
|
+
*/
|
|
15
|
+
export function parseTagList(text: string, normalize: (raw: string) => string = (raw) => raw.trim()): string[] {
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
for (const part of text.split(/[,;\n\r]+/)) {
|
|
18
|
+
const tag = normalize(part);
|
|
19
|
+
if (tag && !out.includes(tag)) out.push(tag);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** `pasted` merged after `current`, without what is already there. */
|
|
25
|
+
export function mergeTagLists(current: readonly string[], pasted: readonly string[]): string[] {
|
|
26
|
+
const next = [...current];
|
|
27
|
+
for (const tag of pasted) if (!next.includes(tag)) next.push(tag);
|
|
28
|
+
return next;
|
|
29
|
+
}
|
|
@@ -85,13 +85,17 @@ export interface MasterDetailProps {
|
|
|
85
85
|
/** Distinct per page — SplitPane persists pane widths under it. */
|
|
86
86
|
storageKey?: string;
|
|
87
87
|
/**
|
|
88
|
-
* The LIST pane's share of the width — default 30
|
|
88
|
+
* The LIST pane's share of the width — default 30%, snapping at 16rem. A page whose detail is
|
|
89
89
|
* the thing being read (a task's text, an app's charts) gives the list less: station's Queue at
|
|
90
90
|
* 1440 drew a 30% list of one-line titles beside a detail pane of four facts, so the text the
|
|
91
91
|
* owner opened the page for had nowhere to go (forge task station-evolution, 2026-10-20). Any
|
|
92
|
-
* CSS length or percentage
|
|
92
|
+
* CSS length or percentage.
|
|
93
|
+
*
|
|
94
|
+
* 🔴 There is no `max`: the reader drags the divider as far as they like, shutting either pane
|
|
95
|
+
* (the owner, 2026-10-27 — station's Queue capped its list at 36% and cut its titles off). `min`
|
|
96
|
+
* is the snap `SplitPane` gives it, not a floor.
|
|
93
97
|
*/
|
|
94
|
-
masterSize?: { default?: string; min?: string
|
|
98
|
+
masterSize?: { default?: string; min?: string };
|
|
95
99
|
/** Rendered above the filters (a "New…" button, a count, a refresh). */
|
|
96
100
|
listHeader?: ReactNode;
|
|
97
101
|
className?: string;
|
|
@@ -320,8 +324,9 @@ export function MasterDetail({
|
|
|
320
324
|
{...(storageKey ? { storageKey } : {})}
|
|
321
325
|
separatorVariant="line"
|
|
322
326
|
panels={[
|
|
323
|
-
{ id: "master", content: list, defaultSize: masterSize?.default ?? "30%", minSize: masterSize?.min ?? "16rem"
|
|
324
|
-
|
|
327
|
+
{ id: "master", content: list, defaultSize: masterSize?.default ?? "30%", minSize: masterSize?.min ?? "16rem" },
|
|
328
|
+
// The detail snaps too, so the list can take the whole width without a sliver beside it.
|
|
329
|
+
{ id: "detail", content: pane, minSize: "16rem" },
|
|
325
330
|
]}
|
|
326
331
|
/>
|
|
327
332
|
</div>
|
|
@@ -32,3 +32,22 @@ describe('FocusViewer — which source the picture comes from', () => {
|
|
|
32
32
|
}
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 🔴 A host's "Make a GIF" command opens the viewer ON the GIF — owner, 2026-10-27: *"it is
|
|
38
|
+
* unclear how to choose part of a video to make into a gif"*, when the panel was one button
|
|
39
|
+
* inside a viewer reached by "Focus and snapshot".
|
|
40
|
+
*/
|
|
41
|
+
describe('openGif', () => {
|
|
42
|
+
test('opens with the GIF section showing, from where the host was', () => {
|
|
43
|
+
render(
|
|
44
|
+
<FocusViewer onClose={() => {}} videoUrl="https://media.test/film.web.mp4" durationSeconds={60} startAt={12} openGif />,
|
|
45
|
+
);
|
|
46
|
+
expect(document.body.querySelector('section[aria-label="Make a GIF"]')).not.toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('without it, the viewer opens on the film and the GIF waits for its button', () => {
|
|
50
|
+
render(<FocusViewer onClose={() => {}} videoUrl="https://media.test/film.web.mp4" durationSeconds={60} startAt={12} />);
|
|
51
|
+
expect(document.body.querySelector('section[aria-label="Make a GIF"]')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -169,6 +169,13 @@ export interface FocusViewerProps {
|
|
|
169
169
|
title?: string;
|
|
170
170
|
/** Where the host's own player was when this opened. */
|
|
171
171
|
startAt?: number;
|
|
172
|
+
/**
|
|
173
|
+
* Open with the GIF panel already showing, its section starting at {@link startAt}.
|
|
174
|
+
*
|
|
175
|
+
* 🔴 For a host's own "Make a GIF" command. Behind "Focus and snapshot" alone, the owner could
|
|
176
|
+
* not find it: *"it is unclear how to choose part of a video to make into a gif"* (2026-10-27).
|
|
177
|
+
*/
|
|
178
|
+
openGif?: boolean;
|
|
172
179
|
/** Duration the host already knows, so the surface is not blank until metadata lands. */
|
|
173
180
|
durationSeconds?: number | null;
|
|
174
181
|
/** Stills already kept beside this video, newest wherever the host wants them. */
|
|
@@ -228,6 +235,7 @@ export function FocusViewer({
|
|
|
228
235
|
hlsUrl,
|
|
229
236
|
title,
|
|
230
237
|
startAt = 0,
|
|
238
|
+
openGif = false,
|
|
231
239
|
durationSeconds,
|
|
232
240
|
snapshots = [],
|
|
233
241
|
onSaveSnapshot,
|
|
@@ -452,6 +460,15 @@ export function FocusViewer({
|
|
|
452
460
|
}, [gif, loopSection, gifProgress]);
|
|
453
461
|
|
|
454
462
|
/** Close the GIF panel, stopping a GIF still being made. */
|
|
463
|
+
// `openGif`: the section opens once the length is known, so it is clamped to the film.
|
|
464
|
+
const openedGif = useRef(false);
|
|
465
|
+
useEffect(() => {
|
|
466
|
+
if (!openGif || openedGif.current || duration <= 0) return;
|
|
467
|
+
openedGif.current = true;
|
|
468
|
+
void loadGifRecorder().catch(() => undefined);
|
|
469
|
+
setGif(gifSectionAt(startAt, duration));
|
|
470
|
+
}, [openGif, duration, startAt]);
|
|
471
|
+
|
|
455
472
|
const closeGif = (): void => {
|
|
456
473
|
gifAbort.current?.abort();
|
|
457
474
|
setGif(null);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import './pictureEditor.css';
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { useEditorVideoSource } from './hlsSource.js';
|
|
3
4
|
import { ActionBar } from '../components/ActionBar.js';
|
|
4
5
|
import { Button } from '../components/Button.js';
|
|
5
6
|
import { Modal } from '../components/Modal.js';
|
|
@@ -42,6 +43,8 @@ export interface FrameGrabberProps {
|
|
|
42
43
|
* *"I see a black screen"* wearing a different dialog's clothes.
|
|
43
44
|
*/
|
|
44
45
|
src: string;
|
|
46
|
+
/** The adaptive ladder's master playlist — a source on its own (`useEditorVideoSource`). */
|
|
47
|
+
hlsUrl?: string | undefined;
|
|
45
48
|
/**
|
|
46
49
|
* True while the app is still finding the source (a signed URL being minted, bytes being
|
|
47
50
|
* fetched). It is the difference between "on its way" and "cannot be shown", and only the
|
|
@@ -143,6 +146,7 @@ type Grab = 'thumbnail' | 'picture';
|
|
|
143
146
|
*/
|
|
144
147
|
export function FrameGrabber({
|
|
145
148
|
src,
|
|
149
|
+
hlsUrl,
|
|
146
150
|
resolving = false,
|
|
147
151
|
name,
|
|
148
152
|
startAt = 0,
|
|
@@ -179,11 +183,12 @@ export function FrameGrabber({
|
|
|
179
183
|
*/
|
|
180
184
|
const cannotShow: string | null = resolving
|
|
181
185
|
? 'Finding this film…'
|
|
182
|
-
: !src
|
|
186
|
+
: !src && !hlsUrl
|
|
183
187
|
? 'This file has no video source to play here, so there are no frames to pick from.'
|
|
184
|
-
: failedSrc === src
|
|
188
|
+
: failedSrc === (hlsUrl || src)
|
|
185
189
|
? 'The film could not be loaded, so there are no frames to pick from.'
|
|
186
190
|
: null;
|
|
191
|
+
const source = useEditorVideoSource(videoRef, { src, hlsUrl }, cannotShow === null);
|
|
187
192
|
|
|
188
193
|
const canThumb = Boolean(onSaveThumbnail);
|
|
189
194
|
const canSnap = Boolean(onSaveSnapshot);
|
|
@@ -482,8 +487,8 @@ export function FrameGrabber({
|
|
|
482
487
|
<video
|
|
483
488
|
ref={videoRef}
|
|
484
489
|
// A new source is a new attempt — see `failedSrc`.
|
|
485
|
-
key={
|
|
486
|
-
src={src}
|
|
490
|
+
key={source.key}
|
|
491
|
+
src={source.src}
|
|
487
492
|
crossOrigin='anonymous'
|
|
488
493
|
playsInline
|
|
489
494
|
preload='metadata'
|
|
@@ -506,7 +511,9 @@ export function FrameGrabber({
|
|
|
506
511
|
if (startAt > 0) el.currentTime = startAt;
|
|
507
512
|
}}
|
|
508
513
|
onTimeUpdate={(event) => setAt(event.currentTarget.currentTime)}
|
|
509
|
-
onError={() =>
|
|
514
|
+
onError={(event) => {
|
|
515
|
+
if (source.exhausted(event.currentTarget)) setFailedSrc(source.key);
|
|
516
|
+
}}
|
|
510
517
|
/>
|
|
511
518
|
{grab === 'thumbnail' ? (
|
|
512
519
|
<canvas
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import './videoChapterEditor.css';
|
|
2
2
|
import React from 'react';
|
|
3
|
+
import { useEditorVideoSource } from './hlsSource.js';
|
|
3
4
|
import { Bookmark, Trash2 } from 'lucide-react';
|
|
4
5
|
import { cutTimecode } from 'cursedbelt-core/media/video-cuts';
|
|
5
6
|
import { ActionBar } from '../components/ActionBar.js';
|
|
@@ -59,6 +60,8 @@ export interface VideoChapterEditorProps {
|
|
|
59
60
|
* and the sections themselves stay editable — they are metadata beside the film.
|
|
60
61
|
*/
|
|
61
62
|
src: string;
|
|
63
|
+
/** The adaptive ladder's master playlist — a source on its own (`useEditorVideoSource`). */
|
|
64
|
+
hlsUrl?: string | undefined;
|
|
62
65
|
/** Shown until the first frame decodes — a black box reads as a broken player. */
|
|
63
66
|
posterUrl?: string;
|
|
64
67
|
/**
|
|
@@ -96,6 +99,7 @@ export interface VideoChapterEditorProps {
|
|
|
96
99
|
|
|
97
100
|
export function VideoChapterEditor({
|
|
98
101
|
src,
|
|
102
|
+
hlsUrl,
|
|
99
103
|
posterUrl,
|
|
100
104
|
resolving = false,
|
|
101
105
|
name,
|
|
@@ -141,11 +145,12 @@ export function VideoChapterEditor({
|
|
|
141
145
|
*/
|
|
142
146
|
const cannotShow: string | null = resolving
|
|
143
147
|
? 'Finding this film…'
|
|
144
|
-
: !src
|
|
148
|
+
: !src && !hlsUrl
|
|
145
149
|
? 'This file has no video source to play here, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
|
|
146
|
-
: failedSrc === src
|
|
150
|
+
: failedSrc === (hlsUrl || src)
|
|
147
151
|
? 'The film could not be loaded, so there is nothing to scrub. The sections are stored beside the film, so the ones it already has can still be edited and saved.'
|
|
148
152
|
: null;
|
|
153
|
+
const source = useEditorVideoSource(videoRef, { src, hlsUrl }, cannotShow === null);
|
|
149
154
|
|
|
150
155
|
const spans = React.useMemo(() => chapterSpans(chapters, duration), [chapters, duration]);
|
|
151
156
|
/** What Save would send. Also what `isDirty` compares, so the two can never disagree. */
|
|
@@ -310,8 +315,8 @@ export function VideoChapterEditor({
|
|
|
310
315
|
ref={videoRef}
|
|
311
316
|
className='cbch-video'
|
|
312
317
|
// A new source is a new attempt — see `failedSrc`.
|
|
313
|
-
key={
|
|
314
|
-
src={src}
|
|
318
|
+
key={source.key}
|
|
319
|
+
src={source.src}
|
|
315
320
|
poster={posterUrl}
|
|
316
321
|
controls
|
|
317
322
|
playsInline
|
|
@@ -323,7 +328,9 @@ export function VideoChapterEditor({
|
|
|
323
328
|
}}
|
|
324
329
|
onTimeUpdate={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
325
330
|
onSeeked={(event) => setPlayhead(event.currentTarget.currentTime)}
|
|
326
|
-
onError={() =>
|
|
331
|
+
onError={(event) => {
|
|
332
|
+
if (source.exhausted(event.currentTarget)) setFailedSrc(source.key);
|
|
333
|
+
}}
|
|
327
334
|
/>
|
|
328
335
|
) : (
|
|
329
336
|
/* In the film's own place and at its own size — a sentence under a black rectangle
|
|
@@ -426,7 +433,10 @@ export function VideoChapterEditor({
|
|
|
426
433
|
? spans.length === 0
|
|
427
434
|
? `${cutTimecode(duration)} of video, in one unnamed part`
|
|
428
435
|
: `${spans.length} section${spans.length === 1 ? '' : 's'} across ${cutTimecode(duration)}`
|
|
429
|
-
:
|
|
436
|
+
: // 🔴 Only a mounted film is being read — see the same line in `VideoCutEditor`.
|
|
437
|
+
cannotShow === null
|
|
438
|
+
? 'Reading the clip…'
|
|
439
|
+
: 'Its length is not known.'}
|
|
430
440
|
</p>
|
|
431
441
|
|
|
432
442
|
{chapters.length === 0 ? (
|