@hh.ru/magritte-ui-chat-input 1.0.1

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 (67) hide show
  1. package/index.css +286 -0
  2. package/index.d.ts +7 -0
  3. package/index.js +38 -0
  4. package/index.js.map +1 -0
  5. package/index.mock.d.ts +7 -0
  6. package/index.mock.js +47 -0
  7. package/index.mock.js.map +1 -0
  8. package/internal/ChatInputContext.d.ts +13 -0
  9. package/internal/ChatInputContext.js +9 -0
  10. package/internal/ChatInputContext.js.map +1 -0
  11. package/internal/ChatInputSlotButton.d.ts +16 -0
  12. package/internal/ChatInputSlotButton.js +25 -0
  13. package/internal/ChatInputSlotButton.js.map +1 -0
  14. package/internal/FocusTarget.d.ts +14 -0
  15. package/internal/FocusTarget.js +17 -0
  16. package/internal/FocusTarget.js.map +1 -0
  17. package/internal/ModeTransition.d.ts +12 -0
  18. package/internal/ModeTransition.js +29 -0
  19. package/internal/ModeTransition.js.map +1 -0
  20. package/internal/TextInput.d.ts +18 -0
  21. package/internal/TextInput.js +149 -0
  22. package/internal/TextInput.js.map +1 -0
  23. package/internal/VoiceRecord.d.ts +24 -0
  24. package/internal/VoiceRecord.js +45 -0
  25. package/internal/VoiceRecord.js.map +1 -0
  26. package/internal/VoiceRecordIndicator.d.ts +4 -0
  27. package/internal/VoiceRecordIndicator.js +22 -0
  28. package/internal/VoiceRecordIndicator.js.map +1 -0
  29. package/internal/focusGroup.d.ts +22 -0
  30. package/internal/focusGroup.js +36 -0
  31. package/internal/focusGroup.js.map +1 -0
  32. package/internal/useHasSound.d.ts +7 -0
  33. package/internal/useHasSound.js +77 -0
  34. package/internal/useHasSound.js.map +1 -0
  35. package/internal/useRovingFocusGroup.d.ts +19 -0
  36. package/internal/useRovingFocusGroup.js +151 -0
  37. package/internal/useRovingFocusGroup.js.map +1 -0
  38. package/internal/useSendOnEnter.d.ts +13 -0
  39. package/internal/useSendOnEnter.js +42 -0
  40. package/internal/useSendOnEnter.js.map +1 -0
  41. package/internal/useVoiceRecordSession.d.ts +51 -0
  42. package/internal/useVoiceRecordSession.js +245 -0
  43. package/internal/useVoiceRecordSession.js.map +1 -0
  44. package/internal/voiceSlotRegistry.d.ts +6 -0
  45. package/internal/voiceSlotRegistry.js +10 -0
  46. package/internal/voiceSlotRegistry.js.map +1 -0
  47. package/package.json +44 -0
  48. package/public/ChatInput.d.ts +47 -0
  49. package/public/ChatInput.js +122 -0
  50. package/public/ChatInput.js.map +1 -0
  51. package/public/ChatInputChips.d.ts +6 -0
  52. package/public/ChatInputChips.js +16 -0
  53. package/public/ChatInputChips.js.map +1 -0
  54. package/public/ChatInputPreview.d.ts +12 -0
  55. package/public/ChatInputPreview.js +14 -0
  56. package/public/ChatInputPreview.js.map +1 -0
  57. package/public/ChatInputSendButton.d.ts +14 -0
  58. package/public/ChatInputSendButton.js +38 -0
  59. package/public/ChatInputSendButton.js.map +1 -0
  60. package/public/ChatInputVoiceButton.d.ts +17 -0
  61. package/public/ChatInputVoiceButton.js +91 -0
  62. package/public/ChatInputVoiceButton.js.map +1 -0
  63. package/styles-CQQdqOFh.js +5 -0
  64. package/styles-CQQdqOFh.js.map +1 -0
  65. package/types.d.ts +46 -0
  66. package/types.js +3 -0
  67. package/types.js.map +1 -0
