@djangocfg/ui-tools 2.1.416 → 2.1.417
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/audio-player/index.cjs +2099 -0
- package/dist/audio-player/index.cjs.map +1 -0
- package/dist/audio-player/index.css +65 -0
- package/dist/audio-player/index.css.map +1 -0
- package/dist/audio-player/index.d.cts +174 -0
- package/dist/audio-player/index.d.ts +174 -0
- package/dist/audio-player/index.mjs +2076 -0
- package/dist/audio-player/index.mjs.map +1 -0
- package/dist/composer-registry/index.cjs +45 -0
- package/dist/composer-registry/index.cjs.map +1 -0
- package/dist/composer-registry/index.d.cts +73 -0
- package/dist/composer-registry/index.d.ts +73 -0
- package/dist/composer-registry/index.mjs +39 -0
- package/dist/composer-registry/index.mjs.map +1 -0
- package/dist/tree/index.cjs +85 -63
- package/dist/tree/index.cjs.map +1 -1
- package/dist/tree/index.d.cts +15 -1
- package/dist/tree/index.d.ts +15 -1
- package/dist/tree/index.mjs +86 -64
- package/dist/tree/index.mjs.map +1 -1
- package/package.json +14 -9
- package/src/tools/chat/composer/Composer.tsx +8 -8
- package/src/tools/chat/context/ChatProvider.tsx +13 -78
- package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
- package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
- package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
- package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
- package/src/tools/data/Tree/TreeRoot.tsx +33 -109
- package/src/tools/data/Tree/components/TreeRow.tsx +11 -0
- package/src/tools/data/Tree/context/TreeContext.tsx +22 -3
- package/src/tools/data/Tree/context/menu/index.ts +1 -0
- package/src/tools/data/Tree/context/menu/render.tsx +75 -0
- package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +16 -2
- package/src/tools/data/Tree/index.tsx +1 -0
- package/src/tools/data/Tree/types/index.ts +1 -1
- package/src/tools/data/Tree/types/root-props.ts +16 -0
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
- package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
- package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
- package/src/tools/integration/ComposerRegistry/index.ts +105 -0
- package/src/tools/media/AudioPlayer/Player.tsx +2 -0
- package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
- package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
- package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
- package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
- package/src/tools/media/AudioPlayer/types.ts +15 -0
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
DEFAULT_TREE_LABELS,
|
|
11
11
|
type FlatRow,
|
|
12
12
|
type TreeActivateOptions,
|
|
13
|
+
type TreeContextMenuSlot,
|
|
13
14
|
type TreeItemId,
|
|
14
15
|
type TreeLabels,
|
|
15
16
|
type TreeNode,
|
|
@@ -22,7 +23,7 @@ import { useExpansion } from './expansion';
|
|
|
22
23
|
import { useSelection } from './selection';
|
|
23
24
|
import { useRename } from './rename';
|
|
24
25
|
import { useClipboard } from './clipboard';
|
|
25
|
-
import { useResolvedMenu } from './menu';
|
|
26
|
+
import { useResolvedMenu, renderItemsAsContextMenu, tidyMenuItems } from './menu';
|
|
26
27
|
import { useDnd, type UseDndReturn } from './dnd';
|
|
27
28
|
import { usePersistSync } from './persist';
|
|
28
29
|
import type { TreeContextValue } from './TreeContextValue';
|
|
@@ -282,6 +283,24 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
|
|
|
282
283
|
pasteFromClipboard: clipboard.pasteFromClipboard,
|
|
283
284
|
});
|
|
284
285
|
|
|
286
|
+
// Translate the declarative resolver into a slot-form
|
|
287
|
+
// `renderContextMenu` so <TreeRow> doesn't need to know about it.
|
|
288
|
+
// Explicit slot prop wins (escape-hatch for full custom menus).
|
|
289
|
+
const finalRenderContextMenu = useMemo<TreeContextMenuSlot<T> | undefined>(
|
|
290
|
+
() => {
|
|
291
|
+
if (renderContextMenu) return renderContextMenu;
|
|
292
|
+
const resolve = resolvedContextMenuActions;
|
|
293
|
+
if (!resolve) return undefined;
|
|
294
|
+
return (rowProps, trigger) => {
|
|
295
|
+
const items = resolve(rowProps);
|
|
296
|
+
const cleaned = items ? tidyMenuItems(items) : null;
|
|
297
|
+
if (!cleaned || cleaned.length === 0) return trigger;
|
|
298
|
+
return renderItemsAsContextMenu(rowProps, cleaned, trigger);
|
|
299
|
+
};
|
|
300
|
+
},
|
|
301
|
+
[renderContextMenu, resolvedContextMenuActions],
|
|
302
|
+
);
|
|
303
|
+
|
|
285
304
|
// ---- Final value --------------------------------------------------
|
|
286
305
|
|
|
287
306
|
const value = useMemo<TreeContextValue<T>>(
|
|
@@ -333,7 +352,7 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
|
|
|
333
352
|
renderIcon,
|
|
334
353
|
renderLabel,
|
|
335
354
|
renderActions,
|
|
336
|
-
renderContextMenu,
|
|
355
|
+
renderContextMenu: finalRenderContextMenu,
|
|
337
356
|
|
|
338
357
|
adapter,
|
|
339
358
|
resolvedContextMenuActions,
|
|
@@ -371,7 +390,7 @@ export function TreeProvider<T>(props: TreeProviderProps<T>) {
|
|
|
371
390
|
renderIcon,
|
|
372
391
|
renderLabel,
|
|
373
392
|
renderActions,
|
|
374
|
-
|
|
393
|
+
finalRenderContextMenu,
|
|
375
394
|
adapter,
|
|
376
395
|
resolvedContextMenuActions,
|
|
377
396
|
nodeById,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ContextMenu,
|
|
5
|
+
ContextMenuContent,
|
|
6
|
+
ContextMenuItem,
|
|
7
|
+
ContextMenuSeparator,
|
|
8
|
+
ContextMenuShortcut,
|
|
9
|
+
ContextMenuTrigger,
|
|
10
|
+
} from '@djangocfg/ui-core/components';
|
|
11
|
+
|
|
12
|
+
import type {
|
|
13
|
+
TreeContextMenuItem,
|
|
14
|
+
TreeRowRenderProps,
|
|
15
|
+
} from '../../types';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Render an array of declarative menu items as a themed `<ContextMenu>`
|
|
19
|
+
* wrapped around the supplied trigger element. Pure presentational layer
|
|
20
|
+
* — the caller resolves and merges items.
|
|
21
|
+
*/
|
|
22
|
+
export function renderItemsAsContextMenu<T>(
|
|
23
|
+
rowProps: TreeRowRenderProps<T>,
|
|
24
|
+
items: TreeContextMenuItem<T>[],
|
|
25
|
+
trigger: React.ReactNode,
|
|
26
|
+
): React.ReactNode {
|
|
27
|
+
return (
|
|
28
|
+
<ContextMenu>
|
|
29
|
+
<ContextMenuTrigger asChild>{trigger}</ContextMenuTrigger>
|
|
30
|
+
<ContextMenuContent>
|
|
31
|
+
{items.map((item, idx) => {
|
|
32
|
+
if (item === 'separator') {
|
|
33
|
+
return <ContextMenuSeparator key={`sep-${idx}`} />;
|
|
34
|
+
}
|
|
35
|
+
const Icon = item.icon;
|
|
36
|
+
return (
|
|
37
|
+
<ContextMenuItem
|
|
38
|
+
key={item.id}
|
|
39
|
+
disabled={item.disabled}
|
|
40
|
+
variant={item.destructive ? 'destructive' : undefined}
|
|
41
|
+
onSelect={() => item.onSelect(rowProps)}
|
|
42
|
+
>
|
|
43
|
+
{Icon ? <Icon /> : null}
|
|
44
|
+
{item.label}
|
|
45
|
+
{item.shortcut ? (
|
|
46
|
+
<ContextMenuShortcut>{item.shortcut}</ContextMenuShortcut>
|
|
47
|
+
) : null}
|
|
48
|
+
</ContextMenuItem>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</ContextMenuContent>
|
|
52
|
+
</ContextMenu>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Drop trailing / leading / duplicate separators so a merged menu never
|
|
58
|
+
* shows a separator next to a section header or another separator.
|
|
59
|
+
*/
|
|
60
|
+
export function tidyMenuItems<T>(
|
|
61
|
+
items: TreeContextMenuItem<T>[],
|
|
62
|
+
): TreeContextMenuItem<T>[] {
|
|
63
|
+
const out: TreeContextMenuItem<T>[] = [];
|
|
64
|
+
for (const it of items) {
|
|
65
|
+
if (it === 'separator') {
|
|
66
|
+
if (out.length === 0) continue;
|
|
67
|
+
if (out[out.length - 1] === 'separator') continue;
|
|
68
|
+
out.push(it);
|
|
69
|
+
} else {
|
|
70
|
+
out.push(it);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
while (out.length > 0 && out[out.length - 1] === 'separator') out.pop();
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -7,16 +7,30 @@ import type {
|
|
|
7
7
|
TreeAdapter,
|
|
8
8
|
TreeBuiltinAction,
|
|
9
9
|
TreeContextMenuActionsResolver,
|
|
10
|
+
TreeContextMenuItem,
|
|
10
11
|
TreeItemId,
|
|
11
12
|
TreeLabels,
|
|
12
13
|
TreeMovePosition,
|
|
13
14
|
TreeNode,
|
|
15
|
+
TreeRowRenderProps,
|
|
14
16
|
} from '../../types';
|
|
17
|
+
|
|
18
|
+
export type { TreeContextMenuActionsResolver };
|
|
15
19
|
import {
|
|
16
20
|
buildDefaultMenuItems,
|
|
17
21
|
type BuiltinActionContext,
|
|
18
22
|
} from './builtin-actions';
|
|
19
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Internal row-driven resolver — same shape as
|
|
26
|
+
* `TreeContextMenuActionsResolver` but takes a plain `TreeRowRenderProps`
|
|
27
|
+
* instead of the context-with-selection. The provider injects
|
|
28
|
+
* `selectedNodes` itself.
|
|
29
|
+
*/
|
|
30
|
+
export type ResolvedMenuResolver<T> = (
|
|
31
|
+
row: TreeRowRenderProps<T>,
|
|
32
|
+
) => TreeContextMenuItem<T>[] | null | undefined;
|
|
33
|
+
|
|
20
34
|
export interface UseResolvedMenuOptions<T> {
|
|
21
35
|
adapter?: TreeAdapter<T>;
|
|
22
36
|
contextMenuActions?: TreeContextMenuActionsResolver<T>;
|
|
@@ -50,7 +64,7 @@ export interface UseResolvedMenuOptions<T> {
|
|
|
50
64
|
*/
|
|
51
65
|
export function useResolvedMenu<T>(
|
|
52
66
|
opts: UseResolvedMenuOptions<T>,
|
|
53
|
-
):
|
|
67
|
+
): ResolvedMenuResolver<T> | undefined {
|
|
54
68
|
const {
|
|
55
69
|
adapter,
|
|
56
70
|
contextMenuActions,
|
|
@@ -67,7 +81,7 @@ export function useResolvedMenu<T>(
|
|
|
67
81
|
pasteFromClipboard,
|
|
68
82
|
} = opts;
|
|
69
83
|
|
|
70
|
-
return useMemo<
|
|
84
|
+
return useMemo<ResolvedMenuResolver<T> | undefined>(() => {
|
|
71
85
|
if (!adapter && !contextMenuActions) return undefined;
|
|
72
86
|
|
|
73
87
|
return (rowProps) => {
|
|
@@ -137,6 +137,22 @@ export interface TreeRootProps<T> {
|
|
|
137
137
|
*/
|
|
138
138
|
defaultMenuItems?: TreeBuiltinAction[];
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Imperative handle for outer code. The provided ref receives a
|
|
142
|
+
* stable handle to `useTreeActions` once Tree mounts. Lets host
|
|
143
|
+
* components trigger `refresh(id)` / `refreshAll()` from outside
|
|
144
|
+
* Tree (e.g. after a transport-level mutation completes).
|
|
145
|
+
*/
|
|
146
|
+
actionsRef?: React.MutableRefObject<TreeActionsHandle | null>;
|
|
147
|
+
|
|
140
148
|
className?: string;
|
|
141
149
|
style?: CSSProperties;
|
|
142
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Subset of `useTreeActions()` exposed via `<TreeRoot actionsRef={…}>`. */
|
|
153
|
+
export interface TreeActionsHandle {
|
|
154
|
+
refresh: (id: string) => Promise<void>;
|
|
155
|
+
refreshAll: () => Promise<void>;
|
|
156
|
+
expandAll: () => void;
|
|
157
|
+
collapseAll: () => void;
|
|
158
|
+
}
|
package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx
CHANGED
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
Tooltip,
|
|
13
13
|
TooltipContent,
|
|
14
14
|
TooltipTrigger,
|
|
15
|
-
SafeTooltipProvider,
|
|
16
15
|
} from '@djangocfg/ui-core/components';
|
|
17
16
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
18
17
|
|
|
@@ -119,13 +118,11 @@ export function MetaActions({ anchor, presentSections }: MetaActionsProps) {
|
|
|
119
118
|
) : null;
|
|
120
119
|
|
|
121
120
|
return (
|
|
122
|
-
<
|
|
123
|
-
<
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
</div>
|
|
129
|
-
</SafeTooltipProvider>
|
|
121
|
+
<div className="flex items-center gap-0.5">
|
|
122
|
+
<IconButton label={linkLabel} onClick={copyLink} active={linkCopied}>
|
|
123
|
+
{linkIcon}
|
|
124
|
+
</IconButton>
|
|
125
|
+
{toggleAllNode}
|
|
126
|
+
</div>
|
|
130
127
|
);
|
|
131
128
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import { keyBy } from 'lodash-es';
|
|
5
5
|
|
|
6
|
-
import { Skeleton
|
|
6
|
+
import { Skeleton } from '@djangocfg/ui-core/components';
|
|
7
7
|
import { useMediaQuery } from '@djangocfg/ui-core/hooks';
|
|
8
8
|
|
|
9
9
|
import useOpenApiSchema from '../../hooks/useOpenApiSchema';
|
|
@@ -234,8 +234,7 @@ export const DocsLayout: React.FC = () => {
|
|
|
234
234
|
// ─── Desktop ──────────────────────────────────────────────────────────
|
|
235
235
|
|
|
236
236
|
return (
|
|
237
|
-
<
|
|
238
|
-
<div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
|
|
237
|
+
<div className="grid grid-cols-[260px_minmax(0,1fr)] items-start">
|
|
239
238
|
<EndpointDraftSync schemaId={currentSchema?.id ?? null} />
|
|
240
239
|
<div
|
|
241
240
|
className="sticky top-[var(--navbar-height,64px)]"
|
|
@@ -283,6 +282,5 @@ export const DocsLayout: React.FC = () => {
|
|
|
283
282
|
above the whole layout (sidebar + navbar included). */}
|
|
284
283
|
<SlideInPlayground open={slideOpen} onClose={handleCloseSlide} />
|
|
285
284
|
</div>
|
|
286
|
-
</TooltipProvider>
|
|
287
285
|
);
|
|
288
286
|
};
|
|
@@ -6,8 +6,7 @@ import { AlertCircle, Loader2, Mic } from 'lucide-react';
|
|
|
6
6
|
|
|
7
7
|
import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
|
|
8
8
|
import { cn } from '@djangocfg/ui-core/lib';
|
|
9
|
-
|
|
10
|
-
import { useChatContextOptional } from '../../../chat/context';
|
|
9
|
+
import { useActiveComposer } from '@djangocfg/ui-tools/composer-registry';
|
|
11
10
|
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
|
12
11
|
import { useVoiceSupport } from '../hooks/useVoiceSupport';
|
|
13
12
|
import { getSpeechLogger } from '../core/logger';
|
|
@@ -105,24 +104,24 @@ export function VoiceComposerSlot({
|
|
|
105
104
|
}: VoiceComposerSlotProps): React.ReactElement | null {
|
|
106
105
|
const support = useVoiceSupport(engine);
|
|
107
106
|
|
|
108
|
-
// Read the composer handle from
|
|
109
|
-
//
|
|
110
|
-
// hosts
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
const composerHandleRef = useRef(
|
|
115
|
-
composerHandleRef.current =
|
|
107
|
+
// Read the active composer handle from the cross-tool registry
|
|
108
|
+
// (`@djangocfg/ui-tools/composer-registry`). The built-in
|
|
109
|
+
// `<Composer>` (and TipTap hosts via `useRegisterComposer`) publish
|
|
110
|
+
// their handle to this registry on mount. Falls back to a no-op
|
|
111
|
+
// when nothing is registered (no composer in the tree).
|
|
112
|
+
const activeComposer = useActiveComposer();
|
|
113
|
+
const composerHandleRef = useRef(activeComposer);
|
|
114
|
+
composerHandleRef.current = activeComposer;
|
|
116
115
|
|
|
117
116
|
useEffect(() => {
|
|
118
117
|
log.slot.debug('mount', {
|
|
119
118
|
supported: support.supported,
|
|
120
119
|
reason: support.reason,
|
|
121
|
-
hasComposerHandle: !!
|
|
120
|
+
hasComposerHandle: !!activeComposer,
|
|
122
121
|
hasExplicitValue: value !== undefined,
|
|
123
122
|
hasOnChange: !!onChange,
|
|
124
123
|
});
|
|
125
|
-
}, [support.supported, support.reason,
|
|
124
|
+
}, [support.supported, support.reason, activeComposer, value, onChange]);
|
|
126
125
|
|
|
127
126
|
// Resolve value/onChange: prop wins; otherwise pull from the
|
|
128
127
|
// registered composer handle. The slot can therefore be dropped into
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Minimal imperative handle every text-editor surface implements so
|
|
7
|
+
* an external tool (voice dictation, command palette, AI suggestion)
|
|
8
|
+
* can read/write its text content without traversing React.
|
|
9
|
+
*
|
|
10
|
+
* Methods are optional so a host can register a partial handle
|
|
11
|
+
* (e.g. only `getValue` + `setValue`), and the caller checks before use.
|
|
12
|
+
*/
|
|
13
|
+
export interface ComposerHandle {
|
|
14
|
+
/** Move keyboard focus into the composer's editable surface. */
|
|
15
|
+
focus: () => void;
|
|
16
|
+
/** Move the caret to the very end of the input. */
|
|
17
|
+
moveCursorToEnd?: () => void;
|
|
18
|
+
/** Read the current draft text. Voice dictation anchors partial
|
|
19
|
+
* transcripts onto the user's already-typed prefix via this. */
|
|
20
|
+
getValue?: () => string;
|
|
21
|
+
/** Replace the current draft text. Voice dictation pushes interim
|
|
22
|
+
* and final transcripts through this without owning a controlled
|
|
23
|
+
* binding. */
|
|
24
|
+
setValue?: (value: string) => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `@djangocfg/ui-tools/composer-registry`
|
|
29
|
+
*
|
|
30
|
+
* Cross-tool bridge: the currently-active text composer's handle.
|
|
31
|
+
*
|
|
32
|
+
* Producer side (`@djangocfg/ui-tools/chat` and TipTap hosts):
|
|
33
|
+
* register their composer's imperative handle via `attachComposer`.
|
|
34
|
+
*
|
|
35
|
+
* Consumer side (`@djangocfg/ui-tools/speech-recognition`):
|
|
36
|
+
* reads the active handle via `useActiveComposer`/`getActiveComposer`
|
|
37
|
+
* and pipes voice transcripts into it.
|
|
38
|
+
*
|
|
39
|
+
* Why this lives in its own subpath (not inside `chat`)
|
|
40
|
+
* ----------------------------------------------------
|
|
41
|
+
* `chat` and `speech-recognition` are sibling subpath exports. If the
|
|
42
|
+
* registry lived inside `chat`, then `speech-recognition` would have
|
|
43
|
+
* to reach into it via a cross-tool relative import — and under Vite
|
|
44
|
+
* dev's dependency optimizer that file ends up loaded TWICE (once via
|
|
45
|
+
* the `./chat` URL, once via the `./speech-recognition` relative-up
|
|
46
|
+
* URL), giving the producer and the consumer two separate `let active`
|
|
47
|
+
* slots. The active handle registered by chat would be invisible to
|
|
48
|
+
* speech-recognition (and vice versa).
|
|
49
|
+
*
|
|
50
|
+
* Putting the registry in its own dedicated subpath (a single tool
|
|
51
|
+
* that NEITHER chat nor speech-recognition cross-import — they both
|
|
52
|
+
* import this one as their dependency) means Vite resolves it from a
|
|
53
|
+
* single URL across the whole graph. One module instance, one shared
|
|
54
|
+
* `active` slot.
|
|
55
|
+
*
|
|
56
|
+
* Semantics: one active composer per realm. The most recent
|
|
57
|
+
* `registerComposer(handle)` wins; `registerComposer(null)` clears it.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
type Listener = (handle: ComposerHandle | null) => void;
|
|
61
|
+
|
|
62
|
+
let active: ComposerHandle | null = null;
|
|
63
|
+
const listeners = new Set<Listener>();
|
|
64
|
+
|
|
65
|
+
/** Set or replace the active composer handle. Pass `null` to clear. */
|
|
66
|
+
export function registerComposer(handle: ComposerHandle | null): void {
|
|
67
|
+
active = handle;
|
|
68
|
+
for (const fn of listeners) fn(active);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Convenience for components: register on mount, unregister on
|
|
73
|
+
* unmount. Returns a cleanup function suitable for `useEffect`.
|
|
74
|
+
*/
|
|
75
|
+
export function attachComposer(handle: ComposerHandle): () => void {
|
|
76
|
+
registerComposer(handle);
|
|
77
|
+
return () => {
|
|
78
|
+
if (active === handle) registerComposer(null);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Read the current active handle (no subscription). */
|
|
83
|
+
export function getActiveComposer(): ComposerHandle | null {
|
|
84
|
+
return active;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Subscribe to handle changes; returns an unsubscribe fn. */
|
|
88
|
+
export function subscribeComposer(listener: Listener): () => void {
|
|
89
|
+
listeners.add(listener);
|
|
90
|
+
return () => {
|
|
91
|
+
listeners.delete(listener);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* React hook: re-renders the caller whenever the active composer
|
|
97
|
+
* changes. Built on `useSyncExternalStore` so concurrent rendering,
|
|
98
|
+
* SSR, and dev-mode strict-effects all behave correctly.
|
|
99
|
+
*/
|
|
100
|
+
export function useActiveComposer(): ComposerHandle | null {
|
|
101
|
+
const subscribe = useCallback((onChange: () => void) => {
|
|
102
|
+
return subscribeComposer(onChange);
|
|
103
|
+
}, []);
|
|
104
|
+
return useSyncExternalStore(subscribe, getActiveComposer, () => null);
|
|
105
|
+
}
|
|
@@ -34,6 +34,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
|
|
|
34
34
|
ariaLabel,
|
|
35
35
|
enableKeyboardShortcuts,
|
|
36
36
|
seekStartsPlayback,
|
|
37
|
+
autoFocus,
|
|
37
38
|
} = props;
|
|
38
39
|
|
|
39
40
|
// onTimeUpdate is intentionally not wired in the provider — we expose it via
|
|
@@ -71,6 +72,7 @@ export const Player = forwardRef<PlayerHandle, PlayerProps>(function Player(prop
|
|
|
71
72
|
enableKeyboardShortcuts={enableKeyboardShortcuts}
|
|
72
73
|
ariaLabel={ariaLabel}
|
|
73
74
|
seekStartsPlayback={seekStartsPlayback}
|
|
75
|
+
autoFocus={autoFocus}
|
|
74
76
|
handleRef={ref}
|
|
75
77
|
/>
|
|
76
78
|
</PlayerProvider>
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
// keyboard shortcuts and MediaSession wiring; renders the picked layout.
|
|
5
5
|
|
|
6
6
|
import { useCallback, useEffect, useImperativeHandle, useState } from 'react';
|
|
7
|
-
import { TooltipProvider } from '@djangocfg/ui-core/components';
|
|
8
7
|
import { useIsPhone } from '@djangocfg/ui-core/hooks';
|
|
9
8
|
import { usePlayerAudio, usePlayerControls, usePlayerMeta } from './context/selectors';
|
|
10
9
|
import { useElementWidth } from './hooks/useResizeObserver';
|
|
@@ -26,6 +25,7 @@ type Props = Pick<
|
|
|
26
25
|
| 'enableKeyboardShortcuts'
|
|
27
26
|
| 'ariaLabel'
|
|
28
27
|
| 'seekStartsPlayback'
|
|
28
|
+
| 'autoFocus'
|
|
29
29
|
> & {
|
|
30
30
|
handleRef?: React.Ref<PlayerHandle>;
|
|
31
31
|
};
|
|
@@ -40,6 +40,7 @@ export function PlayerShell({
|
|
|
40
40
|
enableKeyboardShortcuts = true,
|
|
41
41
|
ariaLabel,
|
|
42
42
|
seekStartsPlayback = true,
|
|
43
|
+
autoFocus = false,
|
|
43
44
|
handleRef,
|
|
44
45
|
}: Props) {
|
|
45
46
|
const [container, setContainer] = useState<HTMLDivElement | null>(null);
|
|
@@ -87,8 +88,9 @@ export function PlayerShell({
|
|
|
87
88
|
seek: (s: number) => controls.seek(s),
|
|
88
89
|
getCurrentTime: () => audio.currentTime,
|
|
89
90
|
getDuration: () => (Number.isFinite(audio.duration) ? audio.duration : 0),
|
|
91
|
+
focus: () => container?.focus(),
|
|
90
92
|
}),
|
|
91
|
-
[audio, controls],
|
|
93
|
+
[audio, controls, container],
|
|
92
94
|
);
|
|
93
95
|
|
|
94
96
|
// Keyboard shortcuts work only when the container can take focus.
|
|
@@ -97,26 +99,39 @@ export function PlayerShell({
|
|
|
97
99
|
container.setAttribute('tabindex', '0');
|
|
98
100
|
}, [container]);
|
|
99
101
|
|
|
102
|
+
// `autoFocus` opts the player into pulling keyboard focus on mount
|
|
103
|
+
// (and whenever the container ref is established). Once focused,
|
|
104
|
+
// the hotkey scope (Space=play/pause, ←→=seek, ↑↓=volume, M=mute)
|
|
105
|
+
// is immediately live.
|
|
106
|
+
//
|
|
107
|
+
// Deferred via a 0-timeout so the tree row's native focus event
|
|
108
|
+
// (fired by the click that triggered the mount) lands first; we
|
|
109
|
+
// then steal focus here. rAF was racy — the row's focus event can
|
|
110
|
+
// fire AFTER the next animation frame.
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!autoFocus || !container) return;
|
|
113
|
+
const id = setTimeout(() => container.focus(), 0);
|
|
114
|
+
return () => clearTimeout(id);
|
|
115
|
+
}, [autoFocus, container]);
|
|
116
|
+
|
|
100
117
|
return (
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
{
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
</div>
|
|
120
|
-
</TooltipProvider>
|
|
118
|
+
<div
|
|
119
|
+
ref={setRootRef}
|
|
120
|
+
role="group"
|
|
121
|
+
aria-label={ariaLabel ?? 'Audio player'}
|
|
122
|
+
className={`audioplayer @container/player rounded-lg border border-border/60 bg-card text-foreground shadow-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 ${className}`}
|
|
123
|
+
>
|
|
124
|
+
{resolvedVariant === 'compact' ? (
|
|
125
|
+
<CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
|
|
126
|
+
) : (
|
|
127
|
+
<DefaultLayout
|
|
128
|
+
waveform={waveform}
|
|
129
|
+
reactiveCover={reactiveCover}
|
|
130
|
+
onPrev={onPrev}
|
|
131
|
+
onNext={onNext}
|
|
132
|
+
seekStartsPlayback={seekStartsPlayback}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
121
136
|
);
|
|
122
137
|
}
|
|
@@ -3,31 +3,41 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* `@djangocfg/ui-tools/audio-player` subpath entrypoint.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* intentionally avoid `React.lazy` + `import('./Player')` here: under bundlers
|
|
8
|
-
* that pre-bundle subpath entries (Vite optimizeDeps in Next.js/Vite/SB), the
|
|
9
|
-
* dynamic import creates a second chunk that re-instantiates the React
|
|
10
|
-
* Contexts (AudioRefCtx/ControlsCtx/MetaCtx/StateCtx/LevelsCtx). The slot
|
|
11
|
-
* components and selector hooks re-exported below would then read from a
|
|
12
|
-
* different context instance than `<PlayerProvider>` writes to, which made
|
|
13
|
-
* `usePlayerAudio` throw "must be used inside <PlayerProvider>".
|
|
6
|
+
* We deliberately keep this surface narrow:
|
|
14
7
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
8
|
+
* - `Player` / `LazyPlayer` — the only React component you should
|
|
9
|
+
* import. Already wraps `<PlayerProvider>` + `<PlayerShell>`.
|
|
10
|
+
* - types
|
|
11
|
+
* - `PlayerProvider` + selector hooks — for the rare consumer that
|
|
12
|
+
* wants to render a fully custom layout. They must own the
|
|
13
|
+
* provider themselves.
|
|
14
|
+
* - cross-instance store helpers (active player, preferences).
|
|
15
|
+
*
|
|
16
|
+
* Slot components (Cover/Title/Waveform/Controls/Layout) and internal
|
|
17
|
+
* peak-cache helpers are NOT re-exported from this entrypoint. Earlier
|
|
18
|
+
* versions did re-export them, which produced a second module-graph
|
|
19
|
+
* entry into the `context/*` files under Vite's `optimizeDeps`
|
|
20
|
+
* pre-bundling — the slot files import the same selectors as
|
|
21
|
+
* `PlayerShell`, but via a different URL key, so `<PlayerProvider>`
|
|
22
|
+
* (one context instance) and `usePlayerAudio` inside a slot read
|
|
23
|
+
* (another context instance) ended up on different `createContext()`
|
|
24
|
+
* objects → "usePlayerAudio must be used inside <PlayerProvider>".
|
|
25
|
+
*
|
|
26
|
+
* If you need raw slots, import from a deeper path (e.g.
|
|
27
|
+
* `@djangocfg/ui-tools/src/tools/media/AudioPlayer/parts/Cover`) and
|
|
28
|
+
* accept that you must own a single `<PlayerProvider>` boundary.
|
|
18
29
|
*/
|
|
19
30
|
|
|
20
31
|
// ============================================================================
|
|
21
|
-
// Player component (synchronous; previously lazy
|
|
32
|
+
// Player component (synchronous; previously lazy)
|
|
22
33
|
// ============================================================================
|
|
23
34
|
|
|
24
35
|
export { Player, Player as LazyPlayer } from './Player';
|
|
25
36
|
|
|
26
37
|
// ============================================================================
|
|
27
|
-
//
|
|
38
|
+
// Types
|
|
28
39
|
// ============================================================================
|
|
29
40
|
|
|
30
|
-
// Types
|
|
31
41
|
export type {
|
|
32
42
|
PlayerProps,
|
|
33
43
|
PlayerState,
|
|
@@ -41,7 +51,10 @@ export type {
|
|
|
41
51
|
PlayerErrorReason,
|
|
42
52
|
} from './types';
|
|
43
53
|
|
|
54
|
+
// ============================================================================
|
|
44
55
|
// Context provider + selector hooks (no UI)
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
45
58
|
export {
|
|
46
59
|
PlayerProvider,
|
|
47
60
|
usePlayerAudio,
|
|
@@ -53,7 +66,10 @@ export {
|
|
|
53
66
|
usePlayerState,
|
|
54
67
|
} from './context';
|
|
55
68
|
|
|
69
|
+
// ============================================================================
|
|
56
70
|
// Cross-instance store (active player, preferences)
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
57
73
|
export {
|
|
58
74
|
setActivePlayer,
|
|
59
75
|
getActivePlayer,
|
|
@@ -66,37 +82,9 @@ export {
|
|
|
66
82
|
type PlayerPreferences,
|
|
67
83
|
} from './store';
|
|
68
84
|
|
|
69
|
-
// Store-backed hooks
|
|
70
85
|
export {
|
|
71
86
|
useActivePlayer,
|
|
72
87
|
useLastActivePlayer,
|
|
73
88
|
useIsActivePlayer,
|
|
74
89
|
} from './hooks/useActivePlayer';
|
|
75
90
|
export { usePlayerPreferences } from './hooks/usePlayerPreferences';
|
|
76
|
-
|
|
77
|
-
// Peak cache helpers
|
|
78
|
-
export { clearPeaksCache, setPeaks } from './audio';
|
|
79
|
-
|
|
80
|
-
// Slot components — presentational, read from PlayerContext. Safe to
|
|
81
|
-
// re-export synchronously: they don't import the heavy Player tree
|
|
82
|
-
// (audio decoding, layouts, shell) — only context selectors and types.
|
|
83
|
-
export { Cover, CoverPlaceholder, ReactivePulse } from './parts/Cover';
|
|
84
|
-
export { Title, Artist, TimeDisplay } from './parts/Meta';
|
|
85
|
-
export {
|
|
86
|
-
PlayButton,
|
|
87
|
-
SkipButton,
|
|
88
|
-
VolumeControl,
|
|
89
|
-
LoopButton,
|
|
90
|
-
ControlsRow,
|
|
91
|
-
IconButton,
|
|
92
|
-
} from './parts/Controls';
|
|
93
|
-
export {
|
|
94
|
-
Waveform,
|
|
95
|
-
PeaksWaveform,
|
|
96
|
-
LiveWaveform,
|
|
97
|
-
BarsWaveform,
|
|
98
|
-
ProgressBar,
|
|
99
|
-
WaveformSkeleton,
|
|
100
|
-
} from './parts/Waveform';
|
|
101
|
-
export { ErrorState } from './parts/ErrorState';
|
|
102
|
-
export { DefaultLayout, CompactLayout } from './parts/Layout';
|