@djangocfg/ui-tools 2.1.415 → 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.
Files changed (140) hide show
  1. package/dist/audio-player/index.cjs +2099 -0
  2. package/dist/audio-player/index.cjs.map +1 -0
  3. package/dist/audio-player/index.css +65 -0
  4. package/dist/audio-player/index.css.map +1 -0
  5. package/dist/audio-player/index.d.cts +174 -0
  6. package/dist/audio-player/index.d.ts +174 -0
  7. package/dist/audio-player/index.mjs +2076 -0
  8. package/dist/audio-player/index.mjs.map +1 -0
  9. package/dist/composer-registry/index.cjs +45 -0
  10. package/dist/composer-registry/index.cjs.map +1 -0
  11. package/dist/composer-registry/index.d.cts +73 -0
  12. package/dist/composer-registry/index.d.ts +73 -0
  13. package/dist/composer-registry/index.mjs +39 -0
  14. package/dist/composer-registry/index.mjs.map +1 -0
  15. package/dist/file-icon/index.d.cts +1 -1
  16. package/dist/file-icon/index.d.ts +1 -1
  17. package/dist/slots-ClRpIzoh.d.cts +88 -0
  18. package/dist/slots-ClRpIzoh.d.ts +88 -0
  19. package/dist/tree/index.cjs +2019 -279
  20. package/dist/tree/index.cjs.map +1 -1
  21. package/dist/tree/index.d.cts +731 -72
  22. package/dist/tree/index.d.ts +731 -72
  23. package/dist/tree/index.mjs +2009 -282
  24. package/dist/tree/index.mjs.map +1 -1
  25. package/package.json +18 -9
  26. package/src/tools/chat/README.md +111 -1
  27. package/src/tools/chat/composer/Composer.tsx +146 -25
  28. package/src/tools/chat/composer/ComposerRichTextarea.tsx +25 -0
  29. package/src/tools/chat/composer/index.ts +22 -0
  30. package/src/tools/chat/composer/slash/README.md +187 -0
  31. package/src/tools/chat/composer/slash/SlashHighlightTextarea.tsx +144 -0
  32. package/src/tools/chat/composer/slash/SlashMenu.tsx +142 -0
  33. package/src/tools/chat/composer/slash/SlashToken.tsx +57 -0
  34. package/src/tools/chat/composer/slash/index.ts +44 -0
  35. package/src/tools/chat/composer/slash/labels.ts +19 -0
  36. package/src/tools/chat/composer/slash/state.ts +168 -0
  37. package/src/tools/chat/composer/slash/types.ts +64 -0
  38. package/src/tools/chat/composer/slash/useSlashCommands.ts +204 -0
  39. package/src/tools/chat/composer/types.ts +8 -0
  40. package/src/tools/chat/context/ChatProvider.tsx +13 -78
  41. package/src/tools/chat/hooks/useAutoFocusOnStreamEnd.ts +12 -15
  42. package/src/tools/chat/hooks/useFocusOnEmptyClick.ts +4 -5
  43. package/src/tools/chat/launcher/header/ChatHeader.tsx +14 -19
  44. package/src/tools/chat/launcher/header/ChatHeaderActionButton.tsx +8 -12
  45. package/src/tools/chat/shell/SuggestedPrompts.tsx +194 -0
  46. package/src/tools/chat/shell/index.ts +6 -0
  47. package/src/tools/data/Listbox/lazy.tsx +1 -1
  48. package/src/tools/data/Masonry/lazy.tsx +1 -1
  49. package/src/tools/data/Timeline/lazy.tsx +1 -1
  50. package/src/tools/data/Tree/FinderTree.tsx +42 -0
  51. package/src/tools/data/Tree/README.md +337 -208
  52. package/src/tools/data/Tree/TreeDndProvider.tsx +137 -0
  53. package/src/tools/data/Tree/TreeRoot.tsx +111 -72
  54. package/src/tools/data/Tree/__tests__/dnd.test.ts +160 -0
  55. package/src/tools/data/Tree/__tests__/keyboard.test.ts +137 -0
  56. package/src/tools/data/Tree/__tests__/renameUtils.test.ts +52 -0
  57. package/src/tools/data/Tree/__tests__/selection.test.ts +227 -0
  58. package/src/tools/data/Tree/components/TreeDropIndicator.tsx +65 -0
  59. package/src/tools/data/Tree/components/TreeEmptyArea.tsx +160 -0
  60. package/src/tools/data/Tree/components/TreeRenameInput.tsx +114 -0
  61. package/src/tools/data/Tree/components/TreeRow.tsx +103 -8
  62. package/src/tools/data/Tree/components/index.ts +6 -0
  63. package/src/tools/data/Tree/context/TreeContext.tsx +223 -363
  64. package/src/tools/data/Tree/context/TreeContextValue.ts +139 -0
  65. package/src/tools/data/Tree/context/async-children/collect-ids.ts +27 -0
  66. package/src/tools/data/Tree/context/async-children/index.ts +8 -0
  67. package/src/tools/data/Tree/context/async-children/use-async-children.ts +157 -0
  68. package/src/tools/data/Tree/context/clipboard/index.ts +4 -0
  69. package/src/tools/data/Tree/context/clipboard/use-clipboard.ts +115 -0
  70. package/src/tools/data/Tree/context/dnd/index.ts +8 -0
  71. package/src/tools/data/Tree/context/dnd/use-dnd.ts +194 -0
  72. package/src/tools/data/Tree/context/expansion/index.ts +4 -0
  73. package/src/tools/data/Tree/context/expansion/use-expansion.ts +55 -0
  74. package/src/tools/data/Tree/context/hooks.ts +68 -1
  75. package/src/tools/data/Tree/context/index.ts +3 -0
  76. package/src/tools/data/Tree/context/menu/builtin-actions.ts +357 -0
  77. package/src/tools/data/Tree/context/menu/index.ts +11 -0
  78. package/src/tools/data/Tree/context/menu/render.tsx +75 -0
  79. package/src/tools/data/Tree/context/menu/use-resolved-menu.ts +141 -0
  80. package/src/tools/data/Tree/context/persist/index.ts +4 -0
  81. package/src/tools/data/Tree/context/persist/use-persist-sync.ts +74 -0
  82. package/src/tools/data/Tree/context/rename/index.ts +4 -0
  83. package/src/tools/data/Tree/context/rename/use-rename.ts +113 -0
  84. package/src/tools/data/Tree/context/selection/index.ts +4 -0
  85. package/src/tools/data/Tree/context/selection/use-selection.ts +146 -0
  86. package/src/tools/data/Tree/context/state/index.ts +6 -0
  87. package/src/tools/data/Tree/context/state/initial.ts +41 -0
  88. package/src/tools/data/Tree/context/state/reducer.ts +76 -0
  89. package/src/tools/data/Tree/context/state/types.ts +46 -0
  90. package/src/tools/data/Tree/data/clipboard.ts +33 -0
  91. package/src/tools/data/Tree/data/dnd.ts +123 -0
  92. package/src/tools/data/Tree/data/finderShortcuts.ts +67 -0
  93. package/src/tools/data/Tree/data/index.ts +19 -0
  94. package/src/tools/data/Tree/data/renameUtils.ts +51 -0
  95. package/src/tools/data/Tree/data/selection.ts +157 -0
  96. package/src/tools/data/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  97. package/src/tools/data/Tree/hooks/finder-hotkeys/index.ts +8 -0
  98. package/src/tools/data/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  99. package/src/tools/data/Tree/hooks/index.ts +23 -4
  100. package/src/tools/data/Tree/hooks/keyboard/activation.ts +27 -0
  101. package/src/tools/data/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  102. package/src/tools/data/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  103. package/src/tools/data/Tree/hooks/keyboard/index.ts +10 -0
  104. package/src/tools/data/Tree/hooks/keyboard/types.ts +39 -0
  105. package/src/tools/data/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  106. package/src/tools/data/Tree/hooks/type-ahead/index.ts +5 -0
  107. package/src/tools/data/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  108. package/src/tools/data/Tree/hooks/{useTreeTypeAhead.ts → type-ahead/use-tree-type-ahead.ts} +8 -19
  109. package/src/tools/data/Tree/index.tsx +26 -2
  110. package/src/tools/data/Tree/types/activation.ts +30 -0
  111. package/src/tools/data/Tree/types/adapter.ts +70 -0
  112. package/src/tools/data/Tree/types/index.ts +27 -0
  113. package/src/tools/data/Tree/types/labels.ts +97 -0
  114. package/src/tools/data/Tree/types/loader.ts +9 -0
  115. package/src/tools/data/Tree/types/node.ts +38 -0
  116. package/src/tools/data/Tree/types/root-props.ts +158 -0
  117. package/src/tools/data/Tree/types/selection.ts +3 -0
  118. package/src/tools/data/Tree/types/slots.ts +64 -0
  119. package/src/tools/dev/OpenapiViewer/components/DocsLayout/EndpointDoc/Header/MetaActions.tsx +6 -9
  120. package/src/tools/dev/OpenapiViewer/components/DocsLayout/index.tsx +2 -4
  121. package/src/tools/forms/MarkdownEditor/MarkdownEditor.tsx +85 -0
  122. package/src/tools/forms/MarkdownEditor/index.ts +1 -0
  123. package/src/tools/forms/MarkdownEditor/lazy.tsx +6 -0
  124. package/src/tools/forms/MarkdownEditor/slash/SlashCommandNode.ts +162 -0
  125. package/src/tools/forms/MarkdownEditor/slash/index.ts +4 -0
  126. package/src/tools/forms/MarkdownEditor/slash/syncSlashNode.ts +97 -0
  127. package/src/tools/forms/MarkdownEditor/slash/types.ts +13 -0
  128. package/src/tools/forms/MarkdownEditor/styles.css +18 -0
  129. package/src/tools/input/SpeechRecognition/widgets/VoiceComposerSlot.tsx +11 -12
  130. package/src/tools/integration/ComposerRegistry/index.ts +105 -0
  131. package/src/tools/media/AudioPlayer/Player.tsx +2 -0
  132. package/src/tools/media/AudioPlayer/PlayerShell.tsx +37 -22
  133. package/src/tools/media/AudioPlayer/lazy.tsx +30 -42
  134. package/src/tools/media/AudioPlayer/parts/Controls/IconButton.tsx +10 -11
  135. package/src/tools/media/AudioPlayer/parts/Controls/VolumeControl.tsx +52 -115
  136. package/src/tools/media/AudioPlayer/types.ts +15 -0
  137. package/dist/types-j2vhn4Kv.d.cts +0 -241
  138. package/dist/types-j2vhn4Kv.d.ts +0 -241
  139. package/src/tools/data/Tree/hooks/useTreeKeyboard.ts +0 -171
  140. package/src/tools/data/Tree/types.ts +0 -217
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `SlashCommandNode` — TipTap inline atom that renders a chosen slash
3
+ * verb as a styled chip inside the editor, analogous to how
4
+ * `@tiptap/extension-mention` renders an `@user` pill.
5
+ *
6
+ * Why a node (and not a mark / decoration):
7
+ *
8
+ * - The chip must read as one indivisible unit — pressing Backspace
9
+ * removes the whole `/verb`, not the trailing letter. That's
10
+ * exactly what `atom: true` + `inline: true` give us.
11
+ * - The chip's text content (`/verb`) participates in `editor.getText()`
12
+ * and the markdown serializer's text output so the host's
13
+ * `composer.value` round-trips to the same plain string the plain
14
+ * `<SlashHighlightTextarea>` produces. The slash hook (which lives on
15
+ * the string buffer) stays oblivious to the node form.
16
+ *
17
+ * Render parity with the plain mirror:
18
+ *
19
+ * The chip class `markdown-slash-command` mirrors the plain textarea's
20
+ * `bg-primary/15 text-primary` look (see `SlashHighlightTextarea.tsx`).
21
+ * Styles live in `../styles.css`. The TipTap chip can carry a touch of
22
+ * horizontal padding without breaking the caret — the node is atomic
23
+ * so the caret never lands inside it — which the plain mirror cannot.
24
+ */
25
+ import { Node, mergeAttributes } from '@tiptap/core';
26
+
27
+ declare module '@tiptap/core' {
28
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
29
+ interface Commands<ReturnType> {
30
+ slashCommand: {
31
+ /**
32
+ * Insert a slash-command atom at the given absolute document
33
+ * position. The host normally calls this once per `pick()`
34
+ * after the slash hook has produced the new string buffer.
35
+ */
36
+ insertSlashCommandAt: (
37
+ pos: number,
38
+ from: number,
39
+ to: number,
40
+ attrs: { id: string; token: string },
41
+ ) => ReturnType;
42
+ };
43
+ }
44
+ }
45
+
46
+ export interface SlashCommandNodeOptions {
47
+ /** Extra HTML attrs spread onto the rendered `<span>`. */
48
+ HTMLAttributes: Record<string, unknown>;
49
+ }
50
+
51
+ /**
52
+ * Inline atom node that paints a `/verb` chip inside the TipTap editor.
53
+ *
54
+ * - `inline: true` — lives next to text in a paragraph.
55
+ * - `atom: true` — caret cannot land inside it; Backspace removes it
56
+ * wholesale, matching the plain mirror's "the verb is one token"
57
+ * feel.
58
+ * - `selectable: false` — clicking the chip does not stick a node
59
+ * selection on it (mention does the same).
60
+ *
61
+ * `renderText` returns the bare `/verb` so `editor.getText()` (and
62
+ * therefore the host `composer.value`) reads the same string the plain
63
+ * mirror produces. This is what keeps the slash hook — which only
64
+ * understands strings — driving the menu correctly when the editor is
65
+ * TipTap-backed.
66
+ */
67
+ export const SlashCommandNode = Node.create<SlashCommandNodeOptions>({
68
+ name: 'slashCommand',
69
+ group: 'inline',
70
+ inline: true,
71
+ atom: true,
72
+ selectable: false,
73
+ // Higher than StarterKit text so paste rules etc. don't try to
74
+ // re-parse our chip text as plain.
75
+ priority: 101,
76
+
77
+ addOptions() {
78
+ return {
79
+ HTMLAttributes: {},
80
+ };
81
+ },
82
+
83
+ addAttributes() {
84
+ return {
85
+ id: {
86
+ default: null,
87
+ parseHTML: (element) => element.getAttribute('data-id'),
88
+ renderHTML: (attrs) =>
89
+ attrs.id ? { 'data-id': attrs.id as string } : {},
90
+ },
91
+ token: {
92
+ default: null,
93
+ parseHTML: (element) => element.getAttribute('data-token'),
94
+ renderHTML: (attrs) =>
95
+ attrs.token ? { 'data-token': attrs.token as string } : {},
96
+ },
97
+ };
98
+ },
99
+
100
+ parseHTML() {
101
+ return [{ tag: `span[data-type="${this.name}"]` }];
102
+ },
103
+
104
+ renderHTML({ node, HTMLAttributes }) {
105
+ const token =
106
+ (node.attrs.token as string | null) ??
107
+ (node.attrs.id ? `/${node.attrs.id as string}` : '/');
108
+ return [
109
+ 'span',
110
+ mergeAttributes(
111
+ { 'data-type': this.name, class: 'markdown-slash-command' },
112
+ this.options.HTMLAttributes,
113
+ HTMLAttributes,
114
+ ),
115
+ token,
116
+ ];
117
+ },
118
+
119
+ // `editor.getText()` and the markdown text-serializer fallback read
120
+ // this — the chip flattens to its raw `/verb` glyph so consumers
121
+ // (slash hook, transport, backends) see plain text.
122
+ renderText({ node }) {
123
+ const token =
124
+ (node.attrs.token as string | null) ??
125
+ (node.attrs.id ? `/${node.attrs.id as string}` : '/');
126
+ return token;
127
+ },
128
+
129
+ // `@tiptap/markdown` reads `renderMarkdown` off the extension at
130
+ // registration time. We emit the bare token, NOT the default
131
+ // shortcode `[slashCommand id="..."]`, so a round-trip through
132
+ // `getMarkdown()` yields the same `/verb argument` string the plain
133
+ // mirror produces. Slash commands never need re-parsed back from
134
+ // markdown — fresh value sync handles that via
135
+ // `syncLeadingSlashNode` after each `setContent`.
136
+ renderMarkdown(node) {
137
+ const attrs = node.attrs as { token?: string | null; id?: string | null };
138
+ const token = attrs.token ?? (attrs.id ? `/${attrs.id}` : '');
139
+ return token;
140
+ },
141
+
142
+ addCommands() {
143
+ return {
144
+ insertSlashCommandAt:
145
+ (pos, from, to, attrs) =>
146
+ ({ chain }) => {
147
+ // Replace the [from, to) range (typically the leading `/verb`
148
+ // text the user just had the menu narrow down) with the atom
149
+ // node. `pos` is unused here but kept in the signature for
150
+ // future use (e.g. cursor-driven insertion not anchored at
151
+ // the start of the doc).
152
+ void pos;
153
+ return chain()
154
+ .insertContentAt(
155
+ { from, to },
156
+ { type: 'slashCommand', attrs },
157
+ )
158
+ .run();
159
+ },
160
+ };
161
+ },
162
+ });
@@ -0,0 +1,4 @@
1
+ export { SlashCommandNode } from './SlashCommandNode';
2
+ export type { SlashCommandNodeOptions } from './SlashCommandNode';
3
+ export { syncLeadingSlashNode } from './syncSlashNode';
4
+ export type { SlashCommandInfo } from './types';
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `syncSlashNode` — bridge between the slash hook's string buffer and
3
+ * the TipTap document's node form.
4
+ *
5
+ * The slash hook lives entirely on the editor value string — it has no
6
+ * concept of nodes. When the host wires a TipTap-backed
7
+ * `<ComposerRichTextarea>` with `slashCommands`, the document needs to
8
+ * pick up two things on its own:
9
+ *
10
+ * 1. After `setContent(newValue)` runs (e.g. the user picked `/verb`
11
+ * from the menu so `composer.value` became `"/verb "`), the
12
+ * leading `/verb` text must turn into a `SlashCommandNode` atom
13
+ * so the chip shows up.
14
+ * 2. If the user backspaces past the chip or types non-slash text,
15
+ * the buffer is no longer a slash command — there's nothing to
16
+ * do because the atom would have been removed wholesale by the
17
+ * backspace.
18
+ *
19
+ * This module owns the post-`setContent` "absorb the leading slash"
20
+ * pass. It is the only piece of TipTap-aware code the slash node needs
21
+ * beyond the node definition itself.
22
+ */
23
+ import type { Editor } from '@tiptap/react';
24
+
25
+ import type {
26
+ SlashCommandInfo,
27
+ } from './types';
28
+
29
+ /**
30
+ * Find the leading `/verb` in the document — if and only if it appears
31
+ * as the very first inline text of the first paragraph. Returns
32
+ * `{ verb, from, to }` (`from` / `to` are absolute document positions
33
+ * usable with `insertContentAt`) or `null` when no leading slash is
34
+ * present.
35
+ */
36
+ function findLeadingSlashText(
37
+ editor: Editor,
38
+ commands: readonly SlashCommandInfo[],
39
+ ): { verb: SlashCommandInfo; from: number; to: number } | null {
40
+ const doc = editor.state.doc;
41
+ if (doc.childCount === 0) return null;
42
+ // Walk the document's first inline block. The buffer must START with
43
+ // the slash — verbs in the middle of the message are not slash
44
+ // commands (matches `parseSlashState` semantics).
45
+ const firstBlock = doc.firstChild;
46
+ if (!firstBlock || firstBlock.childCount === 0) return null;
47
+ const firstInline = firstBlock.firstChild;
48
+ if (!firstInline) return null;
49
+ // If the first inline is already a slash atom we're done.
50
+ if (firstInline.type.name === 'slashCommand') return null;
51
+ if (!firstInline.isText) return null;
52
+ const text = firstInline.text ?? '';
53
+ // Mirrors SLASH_RE from ../../../chat/composer/slash/state.ts. Kept
54
+ // inline to avoid pulling the chat package into the editor module.
55
+ const m = /^\/([a-zA-Z][\w-]*)(?:[ \n]|$)/.exec(text);
56
+ if (!m) return null;
57
+ const verb = m[1]?.toLowerCase() ?? '';
58
+ const cmd = commands.find((c) => c.id === verb);
59
+ if (!cmd) return null;
60
+ const slashLen = 1 + (m[1]?.length ?? 0); // `/verb` length, no trailing
61
+ // First text child of the first block starts at position 1 in the
62
+ // document (the block boundary takes position 0).
63
+ const from = 1;
64
+ const to = from + slashLen;
65
+ return { verb: cmd, from, to };
66
+ }
67
+
68
+ /**
69
+ * If the document starts with a `/verb` text node whose verb is in
70
+ * `commands`, replace that text with a `SlashCommandNode` atom. No-op
71
+ * otherwise. Idempotent — calling it on a document that already starts
72
+ * with the atom does nothing.
73
+ *
74
+ * Returns `true` when a replacement happened (so callers can suppress
75
+ * a feedback `onUpdate` if needed).
76
+ */
77
+ export function syncLeadingSlashNode(
78
+ editor: Editor,
79
+ commands: readonly SlashCommandInfo[],
80
+ ): boolean {
81
+ if (!editor || editor.isDestroyed) return false;
82
+ if (commands.length === 0) return false;
83
+ const found = findLeadingSlashText(editor, commands);
84
+ if (!found) return false;
85
+ const { verb, from, to } = found;
86
+ editor
87
+ .chain()
88
+ .insertContentAt(
89
+ { from, to },
90
+ {
91
+ type: 'slashCommand',
92
+ attrs: { id: verb.id, token: verb.token },
93
+ },
94
+ )
95
+ .run();
96
+ return true;
97
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Editor-side slash command info — a deliberately tiny subset of the
3
+ * chat package's `SlashCommand` so the editor module does not depend
4
+ * on `@djangocfg/ui-tools/chat`. Hosts pass either the chat package's
5
+ * `SlashCommand[]` directly (structural compatibility) or this minimal
6
+ * shape.
7
+ */
8
+ export interface SlashCommandInfo {
9
+ /** Verb without the leading slash (e.g. `"clear"`). */
10
+ id: string;
11
+ /** Display token with the slash (e.g. `"/clear"`). */
12
+ token: string;
13
+ }
@@ -116,6 +116,24 @@
116
116
  box-shadow: none;
117
117
  }
