cursedbelt 3.0.2 โ 4.1.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/ListToolbar.d.ts +94 -0
- package/dist/react/components/ListToolbar.d.ts.map +1 -0
- package/dist/react/components/ListToolbar.js +66 -0
- package/dist/react/components/ListToolbar.js.map +1 -0
- package/dist/react/file-tree/FileTree.d.ts +282 -3
- package/dist/react/file-tree/FileTree.d.ts.map +1 -1
- package/dist/react/file-tree/FileTree.js +799 -131
- package/dist/react/file-tree/FileTree.js.map +1 -1
- package/dist/react/file-tree/fileTree.css +151 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
- package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
- package/dist/react/file-tree/fileTreeSelection.js +264 -0
- package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
- package/dist/react/file-tree/index.d.ts +1 -0
- package/dist/react/file-tree/index.d.ts.map +1 -1
- package/dist/react/file-tree/index.js +8 -0
- package/dist/react/file-tree/index.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/lib/form.d.ts +3 -0
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +43 -5
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/media/DenseGalleryShell.d.ts +19 -3
- package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
- package/dist/react/media/DenseGalleryShell.js +55 -15
- package/dist/react/media/DenseGalleryShell.js.map +1 -1
- package/dist/react/media/FocusViewer.d.ts.map +1 -1
- package/dist/react/media/FocusViewer.js +14 -14
- package/dist/react/media/FocusViewer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +23 -0
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +67 -1
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/MediaResize.d.ts +51 -19
- package/dist/react/media/MediaResize.d.ts.map +1 -1
- package/dist/react/media/MediaResize.js +20 -13
- package/dist/react/media/MediaResize.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +67 -0
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
- package/dist/react/media/VideoChapterEditor.js +148 -0
- package/dist/react/media/VideoChapterEditor.js.map +1 -0
- package/dist/react/media/VideoPlayer.d.ts +46 -4
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +124 -11
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/VideoTurner.d.ts +15 -2
- package/dist/react/media/VideoTurner.d.ts.map +1 -1
- package/dist/react/media/VideoTurner.js +29 -2
- package/dist/react/media/VideoTurner.js.map +1 -1
- package/dist/react/media/denseGallery.css +104 -46
- package/dist/react/media/hlsSource.d.ts +57 -0
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +177 -1
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +2 -0
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +6 -0
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/media/mediaResize.css +127 -54
- package/dist/react/media/pictureEditor.css +52 -6
- package/dist/react/media/videoChapterEditor.css +203 -0
- package/dist/react/media/videoChapters.d.ts +117 -0
- package/dist/react/media/videoChapters.d.ts.map +1 -0
- package/dist/react/media/videoChapters.js +95 -0
- package/dist/react/media/videoChapters.js.map +1 -0
- package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
- package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
- package/dist/react/media-gallery/GalleryTable.js +40 -9
- package/dist/react/media-gallery/GalleryTable.js.map +1 -1
- package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +484 -104
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
- package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
- package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
- package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
- package/dist/react/media-gallery/index.d.ts +2 -0
- package/dist/react/media-gallery/index.d.ts.map +1 -1
- package/dist/react/media-gallery/index.js +5 -0
- package/dist/react/media-gallery/index.js.map +1 -1
- package/dist/react/media-gallery/mediaGallery.css +180 -4
- package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
- package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
- package/dist/react/media-gallery/mediaMeta.js +56 -0
- package/dist/react/media-gallery/mediaMeta.js.map +1 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
- package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
- package/dist/react/media-gallery/playbackPreferences.js +42 -0
- package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +34 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +29 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
- package/dist/styles-areas/activity-bar.css +30 -0
- package/dist/styles-areas/analytics.css +672 -0
- package/dist/styles-areas/auth.css +154 -0
- package/dist/styles-areas/calendar.css +416 -0
- package/dist/styles-areas/chart-table.css +41 -0
- package/dist/styles-areas/charts.css +89 -0
- package/dist/styles-areas/chat.css +364 -0
- package/dist/styles-areas/clipboard.css +26 -0
- package/dist/styles-areas/code-editor.css +130 -0
- package/dist/styles-areas/companion-link.css +70 -0
- package/dist/styles-areas/core.css +2503 -0
- package/dist/styles-areas/dashboard-grid.css +84 -0
- package/dist/styles-areas/data-table.css +657 -0
- package/dist/styles-areas/deep-link.css +26 -0
- package/dist/styles-areas/diff-viewer.css +30 -0
- package/dist/styles-areas/disk-usage.css +262 -0
- package/dist/styles-areas/emoji.css +154 -0
- package/dist/styles-areas/fields.css +582 -0
- package/dist/styles-areas/file-tree.css +33 -0
- package/dist/styles-areas/filter-rail.css +235 -0
- package/dist/styles-areas/folder-tree.css +219 -0
- package/dist/styles-areas/keep-awake.css +26 -0
- package/dist/styles-areas/layout-engine.css +239 -0
- package/dist/styles-areas/markdown.css +87 -0
- package/dist/styles-areas/master-detail.css +239 -0
- package/dist/styles-areas/master-lock.css +26 -0
- package/dist/styles-areas/media-gallery.css +351 -0
- package/dist/styles-areas/media.css +658 -0
- package/dist/styles-areas/nav.css +488 -0
- package/dist/styles-areas/notifications.css +245 -0
- package/dist/styles-areas/overlays.css +133 -0
- package/dist/styles-areas/palette.css +140 -0
- package/dist/styles-areas/pdf-viewer.css +47 -0
- package/dist/styles-areas/problem-detail.css +97 -0
- package/dist/styles-areas/rich-text.css +167 -0
- package/dist/styles-areas/sharing.css +153 -0
- package/dist/styles-areas/spreadsheet.css +37 -0
- package/dist/styles-areas/stats.css +281 -0
- package/dist/styles-areas/test-report.css +133 -0
- package/dist/styles-areas/virtual.css +30 -0
- package/dist/styles-areas/wizard.css +381 -0
- package/dist/styles-areas/workbench.css +543 -0
- package/dist/styles-areas/workbook-viewer.css +44 -0
- package/dist/styles-static.css +34 -23
- package/dist/styles.css +34 -23
- package/package.json +130 -32
- package/scripts/checkDistExports.ts +67 -0
- package/scripts/cssRules.ts +125 -0
- package/scripts/fixtureAppCss.ts +344 -0
- package/scripts/generateAreaStyles.ts +253 -0
- package/scripts/guardrailsEnforce.spec.ts +62 -0
- package/scripts/guardrailsEnforce.ts +67 -3
- package/scripts/paths.ts +106 -0
- package/scripts/styleAreas.ts +585 -0
- package/scripts/verify.ts +5 -0
- package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
- package/src/docsMatchTheSplit.spec.ts +98 -0
- package/src/fixtureAppCss.spec.ts +177 -0
- package/src/namedSubpathsResolve.spec.ts +35 -0
- package/src/publishShape.spec.ts +67 -0
- package/src/react/components/ListToolbar.spec.tsx +172 -0
- package/src/react/components/ListToolbar.tsx +151 -0
- package/src/react/file-tree/FileTree.spec.tsx +1009 -0
- package/src/react/file-tree/FileTree.tsx +1459 -309
- package/src/react/file-tree/fileTree.css +151 -0
- package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
- package/src/react/file-tree/fileTreeSelection.ts +321 -0
- package/src/react/file-tree/index.ts +21 -0
- package/src/react/index.ts +1 -0
- package/src/react/lib/form.ts +51 -8
- package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
- package/src/react/media/DenseGalleryShell.tsx +108 -36
- package/src/react/media/FocusViewer.tsx +15 -8
- package/src/react/media/FrameGrabber.spec.tsx +175 -0
- package/src/react/media/FrameGrabber.tsx +67 -0
- package/src/react/media/MediaResize.spec.tsx +84 -14
- package/src/react/media/MediaResize.tsx +51 -19
- package/src/react/media/VideoChapterEditor.tsx +428 -0
- package/src/react/media/VideoPlayer.spec.tsx +111 -1
- package/src/react/media/VideoPlayer.tsx +158 -11
- package/src/react/media/VideoTurner.spec.tsx +67 -0
- package/src/react/media/VideoTurner.tsx +70 -15
- package/src/react/media/denseGallery.css +104 -46
- package/src/react/media/hlsSource.recovery.spec.ts +213 -0
- package/src/react/media/hlsSource.spec.ts +8 -0
- package/src/react/media/hlsSource.ts +189 -1
- package/src/react/media/index.ts +14 -0
- package/src/react/media/mediaResize.css +127 -54
- package/src/react/media/pictureEditor.css +52 -6
- package/src/react/media/videoChapterEditor.css +203 -0
- package/src/react/media/videoChapters.spec.ts +176 -0
- package/src/react/media/videoChapters.ts +181 -0
- package/src/react/media-gallery/GalleryTable.tsx +93 -3
- package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
- package/src/react/media-gallery/MediaGallery.tsx +813 -95
- package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
- package/src/react/media-gallery/galleryTable.spec.ts +41 -1
- package/src/react/media-gallery/index.ts +7 -0
- package/src/react/media-gallery/mediaGallery.css +180 -4
- package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
- package/src/react/media-gallery/mediaMeta.ts +101 -0
- package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
- package/src/react/media-gallery/playbackPreferences.ts +100 -0
- package/src/react/media-gallery/types.ts +34 -0
- package/src/shippedFilesAreTracked.spec.ts +69 -0
- package/src/styles-areas/activity-bar.css +30 -0
- package/src/styles-areas/analytics.css +672 -0
- package/src/styles-areas/auth.css +154 -0
- package/src/styles-areas/calendar.css +416 -0
- package/src/styles-areas/chart-table.css +41 -0
- package/src/styles-areas/charts.css +89 -0
- package/src/styles-areas/chat.css +364 -0
- package/src/styles-areas/clipboard.css +26 -0
- package/src/styles-areas/code-editor.css +130 -0
- package/src/styles-areas/companion-link.css +70 -0
- package/src/styles-areas/core.css +2503 -0
- package/src/styles-areas/dashboard-grid.css +84 -0
- package/src/styles-areas/data-table.css +657 -0
- package/src/styles-areas/deep-link.css +26 -0
- package/src/styles-areas/diff-viewer.css +30 -0
- package/src/styles-areas/disk-usage.css +262 -0
- package/src/styles-areas/emoji.css +154 -0
- package/src/styles-areas/fields.css +582 -0
- package/src/styles-areas/file-tree.css +33 -0
- package/src/styles-areas/filter-rail.css +235 -0
- package/src/styles-areas/folder-tree.css +219 -0
- package/src/styles-areas/keep-awake.css +26 -0
- package/src/styles-areas/layout-engine.css +239 -0
- package/src/styles-areas/markdown.css +87 -0
- package/src/styles-areas/master-detail.css +239 -0
- package/src/styles-areas/master-lock.css +26 -0
- package/src/styles-areas/media-gallery.css +351 -0
- package/src/styles-areas/media.css +658 -0
- package/src/styles-areas/nav.css +488 -0
- package/src/styles-areas/notifications.css +245 -0
- package/src/styles-areas/overlays.css +133 -0
- package/src/styles-areas/palette.css +140 -0
- package/src/styles-areas/pdf-viewer.css +47 -0
- package/src/styles-areas/problem-detail.css +97 -0
- package/src/styles-areas/rich-text.css +167 -0
- package/src/styles-areas/sharing.css +153 -0
- package/src/styles-areas/spreadsheet.css +37 -0
- package/src/styles-areas/stats.css +281 -0
- package/src/styles-areas/test-report.css +133 -0
- package/src/styles-areas/virtual.css +30 -0
- package/src/styles-areas/wizard.css +381 -0
- package/src/styles-areas/workbench.css +543 -0
- package/src/styles-areas/workbook-viewer.css +44 -0
- package/src/styles-static.css +34 -23
- package/src/styles.css +34 -23
- package/src/stylesAreas.spec.ts +247 -0
- package/src/stylesUtilitiesMatches.spec.ts +6 -77
- package/src/testFilesRunInParallel.spec.ts +79 -0
- package/src/typecheckCachesAreSeparate.spec.ts +128 -0
- package/src/verifyGraph.spec.ts +6 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import './denseGallery.css';
|
|
2
|
+
import { ChevronLeft, ChevronRight, Maximize2, Minimize2 } from 'lucide-react';
|
|
2
3
|
import {
|
|
3
4
|
type ButtonHTMLAttributes,
|
|
4
5
|
type CSSProperties,
|
|
@@ -43,11 +44,28 @@ export interface GalleryActionButtonProps
|
|
|
43
44
|
active?: boolean;
|
|
44
45
|
/** Hover/focus explanation. A pill with a non-obvious effect should always carry one. */
|
|
45
46
|
tooltip?: ReactNode;
|
|
47
|
+
/**
|
|
48
|
+
* Draw `children` as a bare glyph in a square pill instead of a labelled one โ the shape
|
|
49
|
+
* the whole gallery toolbar uses since 2026-09-16 (owner: *"Make it have icons only,
|
|
50
|
+
* instead of words in the strip of tools"*).
|
|
51
|
+
*
|
|
52
|
+
* ๐ด An `aria-label` becomes REQUIRED, and this prop cannot supply one for you: a pill
|
|
53
|
+
* whose only content is an `<svg>` has no accessible name at all, and a toolbar of
|
|
54
|
+
* unnamed glyphs is unusable with a screen reader. The same bargain
|
|
55
|
+
* {@link DeleteButton}'s `iconOnly` already makes โ named so a consumer meets one word
|
|
56
|
+
* for one idea across the design system.
|
|
57
|
+
*
|
|
58
|
+
* The 44px tap floor comes with it, in `denseGallery.css`'s `.is-icon` โ a consumer
|
|
59
|
+
* cannot get that wrong from out here, which is the reason this is a prop rather than a
|
|
60
|
+
* class name in a doc.
|
|
61
|
+
*/
|
|
62
|
+
iconOnly?: boolean;
|
|
46
63
|
}
|
|
47
64
|
|
|
48
65
|
export function GalleryActionButton({
|
|
49
66
|
active,
|
|
50
67
|
tooltip,
|
|
68
|
+
iconOnly,
|
|
51
69
|
children,
|
|
52
70
|
type,
|
|
53
71
|
...props
|
|
@@ -57,7 +75,7 @@ export function GalleryActionButton({
|
|
|
57
75
|
// guardrails-ignore no-raw-action-button: THE shared dense-gallery action pill โ
|
|
58
76
|
// this is the component consumers use instead of hand-rolling one.
|
|
59
77
|
type={type === 'submit' || type === 'reset' ? type : 'button'}
|
|
60
|
-
className={`cbgd-order-btn${active ? ' is-active' : ''}`}
|
|
78
|
+
className={`cbgd-order-btn${active ? ' is-active' : ''}${iconOnly ? ' is-icon' : ''}`}
|
|
61
79
|
{...(active === undefined ? {} : { 'aria-pressed': active })}
|
|
62
80
|
{...props}
|
|
63
81
|
>
|
|
@@ -363,7 +381,7 @@ export interface DenseGalleryDetailProps {
|
|
|
363
381
|
/** See {@link DenseGalleryShellProps.rowExtraHeight} โ leave unset. */
|
|
364
382
|
rowExtraHeight?: number;
|
|
365
383
|
/**
|
|
366
|
-
* Full-stage mode as a CONTROLLED value โ the shell's own
|
|
384
|
+
* Full-stage mode as a CONTROLLED value โ the shell's own `Fullscreen`, which hides the
|
|
367
385
|
* rail and the related grid so the picture takes the whole container.
|
|
368
386
|
*
|
|
369
387
|
* Omit both and the toggle keeps its own state, which is what every consumer had before
|
|
@@ -385,7 +403,7 @@ export interface DenseGalleryDetailProps {
|
|
|
385
403
|
* `false` (the default) for everything else, and since 2026-09-11 that is a FEATURE rather
|
|
386
404
|
* than an absence. Owner: *"Doubleclicking an image to toggle full screen is not working at
|
|
387
405
|
* all and may be a new feature to add now."* A still picture has no player and therefore no
|
|
388
|
-
* gesture of its own, so the shell's
|
|
406
|
+
* gesture of its own, so the shell's `Fullscreen` is what a double-click means on one โ
|
|
389
407
|
* in BOTH directions, which is the half that was missing (it used only to LEAVE).
|
|
390
408
|
*
|
|
391
409
|
* ๐ด Either way the shell still handles the leave while `fullStage` is on, because the video
|
|
@@ -451,6 +469,20 @@ export function DenseGalleryDetail({
|
|
|
451
469
|
* pass. A consumer that knows better still wins โ it sets `--cbgd-stage-h`, which
|
|
452
470
|
* this never writes.
|
|
453
471
|
*/
|
|
472
|
+
/**
|
|
473
|
+
* How tall the resize rail is, right now.
|
|
474
|
+
*
|
|
475
|
+
* ๐ด Lifted into state on 2026-09-16 because TWO things need it and they must not disagree:
|
|
476
|
+
* the automatic fit below, and the DRAG's own ceiling (`containerPx`, further down). The
|
|
477
|
+
* rail is the strip of chrome the grips moved into when they came off the picture โ 64px on
|
|
478
|
+
* a mouse, 84px under a coarse pointer โ and a `MAX_STAGE_SHARE` computed against a
|
|
479
|
+
* container that pretends the rail is not there hands the stage 86% of a box it does not
|
|
480
|
+
* have, which puts the related grid back below the fold at the top of the drag. That is the
|
|
481
|
+
* 2026-08-23 *"the bottom gets cut off โฆ instead of showing more of the thumbnails under
|
|
482
|
+
* it"* report, arriving from the control added to serve it, for the second time.
|
|
483
|
+
*/
|
|
484
|
+
const [railPx, setRailPx] = useState(0);
|
|
485
|
+
|
|
454
486
|
useEffect(() => {
|
|
455
487
|
if (!watchEl || typeof ResizeObserver === 'undefined') return;
|
|
456
488
|
const host = watchEl.parentElement ?? watchEl;
|
|
@@ -458,21 +490,46 @@ export function DenseGalleryDetail({
|
|
|
458
490
|
const apply = () => {
|
|
459
491
|
const available = host.clientHeight;
|
|
460
492
|
if (!available) return; // unmeasurable โ the stylesheet's default stands
|
|
461
|
-
|
|
462
|
-
// frame it sizes. Once the player became a column (see `.cbgd-watch-player`) the bar
|
|
463
|
-
// took real height out of the same box, so a fit computed from the raw container
|
|
464
|
-
// overshot by exactly that much and pushed the last row of thumbnails through the
|
|
465
|
-
// bottom of the gallery โ the 2026-08-23 report, arriving from a control added to
|
|
466
|
-
// serve it. Measured from the element rather than written as a constant, because a
|
|
467
|
-
// constant here is a second copy of a number that lives in `mediaResize.css`.
|
|
468
|
-
const grip = watchEl.querySelector('.cbmr-bar');
|
|
493
|
+
const grip = watchEl.querySelector('.cbmr-rail');
|
|
469
494
|
const gripPx = grip ? Math.round(grip.getBoundingClientRect().height) : 0;
|
|
495
|
+
// Compared before setting, like every other measurement in this file: an observer that
|
|
496
|
+
// wrote state on each sub-pixel reflow would re-render the whole detail view forever.
|
|
497
|
+
setRailPx((current) => (current === gripPx ? current : gripPx));
|
|
498
|
+
/*
|
|
499
|
+
* ๐ด Minus EVERY pixel of the top region that is not the frame โ the resize rail, the
|
|
500
|
+
* titlebar and the padding around both.
|
|
501
|
+
*
|
|
502
|
+
* `STAGE_SHARE` is a share of what is LEFT for the grid to live in, so a fit computed
|
|
503
|
+
* from the raw container hands the stage 58% of a box that also has to hold the chrome,
|
|
504
|
+
* and the grid gets 42% minus all of it. That is the 2026-08-23 *"the bottom gets cut
|
|
505
|
+
* off for empty space instead of showing more of the thumbnails under it"* report, and
|
|
506
|
+
* it arrives from whatever control is added under the picture next.
|
|
507
|
+
*
|
|
508
|
+
* It used to subtract only `.cbmr-bar`, which was a 22px strip and left the sum just
|
|
509
|
+
* inside the margin. The grips moved into a 64px rail on 2026-09-16 and it stopped
|
|
510
|
+
* being inside it โ `e2e/stage-layout.spec.ts` caught the first row of tiles hanging
|
|
511
|
+
* 10px through the bottom of the gallery โ so the measurement is the general one now
|
|
512
|
+
* rather than one more named control.
|
|
513
|
+
*
|
|
514
|
+
* ๐ด A DIFFERENCE, and that is what makes it loop-proof: the chrome's height does not
|
|
515
|
+
* depend on the frame's, so shrinking the frame shrinks the region and leaves this
|
|
516
|
+
* number alone. The same shape `FocusViewer` uses for its own ceiling. Falls back to
|
|
517
|
+
* the rail alone if either box is missing, which is never worse than what it replaced.
|
|
518
|
+
*/
|
|
519
|
+
const top = watchEl.querySelector('.cbgd-watch-top');
|
|
520
|
+
const frame = watchEl.querySelector('.cbgd-watch-player-box');
|
|
521
|
+
const chromePx =
|
|
522
|
+
top && frame
|
|
523
|
+
? Math.round(
|
|
524
|
+
top.getBoundingClientRect().height - frame.getBoundingClientRect().height,
|
|
525
|
+
)
|
|
526
|
+
: gripPx;
|
|
470
527
|
// A ternary rather than `Math.max`, deliberately: `denseGallery.layout.spec.ts` asserts
|
|
471
528
|
// the fit expression contains no `max(` โ the CSS `min()` there may never become a
|
|
472
529
|
// `max()`, because a rule that can GROW the stage tracks the content it is measuring
|
|
473
530
|
// and chases itself. That guard reads the source text, so the JS clamp is written in a
|
|
474
531
|
// form that cannot be mistaken for the thing it forbids.
|
|
475
|
-
const usable = available >
|
|
532
|
+
const usable = available > chromePx ? available - chromePx : 0;
|
|
476
533
|
const fit = `min(${Math.round(usable * STAGE_SHARE)}px, var(--cbgd-stage-default))`;
|
|
477
534
|
if (fit === last) return;
|
|
478
535
|
last = fit;
|
|
@@ -559,7 +616,11 @@ export function DenseGalleryDetail({
|
|
|
559
616
|
handle: resizeHandle,
|
|
560
617
|
} = useMediaResize({
|
|
561
618
|
storageKey: STAGE_HEIGHT_KEY,
|
|
562
|
-
|
|
619
|
+
// ๐ด Minus the rail โ the SAME subtraction the automatic fit makes a few lines up, and for
|
|
620
|
+
// the same reason. `MAX_STAGE_SHARE` promises a band of tiles is still on screen at the
|
|
621
|
+
// largest size the handle can reach; that promise is about the space the FRAME may take,
|
|
622
|
+
// and the rail is chrome below the frame rather than part of it.
|
|
623
|
+
containerPx: Math.max(0, (watchEl?.clientHeight ?? 0) - railPx),
|
|
563
624
|
frameEl,
|
|
564
625
|
});
|
|
565
626
|
|
|
@@ -582,7 +643,7 @@ export function DenseGalleryDetail({
|
|
|
582
643
|
<div className='cbgd-watch-main'>
|
|
583
644
|
<div className='cbgd-watch-player'>
|
|
584
645
|
{/* biome-ignore lint/a11y/noStaticElementInteractions: the frame is a picture,
|
|
585
|
-
not a control โ the double-click is a SHORTCUT for the
|
|
646
|
+
not a control โ the double-click is a SHORTCUT for the `Fullscreen` toggle
|
|
586
647
|
in the titlebar, which is the keyboard-reachable route and stays the only
|
|
587
648
|
one a screen reader is told about. See `onStageDoubleClick`. */}
|
|
588
649
|
<div
|
|
@@ -595,27 +656,26 @@ export function DenseGalleryDetail({
|
|
|
595
656
|
onDoubleClick={onStageDoubleClick}
|
|
596
657
|
>
|
|
597
658
|
{stage}
|
|
598
|
-
{/*
|
|
599
|
-
* The corner grip โ the owner's literal words, *"dragging the corner"*.
|
|
600
|
-
*
|
|
601
|
-
* ๐ด Inside the clipped frame, on the picture. The note that used to stand
|
|
602
|
-
* here said a handle in this box "would be painted over by a full-bleed
|
|
603
|
-
* video and unreachable"; the big โจ โฉ step arrows that USED to hang in this
|
|
604
|
-
* same box were the counter-example โ absolutely positioned over arbitrary
|
|
605
|
-
* media, clickable every day they existed. (They are gone: the owner asked
|
|
606
|
-
* for them off the sides of the video on 2026-08-25, and stepping now lives
|
|
607
|
-
* in the titlebar and the player's control bar. Do not read this as a reason
|
|
608
|
-
* to bring them back.) What the old reasoning bought instead was a bar nobody
|
|
609
|
-
* found, reported on 2026-08-25 as the feature not existing.
|
|
610
|
-
*/}
|
|
611
|
-
{fullStage ? null : <MediaResizeCorner {...resizeHandle} label='Viewer height' />}
|
|
612
659
|
</div>
|
|
613
660
|
{/*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
661
|
+
* ๐ด The resize rail โ a SIBLING of the frame, never a child of it.
|
|
662
|
+
*
|
|
663
|
+
* Owner, `ideas/collections-09-14.md`: *"Fix the drag to resize area to no longer
|
|
664
|
+
* overlap part of the horizontal play timeline area โฆ It should only be draggable
|
|
665
|
+
* when my mouse pointer is at the bottom right corner of the video player, just
|
|
666
|
+
* like mac windows for other apps work."* The corner grip was a 64px overlay in
|
|
667
|
+
* the box above until 2026-09-16, and no gutter in the player's control bar can
|
|
668
|
+
* clear it โ `mediaResize.css`'s header has the measurement. Out here, nothing of
|
|
669
|
+
* either grip is inside the frame at any size.
|
|
670
|
+
*
|
|
671
|
+
* The bar beside it is shown on coarse pointers only; see `MediaResize.tsx`.
|
|
617
672
|
*/}
|
|
618
|
-
{fullStage ? null :
|
|
673
|
+
{fullStage ? null : (
|
|
674
|
+
<div className='cbmr-rail'>
|
|
675
|
+
<MediaResizeBar {...resizeHandle} label='Viewer height' />
|
|
676
|
+
<MediaResizeCorner {...resizeHandle} label='Viewer height' />
|
|
677
|
+
</div>
|
|
678
|
+
)}
|
|
619
679
|
</div>
|
|
620
680
|
<div className='cbgd-watch-titlebar'>
|
|
621
681
|
<span className='cbgd-watch-title'>{title}</span>
|
|
@@ -640,21 +700,28 @@ export function DenseGalleryDetail({
|
|
|
640
700
|
*/}
|
|
641
701
|
{onStep ? (
|
|
642
702
|
<span className='cbgd-watch-step'>
|
|
703
|
+
{/* Lucide chevrons rather than the `โน โบ` characters these were until
|
|
704
|
+
2026-09-16: the rest of this row is a lucide set now, and a typographic
|
|
705
|
+
glyph beside real icons is the seam that reads as unfinished โ its
|
|
706
|
+
weight, its baseline and its optical size come from whichever font the
|
|
707
|
+
host happens to have. */}
|
|
643
708
|
<GalleryActionButton
|
|
709
|
+
iconOnly
|
|
644
710
|
tooltip='Previous file (โงโ)'
|
|
645
711
|
aria-label='Previous file'
|
|
646
712
|
disabled={!canStep}
|
|
647
713
|
onClick={() => onStep(-1)}
|
|
648
714
|
>
|
|
649
|
-
<
|
|
715
|
+
<ChevronLeft aria-hidden />
|
|
650
716
|
</GalleryActionButton>
|
|
651
717
|
<GalleryActionButton
|
|
718
|
+
iconOnly
|
|
652
719
|
tooltip='Next file (โงโ)'
|
|
653
720
|
aria-label='Next file'
|
|
654
721
|
disabled={!canStep}
|
|
655
722
|
onClick={() => onStep(1)}
|
|
656
723
|
>
|
|
657
|
-
<
|
|
724
|
+
<ChevronRight aria-hidden />
|
|
658
725
|
</GalleryActionButton>
|
|
659
726
|
</span>
|
|
660
727
|
) : null}
|
|
@@ -669,11 +736,16 @@ export function DenseGalleryDetail({
|
|
|
669
736
|
// pill styling as the sort pills above โ a UI-preference toggle (not
|
|
670
737
|
// persisted, not destructive) styled by this module's own plain CSS.
|
|
671
738
|
type='button'
|
|
672
|
-
className='cbgd-order-btn'
|
|
739
|
+
className='cbgd-order-btn is-icon'
|
|
673
740
|
aria-pressed={fullStage}
|
|
741
|
+
// ๐ด The word moved into the accessible name rather than being deleted with
|
|
742
|
+
// the glyph. It is also the STATE โ `Shrink` is only offered while the stage
|
|
743
|
+
// is already filling the frame โ so a reader who cannot see which of the two
|
|
744
|
+
// arrows is drawn still learns which way this press goes.
|
|
745
|
+
aria-label={fullStage ? 'Shrink' : 'Fullscreen'}
|
|
674
746
|
onClick={() => setFullStage(!fullStage)}
|
|
675
747
|
>
|
|
676
|
-
{fullStage ?
|
|
748
|
+
{fullStage ? <Minimize2 aria-hidden /> : <Maximize2 aria-hidden />}
|
|
677
749
|
</button>
|
|
678
750
|
</Tooltip>
|
|
679
751
|
</span>
|
|
@@ -680,16 +680,23 @@ export function FocusViewer({
|
|
|
680
680
|
/>
|
|
681
681
|
{marquee ? <span className="inspector__marquee" style={marqueeStyle(marquee)} /> : null}
|
|
682
682
|
</div>
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
683
|
+
</div>
|
|
684
|
+
{/*
|
|
685
|
+
* ๐ด The resize rail โ OUTSIDE the viewport, the same shape the gallery stage uses.
|
|
686
|
+
*
|
|
687
|
+
* The corner grip used to be a child of `.inspector__viewport` (never of
|
|
688
|
+
* `.inspector__frame`, whose every fraction is a marquee/pan/crop coordinate). It is
|
|
689
|
+
* out here now for the 2026-09-14 report โ *"It should only be draggable when my mouse
|
|
690
|
+
* pointer is at the bottom right corner โฆ just like mac windows"* โ and because the
|
|
691
|
+
* bar's old `margin-block: -11px` reach hung over the picture above it AND over
|
|
692
|
+
* `.inspector__timeline` below it, which is the *"horizontal play timeline area"* in
|
|
693
|
+
* that same sentence. Both grips are inside the strip now, and the viewport is once
|
|
694
|
+
* again exactly the picture โ no pointer target of ours inside the drawing surface.
|
|
695
|
+
*/}
|
|
696
|
+
<div className="cbmr-rail">
|
|
697
|
+
<MediaResizeBar {...resizeHandle} label="Picture height" />
|
|
690
698
|
<MediaResizeCorner {...resizeHandle} label="Picture height" />
|
|
691
699
|
</div>
|
|
692
|
-
<MediaResizeBar {...resizeHandle} label="Picture height" />
|
|
693
700
|
|
|
694
701
|
<div className="inspector__transport">
|
|
695
702
|
<Button onClick={() => stepFrame(-1)} tooltip="Back one frame (โ)" variant="ghost">
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { cleanup, render } from '@testing-library/react';
|
|
5
|
+
import { FrameGrabber } from './FrameGrabber';
|
|
6
|
+
|
|
7
|
+
afterEach(cleanup);
|
|
8
|
+
|
|
9
|
+
// The dialog is a Radix portal, so everything it renders is queried off `document.body`.
|
|
10
|
+
const body = () => document.body;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The dialog's `<video>`, with a recording stub for the two methods happy-dom does not
|
|
14
|
+
* implement. Same technique, and the same reason, as `MediaGallery.spec.tsx`'s.
|
|
15
|
+
*/
|
|
16
|
+
function stubVideo(): { calls: string[]; video: HTMLVideoElement } {
|
|
17
|
+
const video = body().querySelector('video') as HTMLVideoElement;
|
|
18
|
+
expect(video, 'the dialog rendered no <video> to toggle').not.toBeNull();
|
|
19
|
+
let paused = true;
|
|
20
|
+
const calls: string[] = [];
|
|
21
|
+
Object.defineProperty(video, 'paused', { configurable: true, get: () => paused });
|
|
22
|
+
video.play = (() => {
|
|
23
|
+
calls.push('play');
|
|
24
|
+
paused = false;
|
|
25
|
+
return Promise.resolve();
|
|
26
|
+
}) as HTMLVideoElement['play'];
|
|
27
|
+
video.pause = () => {
|
|
28
|
+
calls.push('pause');
|
|
29
|
+
paused = true;
|
|
30
|
+
};
|
|
31
|
+
return { calls, video };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Dispatch a bare Space at `target`, and report whether the dialog claimed it. */
|
|
35
|
+
function press(target: EventTarget): boolean {
|
|
36
|
+
const event = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true });
|
|
37
|
+
Object.defineProperty(event, 'target', { value: target });
|
|
38
|
+
window.dispatchEvent(event);
|
|
39
|
+
return event.defaultPrevented;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function open(props: { open?: boolean; startAt?: number } = {}) {
|
|
43
|
+
return render(
|
|
44
|
+
<FrameGrabber
|
|
45
|
+
src='/films/wedding.mp4'
|
|
46
|
+
name='wedding.mp4'
|
|
47
|
+
onClose={() => {}}
|
|
48
|
+
onSaveThumbnail={() => {}}
|
|
49
|
+
onSaveSnapshot={() => {}}
|
|
50
|
+
{...props}
|
|
51
|
+
/>,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ๐ด SPACE IS PLAY/PAUSE IN HERE TOO.
|
|
57
|
+
*
|
|
58
|
+
* The owner, 2026-09-14, on the thumbnail chooser in `apps/collections`: *"the space bar
|
|
59
|
+
* should toggle playing the video no matter what is focused but isn't working on the
|
|
60
|
+
* thumbnail selection page."*
|
|
61
|
+
*
|
|
62
|
+
* Finding one frame in a two-hour wedding video is not a job for a slider you nudge โ you let
|
|
63
|
+
* it run and stop where you want it. The press was not being dropped, either: `MediaGallery`
|
|
64
|
+
* binds the same rule at capture on `window`, so it was toggling the STAGE player behind this
|
|
65
|
+
* dialog, a film the reader cannot see. That handler now stands down while a capability dialog
|
|
66
|
+
* is open (asserted in `MediaGallery.spec.tsx`), and this is the other half.
|
|
67
|
+
*/
|
|
68
|
+
describe('๐ด FrameGrabber โ Space toggles the clip you are scrubbing', () => {
|
|
69
|
+
test('a press with nothing focused plays, and the next one pauses', () => {
|
|
70
|
+
open();
|
|
71
|
+
const { calls } = stubVideo();
|
|
72
|
+
expect(press(body()), 'the dialog did not claim the press').toBe(true);
|
|
73
|
+
expect(calls).toEqual(['play']);
|
|
74
|
+
press(body());
|
|
75
|
+
expect(calls).toEqual(['play', 'pause']);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('๐ด โฆand it still toggles while one of the dialogโs own buttons has focus', () => {
|
|
79
|
+
// The state the hand is actually in: Cancel, Centre it, a crop pill or the segment was the
|
|
80
|
+
// last thing pressed, and the browser would otherwise activate it on the Space KEYUP. That
|
|
81
|
+
// inversion is the whole point of `playerSpaceKey` โ see its header.
|
|
82
|
+
open();
|
|
83
|
+
const { calls } = stubVideo();
|
|
84
|
+
const cancel = Array.from(body().querySelectorAll('button')).find(
|
|
85
|
+
(b) => b.textContent === 'Cancel',
|
|
86
|
+
);
|
|
87
|
+
expect(cancel, 'no Cancel button to focus').toBeDefined();
|
|
88
|
+
expect(press(cancel as HTMLElement)).toBe(true);
|
|
89
|
+
expect(calls).toEqual(['play']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('๐ด a press on the MOMENT slider toggles playback rather than nudging the frame', () => {
|
|
93
|
+
// `<input type="range">` is deliberately NOT text entry: the dialog is made of sliders, so
|
|
94
|
+
// standing down for one would turn the shortcut off exactly where the pointer has been.
|
|
95
|
+
open();
|
|
96
|
+
const { calls } = stubVideo();
|
|
97
|
+
const slider = body().querySelector('input[type="range"]') as HTMLElement;
|
|
98
|
+
expect(slider, 'the dialog rendered no slider').not.toBeNull();
|
|
99
|
+
expect(press(slider)).toBe(true);
|
|
100
|
+
expect(calls).toEqual(['play']);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('typing a space in a text field is never stolen', () => {
|
|
104
|
+
open();
|
|
105
|
+
const { calls } = stubVideo();
|
|
106
|
+
for (const tag of ['input', 'textarea'] as const) {
|
|
107
|
+
const field = document.createElement(tag);
|
|
108
|
+
body().appendChild(field);
|
|
109
|
+
expect(press(field), `${tag} lost its space`).toBe(false);
|
|
110
|
+
field.remove();
|
|
111
|
+
}
|
|
112
|
+
const editable = document.createElement('div');
|
|
113
|
+
editable.setAttribute('contenteditable', 'true');
|
|
114
|
+
body().appendChild(editable);
|
|
115
|
+
expect(press(editable), 'a contenteditable lost its space').toBe(false);
|
|
116
|
+
editable.remove();
|
|
117
|
+
expect(calls, 'a text field must never reach the player').toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a CLOSED grabber does not own the space bar', () => {
|
|
121
|
+
// It is mounted-but-closed for as long as a caller holds `open={false}`, and a dialog
|
|
122
|
+
// nobody can see claiming a global key is the bug one file over.
|
|
123
|
+
open({ open: false });
|
|
124
|
+
expect(body().querySelector('video'), 'a closed dialog rendered a player').toBeNull();
|
|
125
|
+
expect(press(body())).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('๐ด the rule is IMPORTED from playerSpaceKey, not re-implemented here', () => {
|
|
129
|
+
// The three previous copies of this rule disagreed in the one case the owner hits, which is
|
|
130
|
+
// why `playerSpaceKey.ts` exists. A behavioural test cannot tell a fourth copy that happens
|
|
131
|
+
// to agree today from the shared rule; this can.
|
|
132
|
+
const source = readFileSync(resolve(import.meta.dir, 'FrameGrabber.tsx'), 'utf8');
|
|
133
|
+
expect(source).toInclude("from './playerSpaceKey'");
|
|
134
|
+
expect(source).toInclude('usePlayPauseSpace(');
|
|
135
|
+
expect(source, 'a hand-rolled Space test means the shared rule was bypassed').not.toInclude(
|
|
136
|
+
"=== ' '",
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The owner's OTHER ask about this dialog, from 2026-08-13 โ *"should open to the same spot in
|
|
143
|
+
* the timeline I click it at because that's usually about where I want the snapshot"* โ was
|
|
144
|
+
* already built. This is the check that it stays built: `startAt` is React state from the first
|
|
145
|
+
* render, so the readout says where it opened before the clip has decoded a single frame.
|
|
146
|
+
*/
|
|
147
|
+
describe('FrameGrabber โ it opens at the playhead it was handed', () => {
|
|
148
|
+
test('the Moment readout shows the carried time, not 0:00', () => {
|
|
149
|
+
open({ startAt: 214.6 });
|
|
150
|
+
const readout = body().querySelector('.cbpe-readout');
|
|
151
|
+
expect(readout?.textContent).toBe('3:34');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('no playhead means the start of the clip', () => {
|
|
155
|
+
open();
|
|
156
|
+
expect(body().querySelector('.cbpe-readout')?.textContent).toBe('0:00');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ๐ด The way out is SAID, not discovered.
|
|
162
|
+
*
|
|
163
|
+
* Owner, 2026-09-14: *"I did not see the 'Done' button for a while and thought that refreshing
|
|
164
|
+
* was the only way I could find to get out of it."* The button was on screen โ that is measured
|
|
165
|
+
* at his window height in `e2e/dialog-reach.spec.ts` โ so this is the second, separate defect in
|
|
166
|
+
* the same sentence, and it is not one geometry can answer.
|
|
167
|
+
*/
|
|
168
|
+
describe('FrameGrabber โ the exit is named in the dialog itself', () => {
|
|
169
|
+
test('the subtitle says where the keep button is and what Escape does', () => {
|
|
170
|
+
open();
|
|
171
|
+
const text = (document.body.querySelector('[role="dialog"]')?.textContent ?? '').toLowerCase();
|
|
172
|
+
expect(text, 'nothing tells the reader where the keep button is').toContain('bottom');
|
|
173
|
+
expect(text, 'nothing tells the reader how to leave without saving').toContain('escape');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
NO_LIGHT,
|
|
17
17
|
type PictureLight,
|
|
18
18
|
} from './pictureEdit';
|
|
19
|
+
import { usePlayPauseSpace } from './playerSpaceKey';
|
|
19
20
|
import {
|
|
20
21
|
DEFAULT_FRAMING,
|
|
21
22
|
drawFramedPoster,
|
|
@@ -101,6 +102,29 @@ type Grab = 'thumbnail' | 'picture';
|
|
|
101
102
|
* gallery and two cannot be kept. That is the case the ruling bites on, so the button SAYS
|
|
102
103
|
* it is a replacement when there is something to replace (`hasPoster`) rather than quietly
|
|
103
104
|
* overwriting the poster somebody chose last week.
|
|
105
|
+
*
|
|
106
|
+
* โโ ๐ด STILL A DIALOG, and the owner asked for a page. Here is the reasoning โโโโโโโโโโโโโโ
|
|
107
|
+
* `ideas/collections-09-14.md`, 2026-09-14, ends: *"Let's put the thumbnail selector on it's own
|
|
108
|
+
* page to make this less confusing."* Read with the sentence before it, that is a proposed FIX
|
|
109
|
+
* for four complaints, not a fifth want โ the footer he could not reach, the folders that would
|
|
110
|
+
* not respond, the selection that did not clear this away, and the space bar.
|
|
111
|
+
*
|
|
112
|
+
* It stays a dialog, because all four are now answered where they belong and a page would cost
|
|
113
|
+
* the one thing this shape buys: **the gallery's player stays mounted behind it**, which is what
|
|
114
|
+
* makes "open at the moment I was watching" a carried number rather than a second load and a
|
|
115
|
+
* second seek of a two-hour film. A route would also have to invent a way back, and "back" from
|
|
116
|
+
* a route is the browser's history โ the control that, in the report one file over, took him out
|
|
117
|
+
* of the app.
|
|
118
|
+
*
|
|
119
|
+
* ยท the footer cannot leave the screen: `Modal` caps the card and pins the action bar, and
|
|
120
|
+
* `e2e/dialog-reach.spec.ts` MEASURES both at a 420px-tall window (proven to go red when
|
|
121
|
+
* the cap is removed โ no unit test can see it, happy-dom computes no layout);
|
|
122
|
+
* ยท selecting anything else leaves this screen: `MediaGallery` clears the dialog with the
|
|
123
|
+
* selection, which is the wire a sidebar is on;
|
|
124
|
+
* ยท Space plays and pauses the clip you are scrubbing, through `playerSpaceKey`;
|
|
125
|
+
* ยท and the way out is now SAID rather than discovered โ see the `subtitle` below, which is
|
|
126
|
+
* the half of *"I did not see the 'Done' button for a while and thought that refreshing was
|
|
127
|
+
* the only way I could find to get out of it"* that geometry does not fix.
|
|
104
128
|
*/
|
|
105
129
|
export function FrameGrabber({
|
|
106
130
|
src,
|
|
@@ -178,6 +202,38 @@ export function FrameGrabber({
|
|
|
178
202
|
return () => stage.removeEventListener('wheel', onWheel);
|
|
179
203
|
}, [grab]);
|
|
180
204
|
|
|
205
|
+
/*
|
|
206
|
+
* ๐ด Space is play/pause HERE TOO, through the fleet's one rule rather than a fourth copy.
|
|
207
|
+
*
|
|
208
|
+
* The owner, 2026-09-14: *"the space bar should toggle playing the video no matter what is
|
|
209
|
+
* focused but isn't working on the thumbnail selection page."*
|
|
210
|
+
*
|
|
211
|
+
* Two things were wrong and only one of them was here. `MediaGallery` binds the same rule at
|
|
212
|
+
* capture on `window` whenever a file is open, so the press WAS being handled โ against the
|
|
213
|
+
* stage player behind this dialog, a film the reader cannot see. That handler now stands
|
|
214
|
+
* down while a capability dialog is up (see its comment), which leaves the key for this one.
|
|
215
|
+
*
|
|
216
|
+
* Finding the moment is what this dialog is for, and letting it RUN is how you find it: a
|
|
217
|
+
* two-hour wedding video is not scrubbable a slider-nudge at a time. `usePlayPauseSpace`
|
|
218
|
+
* carries the whole rule โ capture phase, `preventDefault` on the keydown so a focused
|
|
219
|
+
* Cancel button is not activated on the keyup, and the text-entry exemption. Every control
|
|
220
|
+
* in here is a button or a `range`, which that rule deliberately takes the key back from.
|
|
221
|
+
*
|
|
222
|
+
* `enabled: open` rather than always: this component is mounted by a caller that may hold it
|
|
223
|
+
* closed, and a closed dialog owning the space bar is the bug one file over.
|
|
224
|
+
*/
|
|
225
|
+
usePlayPauseSpace(
|
|
226
|
+
() => {
|
|
227
|
+
const video = videoRef.current;
|
|
228
|
+
if (!video) return;
|
|
229
|
+
// `.play()` rejects if the element is torn down mid-gesture; there is nothing to say
|
|
230
|
+
// about that to somebody who pressed a key, and an unhandled rejection is noise.
|
|
231
|
+
if (video.paused) void video.play().catch(() => {});
|
|
232
|
+
else video.pause();
|
|
233
|
+
},
|
|
234
|
+
{ enabled: open },
|
|
235
|
+
);
|
|
236
|
+
|
|
181
237
|
const setCrop = (rect: CropRect | undefined) => setCropState(rect);
|
|
182
238
|
|
|
183
239
|
/** A pointer position as a fraction of the stage box โ the marquee's coordinate system. */
|
|
@@ -292,6 +348,17 @@ export function FrameGrabber({
|
|
|
292
348
|
onClose={onClose}
|
|
293
349
|
size='lg'
|
|
294
350
|
title={name ? `Pick a moment in ${name}` : 'Pick a moment'}
|
|
351
|
+
/*
|
|
352
|
+
* ๐ด The way OUT, stated. Owner, 2026-09-14: *"I did not see the 'Done' button for a
|
|
353
|
+
* while and thought that refreshing was the only way I could find to get out of it."*
|
|
354
|
+
*
|
|
355
|
+
* The button was on screen (that is measured now, at his window height), so this is not
|
|
356
|
+
* a layout fix wearing a sentence โ it is the other defect in the same report. The
|
|
357
|
+
* dialog is a wide two-column workspace whose one exit is a pill in the bottom corner,
|
|
358
|
+
* and a reader hunting for it has no reason to believe the corner is where it lives.
|
|
359
|
+
* `DESIGN.md` ยง6 keeps this to two muted lines; one is enough.
|
|
360
|
+
*/
|
|
361
|
+
subtitle='Scrub to the moment you want, then keep it with the button at the bottom. Escape leaves it as it was.'
|
|
295
362
|
scrollBody
|
|
296
363
|
/*
|
|
297
364
|
* Scrubbing to a moment, framing it and re-lighting it IS unsaved work, even though
|