@kolkrabbi/kol-component 0.216.0 → 0.218.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/package.json +3 -2
- package/src/atoms/ActionButton.jsx +10 -9
- package/src/atoms/FileIcon.jsx +31 -0
- package/src/atoms/ViewToggle.jsx +35 -17
- package/src/hooks/useMarquee.js +93 -0
- package/src/index.js +3 -0
- package/src/molecules/AudioPreview.jsx +4 -2
- package/src/molecules/AudioSheet.jsx +22 -36
- package/src/molecules/CodeBlock.jsx +4 -2
- package/src/molecules/ContentCard.jsx +13 -0
- package/src/molecules/ContentRow.jsx +9 -0
- package/src/molecules/DocFrontmatter.jsx +1 -1
- package/src/molecules/KindPreview.jsx +91 -5
- package/src/molecules/MediaTile.jsx +29 -0
- package/src/molecules/Modal.jsx +25 -12
- package/src/molecules/PdfPage.jsx +110 -0
- package/src/molecules/PlaybackBar.jsx +16 -8
- package/src/molecules/QuickLookFrame.jsx +88 -0
- package/src/molecules/ShellDrawer.jsx +10 -4
- package/src/molecules/Stepper.jsx +2 -2
- package/src/molecules/VideoSheet.jsx +12 -11
- package/src/organisms/ColumnBrowser.jsx +191 -37
- package/src/organisms/ContentFilters.jsx +29 -13
- package/src/organisms/MediaLibrary.jsx +2 -2
- package/src/organisms/MediaLibraryExplorer.jsx +22 -107
- package/src/organisms/MediaLibraryPages.jsx +1389 -348
- package/src/organisms/SettingsPanel.jsx +16 -6
- package/src/utilities/FullscreenOverlay.jsx +17 -3
- package/src/utilities/Popover.jsx +11 -4
- package/src/utilities/id3.js +3 -0
- package/src/utilities/layerStack.js +18 -0
- package/src/utilities/mediaKinds.js +10 -4
package/src/molecules/Modal.jsx
CHANGED
|
@@ -4,13 +4,14 @@ import Button from '../atoms/Button.jsx'
|
|
|
4
4
|
import Input from '../atoms/Input.jsx'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Modal — promise-based prompt + confirm dialogs.
|
|
7
|
+
* Modal — promise-based prompt + confirm + alert dialogs.
|
|
8
8
|
*
|
|
9
|
-
* const { prompt, confirm } = useModal()
|
|
9
|
+
* const { prompt, confirm, alert } = useModal()
|
|
10
10
|
* const name = await prompt('Name this frame:', 'Untitled')
|
|
11
11
|
* const proceed = await confirm('Discard unsaved changes?')
|
|
12
12
|
* const restore = await confirm('Restore your last canvas?',
|
|
13
13
|
* { okLabel: 'Restore', cancelLabel: 'New file' })
|
|
14
|
+
* await alert('Upload failed: quota exceeded')
|
|
14
15
|
*
|
|
15
16
|
* Both take an options object — `{ okLabel, cancelLabel }` (prompt: third
|
|
16
17
|
* arg, after defaultValue) — so the buttons can SAY the outcome; defaults
|
|
@@ -20,6 +21,9 @@ import Input from '../atoms/Input.jsx'
|
|
|
20
21
|
* Returned promise resolves to:
|
|
21
22
|
* - prompt → string (value) on submit, `null` on cancel
|
|
22
23
|
* - confirm → boolean: `true` on confirm, `false` on cancel
|
|
24
|
+
* - alert → undefined, once dismissed. One button; `okLabel` names it.
|
|
25
|
+
* Added 2026-09-22 (media-pages-route-dialogs-through-usemodal) so an
|
|
26
|
+
* error report needs no native `alert()` beside DS prompts.
|
|
23
27
|
*
|
|
24
28
|
* Mounted once at the app root (BrandLayout). Renders into `document.body`
|
|
25
29
|
* via portal, so it floats above any rail / scroll-context.
|
|
@@ -45,8 +49,12 @@ export function ModalProvider({ children }) {
|
|
|
45
49
|
new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, resolve })),
|
|
46
50
|
[])
|
|
47
51
|
|
|
52
|
+
const alert = useCallback((title, { okLabel } = {}) =>
|
|
53
|
+
new Promise((resolve) => setState({ kind: 'alert', title, okLabel, resolve })),
|
|
54
|
+
[])
|
|
55
|
+
|
|
48
56
|
return (
|
|
49
|
-
<ModalCtx.Provider value={{ prompt, confirm }}>
|
|
57
|
+
<ModalCtx.Provider value={{ prompt, confirm, alert }}>
|
|
50
58
|
{children}
|
|
51
59
|
{state && typeof document !== 'undefined' && createPortal(
|
|
52
60
|
<ModalView state={state} closeWith={closeWith} />,
|
|
@@ -60,18 +68,19 @@ function ModalView({ state, closeWith }) {
|
|
|
60
68
|
const [val, setVal] = useState(state.defaultValue ?? '')
|
|
61
69
|
const inputRef = useRef(null)
|
|
62
70
|
|
|
71
|
+
/* alert resolves `undefined` whichever way it is dismissed */
|
|
72
|
+
const submit = () => closeWith(state.kind === 'prompt' ? val : state.kind === 'alert' ? undefined : true)
|
|
73
|
+
const cancel = () => closeWith(state.kind === 'prompt' ? null : state.kind === 'alert' ? undefined : false)
|
|
74
|
+
|
|
63
75
|
useEffect(() => {
|
|
64
76
|
if (state.kind === 'prompt') inputRef.current?.focus()
|
|
65
77
|
const onKey = (e) => {
|
|
66
|
-
if (e.key === 'Escape')
|
|
67
|
-
if (e.key === 'Enter')
|
|
78
|
+
if (e.key === 'Escape') cancel()
|
|
79
|
+
if (e.key === 'Enter') submit()
|
|
68
80
|
}
|
|
69
81
|
window.addEventListener('keydown', onKey)
|
|
70
82
|
return () => window.removeEventListener('keydown', onKey)
|
|
71
|
-
}, [state.kind, val, closeWith])
|
|
72
|
-
|
|
73
|
-
const submit = () => closeWith(state.kind === 'prompt' ? val : true)
|
|
74
|
-
const cancel = () => closeWith(state.kind === 'prompt' ? null : false)
|
|
83
|
+
}, [state.kind, val, closeWith]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
75
84
|
|
|
76
85
|
return (
|
|
77
86
|
<div
|
|
@@ -97,7 +106,8 @@ function ModalView({ state, closeWith }) {
|
|
|
97
106
|
>
|
|
98
107
|
{/* kol-mono-12, not helper: dialog copy WRAPS, and helper's
|
|
99
108
|
* line-height 1 is single-line chrome only (type protocol). */}
|
|
100
|
-
|
|
109
|
+
{/* pre-line: an alert can carry a failure list, one per line */}
|
|
110
|
+
<p className="kol-mono-12 text-emphasis whitespace-pre-line">{state.title}</p>
|
|
101
111
|
{state.kind === 'prompt' && (
|
|
102
112
|
<Input
|
|
103
113
|
ref={inputRef}
|
|
@@ -109,7 +119,7 @@ function ModalView({ state, closeWith }) {
|
|
|
109
119
|
/>
|
|
110
120
|
)}
|
|
111
121
|
<div className="flex gap-2 justify-end">
|
|
112
|
-
<Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>
|
|
122
|
+
{state.kind !== 'alert' && <Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>}
|
|
113
123
|
<Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
|
|
114
124
|
</div>
|
|
115
125
|
</div>
|
|
@@ -129,7 +139,7 @@ export function useModal() {
|
|
|
129
139
|
* don't need to null-check. */
|
|
130
140
|
if (!warnedNoProvider && typeof console !== 'undefined') {
|
|
131
141
|
warnedNoProvider = true
|
|
132
|
-
console.warn('[kol] useModal(): no <ModalProvider> mounted — falling back to native window.prompt/confirm. Custom labels are ignored on the fallback.')
|
|
142
|
+
console.warn('[kol] useModal(): no <ModalProvider> mounted — falling back to native window.prompt/confirm/alert. Custom labels are ignored on the fallback.')
|
|
133
143
|
}
|
|
134
144
|
return {
|
|
135
145
|
prompt: async (title, def = '') => {
|
|
@@ -141,5 +151,8 @@ export function useModal() {
|
|
|
141
151
|
if (typeof window === 'undefined') return false
|
|
142
152
|
return window.confirm(title)
|
|
143
153
|
},
|
|
154
|
+
alert: async (title) => {
|
|
155
|
+
if (typeof window !== 'undefined') window.alert(title)
|
|
156
|
+
},
|
|
144
157
|
}
|
|
145
158
|
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/* taxonomy-ok: molecule — a renderer KindPreview composes; no DS parts of its own to nest. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PdfPage · PdfDocument — a PDF drawn flat to canvases. The thumbnail Finder, Drive and Dropbox
|
|
7
|
+
* all show (user 2026-09-23: *"why is pdf preview inconsistent"*). The browser's `<embed>` is a
|
|
8
|
+
* viewer — Chrome paints its toolbar over the tile whatever the URL fragment says — so every fit
|
|
9
|
+
* draws pictures with pdf.js instead, and Quick Look gets Finder's layout from the same renderer.
|
|
10
|
+
*
|
|
11
|
+
* PdfPage page one, sized to its box — tiles and the column pane
|
|
12
|
+
* PdfDocument every page in a scroller with a thumbnail rail — the Quick Look window
|
|
13
|
+
*
|
|
14
|
+
* pdf.js is imported on first use, so a page with no PDF never loads it. A canvas paints at its
|
|
15
|
+
* box's width × device pixel ratio, and only once it scrolls into view.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} src the PDF's URL
|
|
18
|
+
* @param {ReactNode} fallback shown if the file cannot be parsed
|
|
19
|
+
*/
|
|
20
|
+
let pdfjs = null
|
|
21
|
+
async function loadPdfjs() {
|
|
22
|
+
if (pdfjs) return pdfjs
|
|
23
|
+
const [lib, worker] = await Promise.all([
|
|
24
|
+
import('pdfjs-dist'),
|
|
25
|
+
import('pdfjs-dist/build/pdf.worker.min.mjs?url'),
|
|
26
|
+
])
|
|
27
|
+
lib.GlobalWorkerOptions.workerSrc = worker.default
|
|
28
|
+
pdfjs = lib
|
|
29
|
+
return lib
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function usePdf(src) {
|
|
33
|
+
const [state, setState] = useState({ src: null, doc: null, failed: false })
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false
|
|
36
|
+
let loading = null
|
|
37
|
+
loadPdfjs()
|
|
38
|
+
.then((lib) => { loading = lib.getDocument({ url: src }); return loading.promise })
|
|
39
|
+
.then((doc) => { if (!cancelled) setState({ src, doc, failed: false }) })
|
|
40
|
+
.catch(() => { if (!cancelled) setState({ src, doc: null, failed: true }) })
|
|
41
|
+
return () => { cancelled = true; loading?.destroy() }
|
|
42
|
+
}, [src])
|
|
43
|
+
return state.src === src ? state : { doc: null, failed: false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function PdfCanvas({ doc, page, className = '' }) {
|
|
47
|
+
const ref = useRef(null)
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
const canvas = ref.current
|
|
50
|
+
if (!doc || !canvas) return undefined
|
|
51
|
+
let task = null
|
|
52
|
+
let done = false
|
|
53
|
+
const paint = async () => {
|
|
54
|
+
done = true
|
|
55
|
+
const p = await doc.getPage(page)
|
|
56
|
+
const width = canvas.clientWidth || canvas.parentElement?.clientWidth || 400
|
|
57
|
+
const base = p.getViewport({ scale: 1 })
|
|
58
|
+
const viewport = p.getViewport({ scale: (width / base.width) * (window.devicePixelRatio || 1) })
|
|
59
|
+
canvas.width = viewport.width
|
|
60
|
+
canvas.height = viewport.height
|
|
61
|
+
task = p.render({ canvas, viewport })
|
|
62
|
+
await task.promise.catch(() => {})
|
|
63
|
+
}
|
|
64
|
+
const io = new IntersectionObserver(([e]) => { if (e.isIntersecting && !done) { io.disconnect(); paint() } })
|
|
65
|
+
io.observe(canvas)
|
|
66
|
+
return () => { io.disconnect(); task?.cancel() }
|
|
67
|
+
}, [doc, page])
|
|
68
|
+
return <canvas ref={ref} className={`kol-pdf-page ${className}`.trim()} />
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default function PdfPage({ src, fallback = null, className = '' }) {
|
|
72
|
+
const { doc, failed } = usePdf(src)
|
|
73
|
+
if (failed) return fallback
|
|
74
|
+
return <PdfCanvas doc={doc} page={1} className={className} />
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function PdfDocument({ src, fallback = null }) {
|
|
78
|
+
const { doc, failed } = usePdf(src)
|
|
79
|
+
const [current, setCurrent] = useState(1)
|
|
80
|
+
const mainRef = useRef(null)
|
|
81
|
+
const pages = doc ? Array.from({ length: doc.numPages }, (_, i) => i + 1) : []
|
|
82
|
+
/* the rail follows the scroller: whichever page holds the middle of the view is current */
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const main = mainRef.current
|
|
85
|
+
if (!main || !doc) return undefined
|
|
86
|
+
const io = new IntersectionObserver((entries) => {
|
|
87
|
+
for (const e of entries) if (e.isIntersecting) setCurrent(Number(e.target.dataset.page))
|
|
88
|
+
}, { root: main, rootMargin: '-50% 0px -50% 0px' })
|
|
89
|
+
main.querySelectorAll('[data-page]').forEach((el) => io.observe(el))
|
|
90
|
+
return () => io.disconnect()
|
|
91
|
+
}, [doc])
|
|
92
|
+
if (failed) return fallback
|
|
93
|
+
const go = (n) => mainRef.current?.querySelector(`[data-page="${n}"]`)?.scrollIntoView({ block: 'start', behavior: 'smooth' })
|
|
94
|
+
return (
|
|
95
|
+
<div className="kol-pdf-document">
|
|
96
|
+
<div ref={mainRef} className="kol-pdf-document-main">
|
|
97
|
+
{pages.map((n) => <div key={n} data-page={n}><PdfCanvas doc={doc} page={n} /></div>)}
|
|
98
|
+
</div>
|
|
99
|
+
{pages.length > 1 && (
|
|
100
|
+
<div className="kol-pdf-document-rail">
|
|
101
|
+
{pages.map((n) => (
|
|
102
|
+
<button key={n} type="button" onClick={() => go(n)} aria-label={`Page ${n}`} aria-current={n === current || undefined}>
|
|
103
|
+
<PdfCanvas doc={doc} page={n} />
|
|
104
|
+
</button>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
@@ -37,24 +37,32 @@ const RATES = [1, 1.5, 2]
|
|
|
37
37
|
* @param {Function} onVolume (0..1) => void
|
|
38
38
|
* @param {Function} onRate (rate) => void — omit to hide the speed control
|
|
39
39
|
* @param {string} place the strip's position classes (see above)
|
|
40
|
+
* @param {boolean} docked in a window's footer (`QuickLookFrame`, 2026-09-23): in flow, full
|
|
41
|
+
* width, 48 tall, the 4px container radius (the 12 is the FLOATING bar's
|
|
42
|
+
* ruling, not a window's), and the THEME's ink on an oq plate — it sits on the
|
|
43
|
+
* window, not on media, so white glyphs would vanish in light; `place`
|
|
44
|
+
* is ignored
|
|
40
45
|
*/
|
|
41
|
-
export default function PlaybackBar({ playing, time, duration, rate = 1, onToggle, onSeek, onVolume, onRate, place = 'left-4 right-4 bottom-4 rounded-xl' }) {
|
|
46
|
+
export default function PlaybackBar({ playing, time, duration, rate = 1, onToggle, onSeek, onVolume, onRate, place = 'left-4 right-4 bottom-4 rounded-xl', docked = false }) {
|
|
42
47
|
const [volume, setVolume] = useState(100)
|
|
43
48
|
const [open, setOpen] = useState(false)
|
|
44
49
|
const pop = usePopover({ open, onOpenChange: setOpen, placement: 'top' })
|
|
45
50
|
const nextRate = RATES[(RATES.indexOf(rate) + 1) % RATES.length]
|
|
46
51
|
return (
|
|
47
|
-
<div className={`kol-playback-bar
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
<div className={`kol-playback-bar flex items-center ${docked ? 'kol-playback-bar--docked relative w-full h-12 px-4 gap-5 rounded' : `h-16 px-8 gap-7 bg-fg-ab-48 text-ab-white absolute backdrop-blur-xl ${place}`}`}>
|
|
53
|
+
{/* TARGETS YOU CAN HIT (user 2026-09-23: *"the click range around the icons is very small …
|
|
54
|
+
* it should be easy to press pause and play"*). Play/pause is the big one (40px), the skips
|
|
55
|
+
* 32px, and each glyph grows with its frame — at 16px the 15s numerals clogged. */}
|
|
56
|
+
<div className="flex items-center gap-3">
|
|
57
|
+
<IconFrame name="skip-back-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time - 15)} aria-label="Back 15 seconds" />
|
|
58
|
+
<IconFrame name={playing ? 'pause' : 'play'} variant="ghost" size="lg" iconSize={24} onClick={onToggle} aria-label={playing ? 'Pause' : 'Play'} />
|
|
59
|
+
<IconFrame name="skip-forward-15" variant="ghost" size="md" iconSize={22} onClick={() => onSeek(time + 15)} aria-label="Forward 15 seconds" />
|
|
52
60
|
</div>
|
|
53
61
|
<span className="kol-mono-16 tabular-nums opacity-80">{clock(time)}</span>
|
|
54
62
|
<input type="range" className="kol-playback-scrub flex-1 min-w-0" min={0} max={duration || 0} step={0.1} value={time} onChange={(e) => onSeek(Number(e.target.value))} aria-label="Scrub" />
|
|
55
63
|
<span className="kol-mono-16 tabular-nums opacity-80">{clock(duration)}</span>
|
|
56
64
|
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
|
57
|
-
<IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="
|
|
65
|
+
<IconFrame name="speaker" variant={open ? 'secondary' : 'ghost'} size="md" onClick={() => {}} aria-label="Volume" />
|
|
58
66
|
</span>
|
|
59
67
|
<PopoverPanel popover={pop} className="p-2">
|
|
60
68
|
<div className="w-6 h-24 flex items-center justify-center">
|
|
@@ -71,7 +79,7 @@ export default function PlaybackBar({ playing, time, duration, rate = 1, onToggl
|
|
|
71
79
|
</PopoverPanel>
|
|
72
80
|
{onRate && (
|
|
73
81
|
<span className="flex items-center gap-1">
|
|
74
|
-
<IconFrame name="chevrons-right" variant="ghost" size="
|
|
82
|
+
<IconFrame name="chevrons-right" variant="ghost" size="md" onClick={() => onRate(nextRate)} aria-label={`Playback speed ${rate}× — next ${nextRate}×`} />
|
|
75
83
|
{rate !== 1 && <span className="kol-mono-12 tabular-nums opacity-80">{rate}×</span>}
|
|
76
84
|
</span>
|
|
77
85
|
)}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useRef, useState } from 'react'
|
|
2
|
+
import Button from '../atoms/Button.jsx'
|
|
3
|
+
import CloseButton from '../utilities/CloseButton.jsx'
|
|
4
|
+
|
|
5
|
+
/* taxonomy-ok: molecule — nests Button (atom) + CloseButton (utility). */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* QuickLookFrame — the Finder Quick Look window (user 2026-09-23: *"put them in a container LIKE
|
|
9
|
+
* FINDER … it just makes everything in the design easier to manage if its wrapped together"*).
|
|
10
|
+
* Every kind sat loose on the scrim with its own chrome — a caption line under a picture, a bar
|
|
11
|
+
* floating over a video, a see-through page — so no two kinds looked like one feature. One
|
|
12
|
+
* opaque window now holds all of them:
|
|
13
|
+
*
|
|
14
|
+
* header close · ‹ › n / N · the file's name · its facts · the kind's actions, right-aligned
|
|
15
|
+
* body the media at its own aspect ratio, capped to the viewport — the window hugs it
|
|
16
|
+
* footer the transport, when the kind has one (`PlaybackBar docked`)
|
|
17
|
+
*
|
|
18
|
+
* RESIZABLE (user 2026-09-23, Finder's corner): drag the bottom-right corner and the window takes
|
|
19
|
+
* that size; the body's caps follow it, so media still fits and documents get the room. `size` /
|
|
20
|
+
* `onResize` let a caller keep the size across files (Finder keeps it); without them the window
|
|
21
|
+
* holds its own.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} title the file's name
|
|
24
|
+
* @param {ReactNode} meta facts after the name (size · dimensions · length)
|
|
25
|
+
* @param {ReactNode} actions the kind's controls, right-aligned in the header
|
|
26
|
+
* @param {Function} onClose shows the close control when given
|
|
27
|
+
* @param {Object} nav `{ index, total, onPrev, onNext }` — ‹ › and `1 / 2` beside the close,
|
|
28
|
+
* Finder's, when the window pages through more than one file
|
|
29
|
+
* @param {Object} size `{ w, h }` in px, or null for the automatic size
|
|
30
|
+
* @param {Function} onResize ({ w, h }) => void while the corner is dragged
|
|
31
|
+
* @param {ReactNode} footer the transport row
|
|
32
|
+
* @param {ReactNode} children the body
|
|
33
|
+
*/
|
|
34
|
+
export default function QuickLookFrame({ title, meta, actions, onClose, nav, size: sizeProp, onResize, footer, className = '', children }) {
|
|
35
|
+
const [own, setOwn] = useState(null)
|
|
36
|
+
const size = sizeProp !== undefined ? sizeProp : own
|
|
37
|
+
const setSize = onResize ?? setOwn
|
|
38
|
+
const ref = useRef(null)
|
|
39
|
+
const drag = useRef(null)
|
|
40
|
+
const header = title || meta || actions || onClose || nav
|
|
41
|
+
|
|
42
|
+
const onPointerDown = (e) => {
|
|
43
|
+
e.preventDefault()
|
|
44
|
+
const box = ref.current.getBoundingClientRect()
|
|
45
|
+
drag.current = { x: e.clientX, y: e.clientY, w: box.width, h: box.height }
|
|
46
|
+
e.currentTarget.setPointerCapture(e.pointerId)
|
|
47
|
+
}
|
|
48
|
+
const onPointerMove = (e) => {
|
|
49
|
+
const d = drag.current
|
|
50
|
+
if (!d) return
|
|
51
|
+
/* the window is centred, so a corner drag grows it on both sides — twice the pointer's travel */
|
|
52
|
+
const w = Math.max(360, Math.min(window.innerWidth - 32, d.w + (e.clientX - d.x) * 2))
|
|
53
|
+
const h = Math.max(240, Math.min(window.innerHeight - 32, d.h + (e.clientY - d.y) * 2))
|
|
54
|
+
setSize({ w: Math.round(w), h: Math.round(h) })
|
|
55
|
+
}
|
|
56
|
+
const onPointerUp = () => { drag.current = null }
|
|
57
|
+
|
|
58
|
+
const style = size ? { width: size.w, height: size.h, '--kol-ql-frame-h': `${size.h}px`, '--kol-ql-max-w': `${size.w - 2}px` } : undefined
|
|
59
|
+
return (
|
|
60
|
+
<div ref={ref} style={style}
|
|
61
|
+
className={`kol-quicklook${footer ? ' kol-quicklook--footer' : ''}${size ? ' kol-quicklook--sized' : ''} ${className}`.trim()}>
|
|
62
|
+
{header && (
|
|
63
|
+
<div className="kol-quicklook-header">
|
|
64
|
+
{/* THE ESTATE'S CLOSE IDIOM, and every header control wears it (user 2026-09-23): bare at
|
|
65
|
+
* rest, a wash on hover — a filled container read as a second surface on the header */}
|
|
66
|
+
{onClose && <CloseButton size="sm" onClick={onClose} />}
|
|
67
|
+
{nav?.total > 1 && (
|
|
68
|
+
/* the pager is its own group: the count sits off the chevrons, and the group sits clear of
|
|
69
|
+
* the file's name (user 2026-09-23) */
|
|
70
|
+
<span className="flex items-center gap-1 mr-3">
|
|
71
|
+
<Button variant="nav" size="sm" iconOnly="chevron-left" onClick={nav.onPrev} aria-label="Previous" />
|
|
72
|
+
<Button variant="nav" size="sm" iconOnly="chevron-right" onClick={nav.onNext} aria-label="Next" />
|
|
73
|
+
<span className="kol-mono-12 text-meta tabular-nums ml-3">{nav.index + 1} / {nav.total}</span>
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
{title && <span className="kol-mono-12 text-strong truncate">{title}</span>}
|
|
77
|
+
{meta && <span className="kol-mono-12 text-meta truncate">{meta}</span>}
|
|
78
|
+
<span className="flex-1" />
|
|
79
|
+
{actions}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
<div className="kol-quicklook-body">{children}</div>
|
|
83
|
+
{footer && <div className="kol-quicklook-footer">{footer}</div>}
|
|
84
|
+
<span className="kol-quicklook-grip" role="separator" aria-label="Resize"
|
|
85
|
+
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} />
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { pushLayer, popLayer, isTopLayer } from '../utilities/layerStack.js'
|
|
2
3
|
import { createPortal } from 'react-dom'
|
|
3
4
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
5
|
import CloseButton from '../utilities/CloseButton.jsx'
|
|
@@ -97,12 +98,17 @@ export default function ShellDrawer({
|
|
|
97
98
|
return () => clearTimeout(t)
|
|
98
99
|
}, [open, reduced])
|
|
99
100
|
|
|
100
|
-
// Escape closes; Tab is trapped inside the panel while open
|
|
101
|
+
// Escape closes; Tab is trapped inside the panel while open — while this is the TOP layer
|
|
102
|
+
// (`layerStack`: a sheet opened over the drawer owns the keyboard until it closes)
|
|
103
|
+
const onCloseRef = useRef(onClose)
|
|
104
|
+
onCloseRef.current = onClose
|
|
101
105
|
useEffect(() => {
|
|
102
106
|
if (!open) return undefined
|
|
107
|
+
const layer = pushLayer()
|
|
103
108
|
const onKey = (e) => {
|
|
109
|
+
if (!isTopLayer(layer)) return
|
|
104
110
|
if (e.key === 'Escape') {
|
|
105
|
-
|
|
111
|
+
onCloseRef.current?.()
|
|
106
112
|
return
|
|
107
113
|
}
|
|
108
114
|
if (e.key !== 'Tab') return
|
|
@@ -129,8 +135,8 @@ export default function ShellDrawer({
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
document.addEventListener('keydown', onKey)
|
|
132
|
-
return () => document.removeEventListener('keydown', onKey)
|
|
133
|
-
}, [open
|
|
138
|
+
return () => { popLayer(layer); document.removeEventListener('keydown', onKey) }
|
|
139
|
+
}, [open])
|
|
134
140
|
|
|
135
141
|
/* Body scroll-lock + focus in/out. Gated on `present` too so the panel
|
|
136
142
|
* exists before we focus it (it mounts one commit after `open` flips). */
|
|
@@ -79,11 +79,11 @@ export default function Stepper({
|
|
|
79
79
|
const chev = CHEVRON_SIZE[size]
|
|
80
80
|
return (
|
|
81
81
|
<div className={`inline-flex items-center justify-center gap-0.5 select-none ${SIZE_TYPE[size]} ${className}`.trim()} style={style}>
|
|
82
|
-
<button type="button" onClick={handleDecrement} className="inline-flex items-center justify-center px-0.5 text-
|
|
82
|
+
<button type="button" onClick={handleDecrement} className="inline-flex items-center justify-center px-0.5 text-oq-40 hover:text-emphasis transition-colors" aria-label="Previous">
|
|
83
83
|
<Icon name="chevron-left" size={chev} />
|
|
84
84
|
</button>
|
|
85
85
|
<span className="min-w-[3ch] text-center text-fg-64" {...props}>{value ?? ''}</span>
|
|
86
|
-
<button type="button" onClick={handleIncrement} className="inline-flex items-center justify-center px-0.5 text-
|
|
86
|
+
<button type="button" onClick={handleIncrement} className="inline-flex items-center justify-center px-0.5 text-oq-40 hover:text-emphasis transition-colors" aria-label="Next">
|
|
87
87
|
<Icon name="chevron-right" size={chev} />
|
|
88
88
|
</button>
|
|
89
89
|
</div>
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import usePlayback from '../hooks/usePlayback.js'
|
|
2
2
|
import PlaybackBar from './PlaybackBar.jsx'
|
|
3
|
+
import QuickLookFrame from './QuickLookFrame.jsx'
|
|
3
4
|
|
|
4
|
-
/* taxonomy-ok: molecule — nests PlaybackBar (relative). */
|
|
5
|
+
/* taxonomy-ok: molecule — nests PlaybackBar + QuickLookFrame (relative). */
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
* VideoSheet — the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* VideoSheet — video in the Quick Look window: the frame at the video's own aspect ratio, the
|
|
9
|
+
* QuickTime bar docked in the window's footer (2026-09-23 — it floated over the picture, which
|
|
10
|
+
* made video the one kind whose controls sat on the media). No native controls; click on the
|
|
11
|
+
* video toggles play; no autoplay (user 2026-09-23 — Quick Look opens paused), `playsInline`, `preload="metadata"`.
|
|
11
12
|
*
|
|
12
13
|
* @param {string} src the video URL
|
|
13
14
|
* @param {string} poster the poster URL
|
|
14
|
-
* @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the
|
|
15
|
+
* @param {Function} onMeta ({ w, h, len }) => void once metadata lands — the window's facts
|
|
16
|
+
* @param {Object} frame the window's header — `QuickLookFrame`'s title · meta · actions · onClose
|
|
15
17
|
*/
|
|
16
|
-
export default function VideoSheet({ src, poster, onMeta }) {
|
|
18
|
+
export default function VideoSheet({ src, poster, onMeta, frame }) {
|
|
17
19
|
const { ref, handlers, bar } = usePlayback((el) => onMeta?.({ w: el.videoWidth, h: el.videoHeight, len: el.duration }))
|
|
18
20
|
return (
|
|
19
|
-
<
|
|
20
|
-
<video ref={ref} src={src} poster={poster}
|
|
21
|
-
|
|
22
|
-
</div>
|
|
21
|
+
<QuickLookFrame {...frame} footer={<PlaybackBar {...bar} docked />}>
|
|
22
|
+
<video ref={ref} src={src} poster={poster} playsInline preload="metadata" className="kol-quicklook-media" onClick={bar.onToggle} {...handlers} />
|
|
23
|
+
</QuickLookFrame>
|
|
23
24
|
)
|
|
24
25
|
}
|