@@ -0,0 +1,151 @@
1
+ import './../index.css';
2
+ import { useRef, useCallback, useEffect } from 'react';
3
+ import { keyboardKeys, keyboardMatches, keyboardMatch } from '@hh.ru/magritte-common-keyboard';
4
+ import { FOCUS_PRIORITY_ATTR } from './focusGroup.js';
5
+
6
+ // Кандидаты в группу: интерактивные элементы, но без исключения [tabindex="-1"] (в отличие от
7
+ // FOCUSABLE_SELECTOR из common-keyboard) — менеджер сам ставит участникам -1 и должен продолжать
8
+ // их видеть при повторных сканах
9
+ const CANDIDATE_SELECTOR = [
10
+ 'button:not([disabled])',
11
+ 'a[href]',
12
+ 'input:not([disabled]):not([type="hidden"])',
13
+ 'select:not([disabled])',
14
+ 'textarea:not([disabled])',
15
+ '[tabindex]',
16
+ ].join(', ');
17
+ const NAV_KEYS = [keyboardKeys.ArrowLeft, keyboardKeys.ArrowRight, keyboardKeys.Home, keyboardKeys.End];
18
+ // метка «tabindex этому элементу писали мы»: его "-1" — не чужое намерение, а членство в группе.
19
+ // Держим её в DOM, а не в JS-коллекции: элементы приложения приходят и уходят без нашего ведома
20
+ const FOCUS_MANAGED_ATTR = 'data-magritte-chat-input-focus-managed';
21
+ // Registry — сам DOM: участники и их порядок вычисляются сканом на каждое обращение, поэтому
22
+ // список никогда не устаревает (кнопки приложения в слотах могут появляться и исчезать без
23
+ // ререндера ChatInput)
24
+ const collectGroup = (root) => {
25
+ const items = [];
26
+ const all = [];
27
+ root.querySelectorAll(CANDIDATE_SELECTOR).forEach((el, index) => {
28
+ // элемент вне приоритетных контейнеров (textarea, произвольный контент вне слотов) —
29
+ // не участник, его tabindex не трогаем
30
+ const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);
31
+ if (!container || !root.contains(container)) {
32
+ return;
33
+ }
34
+ // чужой явный tabindex="-1" уважаем (приложение само вывело элемент из фокуса);
35
+ // свой (с меткой managed) — просто признак участника группы
36
+ if (el.getAttribute('tabindex') === '-1' && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {
37
+ return;
38
+ }
39
+ all.push(el);
40
+ // скрытый слой не участвует в обходе, но остаётся под управлением (см. applyTabStops)
41
+ if (el.closest('[aria-hidden="true"]')) {
42
+ return;
43
+ }
44
+ items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });
45
+ });
46
+ // сортировка стабильная: при равном приоритете сохраняем исходный DOM-порядок
47
+ const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);
48
+ return { members, all };
49
+ };
50
+ /**
51
+ * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников
52
+ * tabindex="-1", у одного (текущего стопа) — "0"; внутри группы фокус ходит стрелками ←/→
53
+ * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).
54
+ * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.
55
+ *
56
+ * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать
57
+ * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:
58
+ * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver
59
+ * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).
60
+ */
61
+ const useRovingFocusGroup = (root) => {
62
+ const stopRef = useRef(null);
63
+ const applyTabStops = useCallback(() => {
64
+ const rootEl = root.current;
65
+ if (!rootEl) {
66
+ return;
67
+ }
68
+ const { members, all } = collectGroup(rootEl);
69
+ const prevStop = stopRef.current;
70
+ const stop = prevStop && members.includes(prevStop) ? prevStop : (members[0] ?? null);
71
+ stopRef.current = stop;
72
+ all.forEach((el) => {
73
+ const tabIndex = el === stop ? 0 : -1;
74
+ if (el.tabIndex !== tabIndex) {
75
+ el.tabIndex = tabIndex;
76
+ el.setAttribute(FOCUS_MANAGED_ATTR, '');
77
+ }
78
+ });
79
+ }, [root]);
80
+ const focusStop = useCallback(() => {
81
+ // observer срабатывает микротаском — пересобираем синхронно, чтобы стоп был свежим
82
+ applyTabStops();
83
+ stopRef.current?.focus();
84
+ }, [applyTabStops]);
85
+ useEffect(() => {
86
+ const rootEl = root.current;
87
+ if (!rootEl) {
88
+ return undefined;
89
+ }
90
+ applyTabStops();
91
+ const handleKeyDown = (event) => {
92
+ if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {
93
+ return;
94
+ }
95
+ if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
96
+ return;
97
+ }
98
+ const { members } = collectGroup(rootEl);
99
+ const activeIndex = members.indexOf(document.activeElement);
100
+ // фокус вне группы (например, в textarea — там стрелки двигают каретку) — не вмешиваемся
101
+ if (activeIndex === -1) {
102
+ return;
103
+ }
104
+ event.preventDefault();
105
+ const lastIndex = members.length - 1;
106
+ let nextIndex;
107
+ if (keyboardMatch(event, keyboardKeys.Home)) {
108
+ nextIndex = 0;
109
+ }
110
+ else if (keyboardMatch(event, keyboardKeys.End)) {
111
+ nextIndex = lastIndex;
112
+ }
113
+ else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {
114
+ nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;
115
+ }
116
+ else {
117
+ nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;
118
+ }
119
+ // focusin перенесёт стоп на новый элемент
120
+ members[nextIndex].focus();
121
+ };
122
+ // стоп следует за фокусом: кликнутый мышью или сфокусированный стрелкой участник
123
+ // становится единственным tab stop группы
124
+ const handleFocusIn = (event) => {
125
+ const target = event.target;
126
+ if (!(target instanceof HTMLElement) || stopRef.current === target) {
127
+ return;
128
+ }
129
+ if (!collectGroup(rootEl).members.includes(target)) {
130
+ return;
131
+ }
132
+ stopRef.current = target;
133
+ applyTabStops();
134
+ };
135
+ // ремонт расстановки: анмаунт держателя стопа, disabled, aria-hidden при смене режима.
136
+ // tabindex в фильтре намеренно нет — собственные записи не должны будить observer
137
+ const observer = new MutationObserver(applyTabStops);
138
+ observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ['disabled', 'aria-hidden'] });
139
+ rootEl.addEventListener('keydown', handleKeyDown);
140
+ rootEl.addEventListener('focusin', handleFocusIn);
141
+ return () => {
142
+ observer.disconnect();
143
+ rootEl.removeEventListener('keydown', handleKeyDown);
144
+ rootEl.removeEventListener('focusin', handleFocusIn);
145
+ };
146
+ }, [root, applyTabStops]);
147
+ return { focusStop };
148
+ };
149
+
150
+ export { useRovingFocusGroup };
151
+ //# sourceMappingURL=useRovingFocusGroup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useRovingFocusGroup.js","sources":["src/internal/useRovingFocusGroup.ts"],"sourcesContent":["import { type RefObject, useCallback, useEffect, useRef } from 'react';\n\nimport { keyboardKeys, keyboardMatch, keyboardMatches } from '@hh.ru/magritte-common-keyboard';\nimport { FOCUS_PRIORITY_ATTR } from '@hh.ru/magritte-ui-chat-input/internal/focusGroup';\n\n// Кандидаты в группу: интерактивные элементы, но без исключения [tabindex=\"-1\"] (в отличие от\n// FOCUSABLE_SELECTOR из common-keyboard) — менеджер сам ставит участникам -1 и должен продолжать\n// их видеть при повторных сканах\nconst CANDIDATE_SELECTOR = [\n 'button:not([disabled])',\n 'a[href]',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]',\n].join(', ');\n\nconst NAV_KEYS = [keyboardKeys.ArrowLeft, keyboardKeys.ArrowRight, keyboardKeys.Home, keyboardKeys.End];\n\n// метка «tabindex этому элементу писали мы»: его \"-1\" — не чужое намерение, а членство в группе.\n// Держим её в DOM, а не в JS-коллекции: элементы приложения приходят и уходят без нашего ведома\nconst FOCUS_MANAGED_ATTR = 'data-magritte-chat-input-focus-managed';\n\ninterface GroupSnapshot {\n /** Участники активного слоя в порядке обхода: приоритет контейнера → DOM-порядок */\n members: HTMLElement[];\n /** Все участники, включая скрытый aria-hidden слой, — им тоже держим tabindex=\"-1\" */\n all: HTMLElement[];\n}\n\n// Registry — сам DOM: участники и их порядок вычисляются сканом на каждое обращение, поэтому\n// список никогда не устаревает (кнопки приложения в слотах могут появляться и исчезать без\n// ререндера ChatInput)\nconst collectGroup = (root: HTMLElement): GroupSnapshot => {\n const items: Array<{ el: HTMLElement; index: number; priority: number }> = [];\n const all: HTMLElement[] = [];\n root.querySelectorAll<HTMLElement>(CANDIDATE_SELECTOR).forEach((el, index) => {\n // элемент вне приоритетных контейнеров (textarea, произвольный контент вне слотов) —\n // не участник, его tabindex не трогаем\n const container = el.closest(`[${FOCUS_PRIORITY_ATTR}]`);\n if (!container || !root.contains(container)) {\n return;\n }\n // чужой явный tabindex=\"-1\" уважаем (приложение само вывело элемент из фокуса);\n // свой (с меткой managed) — просто признак участника группы\n if (el.getAttribute('tabindex') === '-1' && !el.hasAttribute(FOCUS_MANAGED_ATTR)) {\n return;\n }\n all.push(el);\n // скрытый слой не участвует в обходе, но остаётся под управлением (см. applyTabStops)\n if (el.closest('[aria-hidden=\"true\"]')) {\n return;\n }\n items.push({ el, index, priority: Number(container.getAttribute(FOCUS_PRIORITY_ATTR)) });\n });\n // сортировка стабильная: при равном приоритете сохраняем исходный DOM-порядок\n const members = items.sort((a, b) => a.priority - b.priority || a.index - b.index).map(({ el }) => el);\n return { members, all };\n};\n\ninterface RovingFocusGroup {\n /** Перевести фокус на текущий tab stop группы (после смены режима — первый по приоритету\n * элемент активного слоя, т.е. галочка при входе в запись) */\n focusStop: () => void;\n}\n\n/**\n * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников\n * tabindex=\"-1\", у одного (текущего стопа) — \"0\"; внутри группы фокус ходит стрелками ←/→\n * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).\n * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.\n *\n * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать\n * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:\n * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver\n * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).\n */\nexport const useRovingFocusGroup = (root: RefObject<HTMLElement>): RovingFocusGroup => {\n const stopRef = useRef<HTMLElement | null>(null);\n\n const applyTabStops = useCallback(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return;\n }\n const { members, all } = collectGroup(rootEl);\n const prevStop = stopRef.current;\n const stop = prevStop && members.includes(prevStop) ? prevStop : (members[0] ?? null);\n stopRef.current = stop;\n all.forEach((el) => {\n const tabIndex = el === stop ? 0 : -1;\n if (el.tabIndex !== tabIndex) {\n el.tabIndex = tabIndex;\n el.setAttribute(FOCUS_MANAGED_ATTR, '');\n }\n });\n }, [root]);\n\n const focusStop = useCallback(() => {\n // observer срабатывает микротаском — пересобираем синхронно, чтобы стоп был свежим\n applyTabStops();\n stopRef.current?.focus();\n }, [applyTabStops]);\n\n useEffect(() => {\n const rootEl = root.current;\n if (!rootEl) {\n return undefined;\n }\n\n applyTabStops();\n\n const handleKeyDown = (event: KeyboardEvent) => {\n if (event.defaultPrevented || !keyboardMatches(event, NAV_KEYS)) {\n return;\n }\n if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {\n return;\n }\n const { members } = collectGroup(rootEl);\n const activeIndex = members.indexOf(document.activeElement as HTMLElement);\n // фокус вне группы (например, в textarea — там стрелки двигают каретку) — не вмешиваемся\n if (activeIndex === -1) {\n return;\n }\n event.preventDefault();\n const lastIndex = members.length - 1;\n let nextIndex: number;\n if (keyboardMatch(event, keyboardKeys.Home)) {\n nextIndex = 0;\n } else if (keyboardMatch(event, keyboardKeys.End)) {\n nextIndex = lastIndex;\n } else if (keyboardMatch(event, keyboardKeys.ArrowRight)) {\n nextIndex = activeIndex === lastIndex ? 0 : activeIndex + 1;\n } else {\n nextIndex = activeIndex === 0 ? lastIndex : activeIndex - 1;\n }\n // focusin перенесёт стоп на новый элемент\n members[nextIndex].focus();\n };\n\n // стоп следует за фокусом: кликнутый мышью или сфокусированный стрелкой участник\n // становится единственным tab stop группы\n const handleFocusIn = (event: FocusEvent) => {\n const target = event.target;\n if (!(target instanceof HTMLElement) || stopRef.current === target) {\n return;\n }\n if (!collectGroup(rootEl).members.includes(target)) {\n return;\n }\n stopRef.current = target;\n applyTabStops();\n };\n\n // ремонт расстановки: анмаунт держателя стопа, disabled, aria-hidden при смене режима.\n // tabindex в фильтре намеренно нет — собственные записи не должны будить observer\n const observer = new MutationObserver(applyTabStops);\n observer.observe(rootEl, { subtree: true, childList: true, attributeFilter: ['disabled', 'aria-hidden'] });\n\n rootEl.addEventListener('keydown', handleKeyDown);\n rootEl.addEventListener('focusin', handleFocusIn);\n return () => {\n observer.disconnect();\n rootEl.removeEventListener('keydown', handleKeyDown);\n rootEl.removeEventListener('focusin', handleFocusIn);\n };\n }, [root, applyTabStops]);\n\n return { focusStop };\n};\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,13 @@
1
+ import { type KeyboardEventHandler } from 'react';
2
+ interface UseSendOnEnterParams {
3
+ onSend?: VoidFunction;
4
+ onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;
5
+ }
6
+ /**
7
+ * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет
8
+ * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил
9
+ * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.
10
+ * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).
11
+ */
12
+ export declare const useSendOnEnter: ({ onSend, onKeyDown, }: UseSendOnEnterParams) => KeyboardEventHandler<HTMLTextAreaElement>;
13
+ export {};
@@ -0,0 +1,42 @@
1
+ import './../index.css';
2
+ import { useRef, useEffect, useCallback } from 'react';
3
+ import { keyboardMatch, keyboardKeys } from '@hh.ru/magritte-common-keyboard';
4
+
5
+ const COARSE_POINTER_QUERY = '(pointer: coarse)';
6
+ /**
7
+ * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет
8
+ * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил
9
+ * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.
10
+ * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).
11
+ */
12
+ const useSendOnEnter = ({ onSend, onKeyDown, }) => {
13
+ const isCoarsePointerRef = useRef(false);
14
+ useEffect(() => {
15
+ if (!('matchMedia' in window)) {
16
+ return undefined;
17
+ }
18
+ const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);
19
+ const update = () => {
20
+ isCoarsePointerRef.current = mediaQuery.matches;
21
+ };
22
+ update();
23
+ mediaQuery.addEventListener('change', update);
24
+ return () => mediaQuery.removeEventListener('change', update);
25
+ }, []);
26
+ return useCallback((event) => {
27
+ onKeyDown?.(event);
28
+ if (event.defaultPrevented || event.nativeEvent.isComposing) {
29
+ return;
30
+ }
31
+ if (isCoarsePointerRef.current) {
32
+ return;
33
+ }
34
+ if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {
35
+ event.preventDefault();
36
+ onSend?.();
37
+ }
38
+ }, [onSend, onKeyDown]);
39
+ };
40
+
41
+ export { useSendOnEnter };
42
+ //# sourceMappingURL=useSendOnEnter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useSendOnEnter.js","sources":["src/internal/useSendOnEnter.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, type KeyboardEvent, type KeyboardEventHandler } from 'react';\n\nimport { keyboardKeys, keyboardMatch } from '@hh.ru/magritte-common-keyboard';\n\ninterface UseSendOnEnterParams {\n onSend?: VoidFunction;\n onKeyDown?: KeyboardEventHandler<HTMLTextAreaElement>;\n}\n\nconst COARSE_POINTER_QUERY = '(pointer: coarse)';\n\n/**\n * Возвращает обработчик keydown для textarea: Enter вызывает `onSend`, Shift+Enter оставляет\n * стандартный перенос строки. Пользовательский `onKeyDown` вызывается первым — если он отменил\n * событие (`preventDefault`), отправка не срабатывает. Ввод через IME (`isComposing`) не отправляет.\n * На тач-устройствах Enter всегда переносит строку (отправка — по кнопке).\n */\nexport const useSendOnEnter = ({\n onSend,\n onKeyDown,\n}: UseSendOnEnterParams): KeyboardEventHandler<HTMLTextAreaElement> => {\n const isCoarsePointerRef = useRef(false);\n useEffect(() => {\n if (!('matchMedia' in window)) {\n return undefined;\n }\n const mediaQuery = window.matchMedia(COARSE_POINTER_QUERY);\n const update = () => {\n isCoarsePointerRef.current = mediaQuery.matches;\n };\n update();\n mediaQuery.addEventListener('change', update);\n return () => mediaQuery.removeEventListener('change', update);\n }, []);\n\n return useCallback(\n (event: KeyboardEvent<HTMLTextAreaElement>) => {\n onKeyDown?.(event);\n\n if (event.defaultPrevented || event.nativeEvent.isComposing) {\n return;\n }\n\n if (isCoarsePointerRef.current) {\n return;\n }\n\n if (keyboardMatch(event, keyboardKeys.Enter) && !event.shiftKey) {\n event.preventDefault();\n onSend?.();\n }\n },\n [onSend, onKeyDown]\n );\n};\n"],"names":[],"mappings":";;;AASA,MAAM,oBAAoB,GAAG,mBAAmB,CAAC;AAEjD;;;;;AAKG;AACU,MAAA,cAAc,GAAG,CAAC,EAC3B,MAAM,EACN,SAAS,GACU,KAA+C;AAClE,IAAA,MAAM,kBAAkB,GAAG,MAAM,CAAC,KAAK,CAAC,CAAC;IACzC,SAAS,CAAC,MAAK;AACX,QAAA,IAAI,EAAE,YAAY,IAAI,MAAM,CAAC,EAAE;AAC3B,YAAA,OAAO,SAAS,CAAC;SACpB;QACD,MAAM,UAAU,GAAG,MAAM,CAAC,UAAU,CAAC,oBAAoB,CAAC,CAAC;QAC3D,MAAM,MAAM,GAAG,MAAK;AAChB,YAAA,kBAAkB,CAAC,OAAO,GAAG,UAAU,CAAC,OAAO,CAAC;AACpD,SAAC,CAAC;AACF,QAAA,MAAM,EAAE,CAAC;AACT,QAAA,UAAU,CAAC,gBAAgB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;QAC9C,OAAO,MAAM,UAAU,CAAC,mBAAmB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;KACjE,EAAE,EAAE,CAAC,CAAC;AAEP,IAAA,OAAO,WAAW,CACd,CAAC,KAAyC,KAAI;AAC1C,QAAA,SAAS,GAAG,KAAK,CAAC,CAAC;QAEnB,IAAI,KAAK,CAAC,gBAAgB,IAAI,KAAK,CAAC,WAAW,CAAC,WAAW,EAAE;YACzD,OAAO;SACV;AAED,QAAA,IAAI,kBAAkB,CAAC,OAAO,EAAE;YAC5B,OAAO;SACV;AAED,QAAA,IAAI,aAAa,CAAC,KAAK,EAAE,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,EAAE;YAC7D,KAAK,CAAC,cAAc,EAAE,CAAC;YACvB,MAAM,IAAI,CAAC;SACd;AACL,KAAC,EACD,CAAC,MAAM,EAAE,SAAS,CAAC,CACtB,CAAC;AACN;;;;"}
@@ -0,0 +1,51 @@
1
+ import { type VoiceRecordStatus } from '@hh.ru/magritte-ui-chat-input/types';
2
+ interface UseVoiceRecordSessionParams {
3
+ /** Максимальная длительность записи в секундах; по достижении — авто-финализация, как по кнопке
4
+ * подтверждения. Не задана — пишем без ограничения */
5
+ maxDuration?: number;
6
+ /** Предпочитаемый MIME-тип записи (можно списком по убыванию приоритета). Берётся первый
7
+ * поддерживаемый `MediaRecorder.isTypeSupported`; иначе кодек выбирает браузер */
8
+ mimeType?: string | string[];
9
+ /** Запись фактически началась (микрофон получен). Стримом владеет сессия — останавливать
10
+ * его треки нельзя, только читать (анализатор, стриминговое распознавание);
11
+ * для независимого времени жизни — `stream.clone()` */
12
+ onRecordStart?: (stream: MediaStream) => void;
13
+ /** Готовая запись — по кнопке подтверждения или `maxDuration`. Если вернуть промис,
14
+ * статус `processing` держится до его завершения (реджект тоже завершает) */
15
+ onComplete?: (file: File) => void | Promise<void>;
16
+ /** Не удалось получить микрофон; статус остался `idle` */
17
+ onError?: (error: unknown) => void;
18
+ /** Запись отменена; треки отпущены, файл выброшен */
19
+ onCancel?: VoidFunction;
20
+ }
21
+ interface VoiceRecordSession {
22
+ status: VoiceRecordStatus;
23
+ /** Метка старта записи (`Date.now()`); `null` вне записи. Отображаемое время по ней считает
24
+ * потребитель (VoiceRecord) локально, чтобы посекундный тик не ререндерил корень */
25
+ startedAt: number | null;
26
+ /** Живой стрим микрофона, пока идёт запись (иначе null). Для чтения (визуализация,
27
+ * распознавание); треки принадлежат сессии — останавливать их нельзя */
28
+ stream: MediaStream | null;
29
+ /** Запросить микрофон и начать запись. Вне `idle` — no-op */
30
+ start: VoidFunction;
31
+ /** Финализировать запись: собрать файл и отдать в `onComplete` */
32
+ stop: VoidFunction;
33
+ /** Отменить запись, ничего не эмитя */
34
+ cancel: VoidFunction;
35
+ }
36
+ /**
37
+ * Запрашивает доступ к микрофону и возвращает промис с готовым потоком.
38
+ *
39
+ * Владение потоком — на стороне вызывающего: он сам решает, когда его отпустить
40
+ * (`stream.getTracks().forEach((track) => track.stop())`). VoiceRecord получает уже готовый
41
+ * `stream` и только пишет, а доступ и жизненный цикл потока остаются снаружи.
42
+ */
43
+ export declare const requestMicrophone: () => Promise<MediaStream>;
44
+ /**
45
+ * Сессия записи голоса целиком: владеет микрофоном (getUserMedia в `start`, треки отпускаются
46
+ * при завершении/отмене/анмаунте), MediaRecorder'ом и таймером. `start` вызывается из обработчика
47
+ * клика, поэтому запроса доступа на монтировании (и проблем со StrictMode) нет. Актуальные колбэки
48
+ * держим в рефах — императивные ручки стабильны.
49
+ */
50
+ export declare const useVoiceRecordSession: ({ maxDuration, mimeType, onRecordStart, onComplete, onError, onCancel, }: UseVoiceRecordSessionParams) => VoiceRecordSession;
51
+ export {};
@@ -0,0 +1,245 @@
1
+ import './../index.css';
2
+ import { useState, useRef, useCallback, useEffect } from 'react';
3
+
4
+ const RECORDING_MIME_FALLBACK = 'audio/webm';
5
+ const RECORDING_FILE_BASENAME = 'voice-message';
6
+ // расширение файла из фактического MIME записи: браузеры выдают разные форматы (webm в
7
+ // Chrome/Firefox, mp4 в Safari), а бэкенд обычно смотрит на расширение — оно должно совпадать
8
+ // с содержимым, иначе mp4 уедет под именем .webm
9
+ const MIME_SUBTYPE_TO_EXTENSION = {
10
+ webm: 'webm',
11
+ ogg: 'ogg',
12
+ mp4: 'm4a',
13
+ 'x-m4a': 'm4a',
14
+ mpeg: 'mp3',
15
+ wav: 'wav',
16
+ 'x-wav': 'wav',
17
+ };
18
+ const fileNameForMime = (mimeType) => {
19
+ // «audio/webm;codecs=opus» -> «webm»
20
+ const subtype = mimeType.split(';')[0].trim().split('/')[1] ?? '';
21
+ const extension = MIME_SUBTYPE_TO_EXTENSION[subtype] ?? (subtype || 'webm');
22
+ return `${RECORDING_FILE_BASENAME}.${extension}`;
23
+ };
24
+ // первый поддерживаемый браузером тип из предпочтений; null — писать в дефолтном кодеке браузера
25
+ const toMimeTypeList = (preferred) => {
26
+ if (Array.isArray(preferred)) {
27
+ return preferred;
28
+ }
29
+ return preferred ? [preferred] : [];
30
+ };
31
+ const pickSupportedMimeType = (preferred) => {
32
+ if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {
33
+ return null;
34
+ }
35
+ return toMimeTypeList(preferred).find((type) => MediaRecorder.isTypeSupported(type)) ?? null;
36
+ };
37
+ /**
38
+ * Запрашивает доступ к микрофону и возвращает промис с готовым потоком.
39
+ *
40
+ * Владение потоком — на стороне вызывающего: он сам решает, когда его отпустить
41
+ * (`stream.getTracks().forEach((track) => track.stop())`). VoiceRecord получает уже готовый
42
+ * `stream` и только пишет, а доступ и жизненный цикл потока остаются снаружи.
43
+ */
44
+ const requestMicrophone = () => {
45
+ if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
46
+ return Promise.reject(new Error('navigator.mediaDevices.getUserMedia is not available'));
47
+ }
48
+ return navigator.mediaDevices.getUserMedia({ audio: true });
49
+ };
50
+ /**
51
+ * Сессия записи голоса целиком: владеет микрофоном (getUserMedia в `start`, треки отпускаются
52
+ * при завершении/отмене/анмаунте), MediaRecorder'ом и таймером. `start` вызывается из обработчика
53
+ * клика, поэтому запроса доступа на монтировании (и проблем со StrictMode) нет. Актуальные колбэки
54
+ * держим в рефах — императивные ручки стабильны.
55
+ */
56
+ const useVoiceRecordSession = ({ maxDuration, mimeType, onRecordStart, onComplete, onError, onCancel, }) => {
57
+ const [status, setStatus] = useState('idle');
58
+ // метка старта записи (Date.now) — по ней таймер VoiceRecord считает время у себя в листе;
59
+ // меняется дважды за сессию (старт/конец), а не раз в секунду, поэтому корень не ререндерится
60
+ const [startedAt, setStartedAt] = useState(null);
61
+ // живой стрим наружу для визуализации/распознавания; выставляется только на время записи
62
+ const [stream, setStream] = useState(null);
63
+ const statusRef = useRef(status);
64
+ const updateStatus = useCallback((next) => {
65
+ statusRef.current = next;
66
+ setStatus(next);
67
+ }, []);
68
+ const onRecordStartRef = useRef(onRecordStart);
69
+ onRecordStartRef.current = onRecordStart;
70
+ const onCompleteRef = useRef(onComplete);
71
+ onCompleteRef.current = onComplete;
72
+ const onErrorRef = useRef(onError);
73
+ onErrorRef.current = onError;
74
+ const onCancelRef = useRef(onCancel);
75
+ onCancelRef.current = onCancel;
76
+ const maxDurationRef = useRef(maxDuration);
77
+ maxDurationRef.current = maxDuration;
78
+ const mimeTypeRef = useRef(mimeType);
79
+ mimeTypeRef.current = mimeType;
80
+ const recordingRef = useRef(null);
81
+ const unmountedRef = useRef(false);
82
+ const stop = useCallback(() => {
83
+ const recording = recordingRef.current;
84
+ if (!recording || statusRef.current !== 'recording') {
85
+ return;
86
+ }
87
+ updateStatus('processing');
88
+ setStream(null);
89
+ setStartedAt(null);
90
+ clearInterval(recording.intervalId);
91
+ recording.emitOnStop = true;
92
+ recordingRef.current = null;
93
+ if (recording.recorder.state !== 'inactive') {
94
+ recording.recorder.stop();
95
+ }
96
+ }, [updateStatus]);
97
+ const beginRecording = useCallback((stream) => {
98
+ const picked = pickSupportedMimeType(mimeTypeRef.current);
99
+ const recorder = new MediaRecorder(stream, picked ? { mimeType: picked } : undefined);
100
+ const chunks = [];
101
+ const startTime = Date.now();
102
+ const recording = { stream, recorder, emitOnStop: false };
103
+ recorder.ondataavailable = (event) => {
104
+ if (event.data.size > 0) {
105
+ chunks.push(event.data);
106
+ }
107
+ };
108
+ recorder.onstop = () => {
109
+ stream.getTracks().forEach((track) => track.stop());
110
+ if (recording.emitOnStop) {
111
+ const type = recorder.mimeType || RECORDING_MIME_FALLBACK;
112
+ const file = new File([new Blob(chunks, { type })], fileNameForMime(type), { type });
113
+ // держим processing, пока приложение обрабатывает запись; ошибки обработки —
114
+ // зона ответственности приложения, мы по ним только выходим из processing
115
+ Promise.resolve()
116
+ .then(() => onCompleteRef.current?.(file))
117
+ .catch(() => undefined)
118
+ .finally(() => {
119
+ if (!unmountedRef.current && statusRef.current === 'processing') {
120
+ updateStatus('idle');
121
+ }
122
+ });
123
+ return;
124
+ }
125
+ // не эмитим: либо это наша отмена/анмаунт (там recordingRef уже очищен до stop()),
126
+ // либо запись оборвалась сама — микрофон отключили/отозвали доступ. Во втором случае
127
+ // recordingRef всё ещё указывает на нас: гасим сессию и сообщаем об ошибке, иначе
128
+ // статус завис бы в recording с мёртвым стримом и без выхода
129
+ if (recordingRef.current === recording) {
130
+ recordingRef.current = null;
131
+ clearInterval(recording.intervalId);
132
+ if (!unmountedRef.current) {
133
+ setStream(null);
134
+ setStartedAt(null);
135
+ updateStatus('idle');
136
+ onErrorRef.current?.(new Error('Voice recording stopped unexpectedly'));
137
+ }
138
+ }
139
+ };
140
+ // ошибка рекордера — обрываем запись; чистка и переход в idle пойдут через onstop выше
141
+ recorder.onerror = () => {
142
+ if (recording.recorder.state !== 'inactive') {
143
+ recording.recorder.stop();
144
+ }
145
+ };
146
+ // микрофон физически пропал (отключили гарнитуру, отозвали доступ) — аудиотрек завершается;
147
+ // сами останавливаем рекордер, дальше отрабатывает ветка неожиданной остановки в onstop
148
+ stream.getAudioTracks().forEach((track) => {
149
+ track.addEventListener('ended', () => {
150
+ if (recordingRef.current === recording && recording.recorder.state !== 'inactive') {
151
+ recording.recorder.stop();
152
+ }
153
+ });
154
+ });
155
+ // таймер живёт только ради авто-финализации по maxDuration и не трогает React-стейт;
156
+ // отображаемое время считает VoiceRecord у себя по startedAt
157
+ const tick = () => {
158
+ const max = maxDurationRef.current;
159
+ if (max !== undefined && Math.floor((Date.now() - startTime) / 1000) >= max) {
160
+ stop();
161
+ }
162
+ };
163
+ recorder.start();
164
+ recording.intervalId = setInterval(tick, 1000);
165
+ recordingRef.current = recording;
166
+ setStartedAt(startTime);
167
+ setStream(stream);
168
+ updateStatus('recording');
169
+ onRecordStartRef.current?.(stream);
170
+ }, [stop, updateStatus]);
171
+ const start = useCallback(() => {
172
+ if (statusRef.current !== 'idle') {
173
+ return;
174
+ }
175
+ updateStatus('starting');
176
+ requestMicrophone()
177
+ .then((stream) => {
178
+ // пока ждали доступ, хук могли размонтировать — тогда сразу отпускаем треки
179
+ if (unmountedRef.current || statusRef.current !== 'starting') {
180
+ stream.getTracks().forEach((track) => track.stop());
181
+ return;
182
+ }
183
+ try {
184
+ beginRecording(stream);
185
+ }
186
+ catch (error) {
187
+ // не удалось создать/запустить MediaRecorder (неподдержанный кодек, отсутствие API) —
188
+ // отпускаем уже полученный микрофон, иначе индикатор «микрофон занят» останется гореть
189
+ stream.getTracks().forEach((track) => track.stop());
190
+ if (!unmountedRef.current) {
191
+ updateStatus('idle');
192
+ onErrorRef.current?.(error);
193
+ }
194
+ }
195
+ })
196
+ .catch((error) => {
197
+ if (unmountedRef.current || statusRef.current !== 'starting') {
198
+ return;
199
+ }
200
+ updateStatus('idle');
201
+ onErrorRef.current?.(error);
202
+ });
203
+ }, [beginRecording, updateStatus]);
204
+ const cancel = useCallback(() => {
205
+ // отмена во время ожидания микрофона: записи ещё нет, но pending-старт надо оборвать —
206
+ // сбрасываем статус, и requestMicrophone().then увидит !== 'starting' и отпустит треки сам
207
+ if (statusRef.current === 'starting') {
208
+ updateStatus('idle');
209
+ onCancelRef.current?.();
210
+ return;
211
+ }
212
+ const recording = recordingRef.current;
213
+ if (!recording || statusRef.current !== 'recording') {
214
+ return;
215
+ }
216
+ setStream(null);
217
+ clearInterval(recording.intervalId);
218
+ recordingRef.current = null;
219
+ // emitOnStop остался false: onstop только отпустит треки
220
+ if (recording.recorder.state !== 'inactive') {
221
+ recording.recorder.stop();
222
+ }
223
+ updateStatus('idle');
224
+ setStartedAt(null);
225
+ onCancelRef.current?.();
226
+ }, [updateStatus]);
227
+ useEffect(() => {
228
+ unmountedRef.current = false;
229
+ return () => {
230
+ unmountedRef.current = true;
231
+ const recording = recordingRef.current;
232
+ if (recording) {
233
+ clearInterval(recording.intervalId);
234
+ recordingRef.current = null;
235
+ if (recording.recorder.state !== 'inactive') {
236
+ recording.recorder.stop();
237
+ }
238
+ }
239
+ };
240
+ }, []);
241
+ return { status, startedAt, stream, start, stop, cancel };
242
+ };
243
+
244
+ export { requestMicrophone, useVoiceRecordSession };
245
+ //# sourceMappingURL=useVoiceRecordSession.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useVoiceRecordSession.js","sources":["src/internal/useVoiceRecordSession.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { type VoiceRecordStatus } from '@hh.ru/magritte-ui-chat-input/types';\n\ninterface UseVoiceRecordSessionParams {\n /** Максимальная длительность записи в секундах; по достижении — авто-финализация, как по кнопке\n * подтверждения. Не задана — пишем без ограничения */\n maxDuration?: number;\n /** Предпочитаемый MIME-тип записи (можно списком по убыванию приоритета). Берётся первый\n * поддерживаемый `MediaRecorder.isTypeSupported`; иначе кодек выбирает браузер */\n mimeType?: string | string[];\n /** Запись фактически началась (микрофон получен). Стримом владеет сессия — останавливать\n * его треки нельзя, только читать (анализатор, стриминговое распознавание);\n * для независимого времени жизни — `stream.clone()` */\n onRecordStart?: (stream: MediaStream) => void;\n /** Готовая запись — по кнопке подтверждения или `maxDuration`. Если вернуть промис,\n * статус `processing` держится до его завершения (реджект тоже завершает) */\n onComplete?: (file: File) => void | Promise<void>;\n /** Не удалось получить микрофон; статус остался `idle` */\n onError?: (error: unknown) => void;\n /** Запись отменена; треки отпущены, файл выброшен */\n onCancel?: VoidFunction;\n}\n\ninterface VoiceRecordSession {\n status: VoiceRecordStatus;\n /** Метка старта записи (`Date.now()`); `null` вне записи. Отображаемое время по ней считает\n * потребитель (VoiceRecord) локально, чтобы посекундный тик не ререндерил корень */\n startedAt: number | null;\n /** Живой стрим микрофона, пока идёт запись (иначе null). Для чтения (визуализация,\n * распознавание); треки принадлежат сессии — останавливать их нельзя */\n stream: MediaStream | null;\n /** Запросить микрофон и начать запись. Вне `idle` — no-op */\n start: VoidFunction;\n /** Финализировать запись: собрать файл и отдать в `onComplete` */\n stop: VoidFunction;\n /** Отменить запись, ничего не эмитя */\n cancel: VoidFunction;\n}\n\ninterface ActiveRecording {\n stream: MediaStream;\n recorder: MediaRecorder;\n intervalId?: ReturnType<typeof setInterval>;\n /** Эмитить ли файл в onstop: true только при финализации (stop/maxDuration),\n * при отмене и анмаунте остаётся false */\n emitOnStop: boolean;\n}\n\nconst RECORDING_MIME_FALLBACK = 'audio/webm';\nconst RECORDING_FILE_BASENAME = 'voice-message';\n\n// расширение файла из фактического MIME записи: браузеры выдают разные форматы (webm в\n// Chrome/Firefox, mp4 в Safari), а бэкенд обычно смотрит на расширение — оно должно совпадать\n// с содержимым, иначе mp4 уедет под именем .webm\nconst MIME_SUBTYPE_TO_EXTENSION: Record<string, string> = {\n webm: 'webm',\n ogg: 'ogg',\n mp4: 'm4a',\n 'x-m4a': 'm4a',\n mpeg: 'mp3',\n wav: 'wav',\n 'x-wav': 'wav',\n};\n\nconst fileNameForMime = (mimeType: string): string => {\n // «audio/webm;codecs=opus» -> «webm»\n const subtype = mimeType.split(';')[0].trim().split('/')[1] ?? '';\n const extension = MIME_SUBTYPE_TO_EXTENSION[subtype] ?? (subtype || 'webm');\n return `${RECORDING_FILE_BASENAME}.${extension}`;\n};\n\n// первый поддерживаемый браузером тип из предпочтений; null — писать в дефолтном кодеке браузера\nconst toMimeTypeList = (preferred: string | string[] | undefined): string[] => {\n if (Array.isArray(preferred)) {\n return preferred;\n }\n return preferred ? [preferred] : [];\n};\n\nconst pickSupportedMimeType = (preferred: string | string[] | undefined): string | null => {\n if (typeof MediaRecorder === 'undefined' || !MediaRecorder.isTypeSupported) {\n return null;\n }\n return toMimeTypeList(preferred).find((type) => MediaRecorder.isTypeSupported(type)) ?? null;\n};\n\n/**\n * Запрашивает доступ к микрофону и возвращает промис с готовым потоком.\n *\n * Владение потоком — на стороне вызывающего: он сам решает, когда его отпустить\n * (`stream.getTracks().forEach((track) => track.stop())`). VoiceRecord получает уже готовый\n * `stream` и только пишет, а доступ и жизненный цикл потока остаются снаружи.\n */\nexport const requestMicrophone = (): Promise<MediaStream> => {\n if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {\n return Promise.reject(new Error('navigator.mediaDevices.getUserMedia is not available'));\n }\n\n return navigator.mediaDevices.getUserMedia({ audio: true });\n};\n\n/**\n * Сессия записи голоса целиком: владеет микрофоном (getUserMedia в `start`, треки отпускаются\n * при завершении/отмене/анмаунте), MediaRecorder'ом и таймером. `start` вызывается из обработчика\n * клика, поэтому запроса доступа на монтировании (и проблем со StrictMode) нет. Актуальные колбэки\n * держим в рефах — императивные ручки стабильны.\n */\nexport const useVoiceRecordSession = ({\n maxDuration,\n mimeType,\n onRecordStart,\n onComplete,\n onError,\n onCancel,\n}: UseVoiceRecordSessionParams): VoiceRecordSession => {\n const [status, setStatus] = useState<VoiceRecordStatus>('idle');\n // метка старта записи (Date.now) — по ней таймер VoiceRecord считает время у себя в листе;\n // меняется дважды за сессию (старт/конец), а не раз в секунду, поэтому корень не ререндерится\n const [startedAt, setStartedAt] = useState<number | null>(null);\n // живой стрим наружу для визуализации/распознавания; выставляется только на время записи\n const [stream, setStream] = useState<MediaStream | null>(null);\n\n const statusRef = useRef(status);\n const updateStatus = useCallback((next: VoiceRecordStatus) => {\n statusRef.current = next;\n setStatus(next);\n }, []);\n\n const onRecordStartRef = useRef(onRecordStart);\n onRecordStartRef.current = onRecordStart;\n const onCompleteRef = useRef(onComplete);\n onCompleteRef.current = onComplete;\n const onErrorRef = useRef(onError);\n onErrorRef.current = onError;\n const onCancelRef = useRef(onCancel);\n onCancelRef.current = onCancel;\n const maxDurationRef = useRef(maxDuration);\n maxDurationRef.current = maxDuration;\n const mimeTypeRef = useRef(mimeType);\n mimeTypeRef.current = mimeType;\n\n const recordingRef = useRef<ActiveRecording | null>(null);\n const unmountedRef = useRef(false);\n\n const stop = useCallback(() => {\n const recording = recordingRef.current;\n if (!recording || statusRef.current !== 'recording') {\n return;\n }\n updateStatus('processing');\n setStream(null);\n setStartedAt(null);\n clearInterval(recording.intervalId);\n recording.emitOnStop = true;\n recordingRef.current = null;\n if (recording.recorder.state !== 'inactive') {\n recording.recorder.stop();\n }\n }, [updateStatus]);\n\n const beginRecording = useCallback(\n (stream: MediaStream) => {\n const picked = pickSupportedMimeType(mimeTypeRef.current);\n const recorder = new MediaRecorder(stream, picked ? { mimeType: picked } : undefined);\n const chunks: Blob[] = [];\n const startTime = Date.now();\n const recording: ActiveRecording = { stream, recorder, emitOnStop: false };\n\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) {\n chunks.push(event.data);\n }\n };\n recorder.onstop = () => {\n stream.getTracks().forEach((track) => track.stop());\n if (recording.emitOnStop) {\n const type = recorder.mimeType || RECORDING_MIME_FALLBACK;\n const file = new File([new Blob(chunks, { type })], fileNameForMime(type), { type });\n // держим processing, пока приложение обрабатывает запись; ошибки обработки —\n // зона ответственности приложения, мы по ним только выходим из processing\n Promise.resolve()\n .then(() => onCompleteRef.current?.(file))\n .catch(() => undefined)\n .finally(() => {\n if (!unmountedRef.current && statusRef.current === 'processing') {\n updateStatus('idle');\n }\n });\n return;\n }\n // не эмитим: либо это наша отмена/анмаунт (там recordingRef уже очищен до stop()),\n // либо запись оборвалась сама — микрофон отключили/отозвали доступ. Во втором случае\n // recordingRef всё ещё указывает на нас: гасим сессию и сообщаем об ошибке, иначе\n // статус завис бы в recording с мёртвым стримом и без выхода\n if (recordingRef.current === recording) {\n recordingRef.current = null;\n clearInterval(recording.intervalId);\n if (!unmountedRef.current) {\n setStream(null);\n setStartedAt(null);\n updateStatus('idle');\n onErrorRef.current?.(new Error('Voice recording stopped unexpectedly'));\n }\n }\n };\n\n // ошибка рекордера — обрываем запись; чистка и переход в idle пойдут через onstop выше\n recorder.onerror = () => {\n if (recording.recorder.state !== 'inactive') {\n recording.recorder.stop();\n }\n };\n\n // микрофон физически пропал (отключили гарнитуру, отозвали доступ) — аудиотрек завершается;\n // сами останавливаем рекордер, дальше отрабатывает ветка неожиданной остановки в onstop\n stream.getAudioTracks().forEach((track) => {\n track.addEventListener('ended', () => {\n if (recordingRef.current === recording && recording.recorder.state !== 'inactive') {\n recording.recorder.stop();\n }\n });\n });\n\n // таймер живёт только ради авто-финализации по maxDuration и не трогает React-стейт;\n // отображаемое время считает VoiceRecord у себя по startedAt\n const tick = () => {\n const max = maxDurationRef.current;\n if (max !== undefined && Math.floor((Date.now() - startTime) / 1000) >= max) {\n stop();\n }\n };\n\n recorder.start();\n recording.intervalId = setInterval(tick, 1000);\n recordingRef.current = recording;\n setStartedAt(startTime);\n setStream(stream);\n updateStatus('recording');\n onRecordStartRef.current?.(stream);\n },\n [stop, updateStatus]\n );\n\n const start = useCallback(() => {\n if (statusRef.current !== 'idle') {\n return;\n }\n updateStatus('starting');\n requestMicrophone()\n .then((stream) => {\n // пока ждали доступ, хук могли размонтировать — тогда сразу отпускаем треки\n if (unmountedRef.current || statusRef.current !== 'starting') {\n stream.getTracks().forEach((track) => track.stop());\n return;\n }\n try {\n beginRecording(stream);\n } catch (error) {\n // не удалось создать/запустить MediaRecorder (неподдержанный кодек, отсутствие API) —\n // отпускаем уже полученный микрофон, иначе индикатор «микрофон занят» останется гореть\n stream.getTracks().forEach((track) => track.stop());\n if (!unmountedRef.current) {\n updateStatus('idle');\n onErrorRef.current?.(error);\n }\n }\n })\n .catch((error: unknown) => {\n if (unmountedRef.current || statusRef.current !== 'starting') {\n return;\n }\n updateStatus('idle');\n onErrorRef.current?.(error);\n });\n }, [beginRecording, updateStatus]);\n\n const cancel = useCallback(() => {\n // отмена во время ожидания микрофона: записи ещё нет, но pending-старт надо оборвать —\n // сбрасываем статус, и requestMicrophone().then увидит !== 'starting' и отпустит треки сам\n if (statusRef.current === 'starting') {\n updateStatus('idle');\n onCancelRef.current?.();\n return;\n }\n\n const recording = recordingRef.current;\n if (!recording || statusRef.current !== 'recording') {\n return;\n }\n setStream(null);\n clearInterval(recording.intervalId);\n recordingRef.current = null;\n // emitOnStop остался false: onstop только отпустит треки\n if (recording.recorder.state !== 'inactive') {\n recording.recorder.stop();\n }\n updateStatus('idle');\n setStartedAt(null);\n onCancelRef.current?.();\n }, [updateStatus]);\n\n useEffect(() => {\n unmountedRef.current = false;\n return () => {\n unmountedRef.current = true;\n const recording = recordingRef.current;\n if (recording) {\n clearInterval(recording.intervalId);\n recordingRef.current = null;\n if (recording.recorder.state !== 'inactive') {\n recording.recorder.stop();\n }\n }\n };\n }, []);\n\n return { status, startedAt, stream, start, stop, cancel };\n};\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ import { type ChatInputVoiceRecordConfig } from '@hh.ru/magritte-ui-chat-input/types';
2
+ export declare const ChatInputVoiceSlotProvider: import("react").FC<{
3
+ children?: import("react").ReactNode | undefined;
4
+ }>;
5
+ export declare const useRegisterVoiceConfig: (config: ChatInputVoiceRecordConfig | null | undefined) => void;
6
+ export declare const useVoiceConfig: () => ChatInputVoiceRecordConfig | undefined;
@@ -0,0 +1,10 @@
1
+ import './../index.css';
2
+ import { createSlotRegistry } from '@hh.ru/magritte-internal-slot-registry';
3
+
4
+ const { SlotRegistryProvider, useRegisterSlot, useSlotRegistryValue } = createSlotRegistry('ChatInputVoice');
5
+ const ChatInputVoiceSlotProvider = SlotRegistryProvider;
6
+ const useRegisterVoiceConfig = (config) => useRegisterSlot('voiceConfig', config);
7
+ const useVoiceConfig = () => useSlotRegistryValue('voiceConfig');
8
+
9
+ export { ChatInputVoiceSlotProvider, useRegisterVoiceConfig, useVoiceConfig };
10
+ //# sourceMappingURL=voiceSlotRegistry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"voiceSlotRegistry.js","sources":["src/internal/voiceSlotRegistry.ts"],"sourcesContent":["import { createSlotRegistry } from '@hh.ru/magritte-internal-slot-registry';\nimport { type ChatInputVoiceRecordConfig } from '@hh.ru/magritte-ui-chat-input/types';\n\ninterface ChatInputVoiceSlots {\n voiceConfig: ChatInputVoiceRecordConfig;\n}\n\nconst { SlotRegistryProvider, useRegisterSlot, useSlotRegistryValue } =\n createSlotRegistry<ChatInputVoiceSlots>('ChatInputVoice');\n\nexport const ChatInputVoiceSlotProvider = SlotRegistryProvider;\nexport const useRegisterVoiceConfig = (config: ChatInputVoiceRecordConfig | null | undefined): void =>\n useRegisterSlot('voiceConfig', config);\nexport const useVoiceConfig = (): ChatInputVoiceRecordConfig | undefined => useSlotRegistryValue('voiceConfig');\n"],"names":[],"mappings":";;AAOA,MAAM,EAAE,oBAAoB,EAAE,eAAe,EAAE,oBAAoB,EAAE,GACjE,kBAAkB,CAAsB,gBAAgB,CAAC,CAAC;AAEvD,MAAM,0BAA0B,GAAG,qBAAqB;AACxD,MAAM,sBAAsB,GAAG,CAAC,MAAqD,KACxF,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE;AAC9B,MAAA,cAAc,GAAG,MAA8C,oBAAoB,CAAC,aAAa;;;;"}