118
118
 
119
+ /* Slash-command inline chip — TipTap atom analogue of the plain
120
+ `<SlashHighlightTextarea>` mirror. Mirrors `bg-primary/15 text-primary`
121
+ so the two paths look identical. Atom nodes don't admit a caret, so
122
+ light horizontal padding is safe here (unlike the plain mirror,
123
+ which has to be padding-less to keep caret alignment). */
124
+ .markdown-slash-command {
125
+ background: color-mix(in oklab, var(--color-primary, var(--primary)) 15%, transparent);
126
+ color: var(--color-primary, var(--primary));
127
+ padding: 0 2px;
128
+ border-radius: 3px;
129
+ font-weight: 500;
130
+ /* Atom nodes can't be entered; reflect that visually so the user does
131
+ not try to click into the chip to edit it. */
132
+ cursor: default;
133
+ user-select: none;
134
+ display: inline;
135
+ }
136
+
119
137
  /* Mention inline chip */
120
138
  .markdown-mention {
121
139
  background: var(--color-primary, var(--primary));
@@ -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 chat context — works transparently
109
- // for the built-in `<Composer>` (registers itself) and for TipTap
110
- // hosts that call `useRegisterComposer({ getValue, setValue, focus,
111
- // moveCursorToEnd })`. Falls back to a no-op when mounted outside of
112
- // a chat.
113
- const chatCtx = useChatContextOptional();
114
- const composerHandleRef = useRef(chatCtx?.composer ?? null);
115
- composerHandleRef.current = chatCtx?.composer ?? null;
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: !!chatCtx?.composer,
120
+ hasComposerHandle: !!activeComposer,
122
121
  hasExplicitValue: value !== undefined,
123
122
  hasOnChange: !!onChange,
124
123
  });
125
- }, [support.supported, support.reason, chatCtx?.composer, value, onChange]);
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
- <TooltipProvider delayDuration={400}>
102
- <div
103
- ref={setRootRef}
104
- role="group"
105
- aria-label={ariaLabel ?? 'Audio player'}
106
- 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}`}
107
- >
108
- {resolvedVariant === 'compact' ? (
109
- <CompactLayout waveform={waveform} seekStartsPlayback={seekStartsPlayback} />
110
- ) : (
111
- <DefaultLayout
112
- waveform={waveform}
113
- reactiveCover={reactiveCover}
114
- onPrev={onPrev}
115
- onNext={onNext}
116
- seekStartsPlayback={seekStartsPlayback}
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
  }