@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,149 @@
1
+ import './../index.css';
2
+ import { jsxs, jsx } from 'react/jsx-runtime';
3
+ import { forwardRef, useState, useCallback, useRef, useLayoutEffect } from 'react';
4
+ import classNames from 'classnames';
5
+ import { useMultipleRefs } from '@hh.ru/magritte-common-use-multiple-refs';
6
+ import { useSendOnEnter } from './useSendOnEnter.js';
7
+ import { useScrollbar, Scrollbar } from '@hh.ru/magritte-ui-scrollbar';
8
+ import { s as styles } from '../styles-CQQdqOFh.js';
9
+ import '@hh.ru/magritte-common-keyboard';
10
+
11
+ const MIN_HEIGHT = 20;
12
+ // допуск на субпиксельные значения скролла
13
+ const HIDDEN_CONTENT_THRESHOLD = 4;
14
+ // вертикальные паддинги ввода - источник правды здесь, ставятся инлайном (в LESS только
15
+ // горизонтальные): фейд и раскладка считают по одним и тем же значениям без чтения стилей из DOM.
16
+ // spacious - ввод на своём ряду (не в нижнем, где контролы), base - все остальные раскладки
17
+ const VERTICAL_PADDING = {
18
+ base: { top: 8, bottom: 8 },
19
+ spacious: { top: 12, bottom: 16 },
20
+ };
21
+ const clamp = (value, min, max) => Math.max(Math.min(max, value), min);
22
+ const useTextInputHeight = ({ inputRef, layout, maxHeight, containerWidth, value, onScrollHeightChange, }) => {
23
+ const [currentState, setCurrentState] = useState(() => ({
24
+ layout,
25
+ height: MIN_HEIGHT,
26
+ }));
27
+ const prevScrollHeightRef = useRef(null);
28
+ useLayoutEffect(() => {
29
+ const input = inputRef.current;
30
+ if (!input) {
31
+ return;
32
+ }
33
+ // изменение реального scrollHeight (например, рост контента при упоре в maxHeight) —
34
+ // повод пересчитать зависимое (скроллбар); читаем до измерительных тоглов
35
+ if (prevScrollHeightRef.current !== null && prevScrollHeightRef.current !== input.scrollHeight) {
36
+ onScrollHeightChange?.();
37
+ }
38
+ prevScrollHeightRef.current = input.scrollHeight;
39
+ // высота контента на ширине конкретного слота: .measure схлопывает высоту и паддинги
40
+ // (scrollHeight = чистая высота контента), layout-vertical выбирает вертикальный/горизонтальный слот
41
+ const measureContentHeight = (targetVertical) => {
42
+ input.classList.add(styles.measure);
43
+ input.classList.toggle(styles.layoutVertical, targetVertical);
44
+ const height = input.scrollHeight;
45
+ input.classList.toggle(styles.layoutVertical, currentState.layout === 'vertical');
46
+ input.classList.remove(styles.measure);
47
+ return height;
48
+ };
49
+ // лейаут определяется по горизонтальному слоту (влезает ли контент в одну строку). Замер
50
+ // горизонтального слота — форс-рефлоу, поэтому сначала снимаем заведомо известные случаи:
51
+ // внешний layout='vertical' и перенос строки в значении гарантируют вертикаль, пустое
52
+ // значение — горизонталь; замеряем только неоднозначный однострочный случай (перенос по ширине).
53
+ // Высоту всегда берём на ширине целевого слота — иначе размер меряется на старом месте.
54
+ let isVertical;
55
+ if (layout === 'vertical' || value.includes('\n')) {
56
+ isVertical = true;
57
+ }
58
+ else if (value === '') {
59
+ isVertical = false;
60
+ }
61
+ else {
62
+ isVertical = measureContentHeight(false) > MIN_HEIGHT;
63
+ }
64
+ const contentHeight = isVertical ? measureContentHeight(true) : MIN_HEIGHT;
65
+ const newState = {
66
+ layout: isVertical ? 'vertical' : 'horizontal',
67
+ height: clamp(contentHeight, MIN_HEIGHT, maxHeight ?? contentHeight),
68
+ };
69
+ if (Object.keys(currentState).some((key) => currentState[key] !== newState[key])) {
70
+ setCurrentState(newState);
71
+ }
72
+ // currentState не должно быть в зависимостях, иначе зациклится обновление
73
+ // eslint-disable-next-line disable-autofix/react-hooks/exhaustive-deps
74
+ }, [layout, maxHeight, value, inputRef, containerWidth]);
75
+ return currentState;
76
+ };
77
+ const useFocus = () => {
78
+ const [isFocused, setFocused] = useState(false);
79
+ const focusHandler = useCallback((event) => {
80
+ setFocused(event.target.classList.contains('focus-visible'));
81
+ }, []);
82
+ const blurHandler = useCallback(() => {
83
+ setFocused(false);
84
+ }, []);
85
+ return { focusHandler, blurHandler, isFocused };
86
+ };
87
+ const TextInputComponent = ({ value, maxHeight, layout, onSend, containerWidth, hasCentral, hasBothSideControls, onKeyDown, onScroll, className, style, ...textareaProps }, forwardedRef) => {
88
+ const inputRef = useRef(null);
89
+ const { hasVerticalScroll, verticalScrollbarProps, scrollContainerRef, resizeCallbackRef, hoverContainerCssClass } = useScrollbar({ axis: 'vertical' });
90
+ const refCallback = useMultipleRefs(inputRef, scrollContainerRef, forwardedRef);
91
+ // ResizeObserver внутри useScrollbar видит только изменения размера textarea:
92
+ // после упора в maxHeight растет лишь scrollHeight - пересчитываем трек по нему
93
+ const handleScrollHeightChange = useCallback(() => {
94
+ resizeCallbackRef.current?.();
95
+ }, [resizeCallbackRef]);
96
+ const inputParams = useTextInputHeight({
97
+ inputRef,
98
+ layout,
99
+ maxHeight,
100
+ value,
101
+ containerWidth,
102
+ onScrollHeightChange: handleScrollHeightChange,
103
+ });
104
+ const verticalPadding = hasCentral || (hasBothSideControls && inputParams.layout === 'vertical')
105
+ ? VERTICAL_PADDING.spacious
106
+ : VERTICAL_PADDING.base;
107
+ // фейд у краёв показываем, только когда за соответствующим краем есть непроскролленный контент
108
+ const [hasHiddenTop, setHasHiddenTop] = useState(false);
109
+ const [hasHiddenBottom, setHasHiddenBottom] = useState(false);
110
+ const updateFades = useCallback(() => {
111
+ const input = inputRef.current;
112
+ if (!input) {
113
+ return;
114
+ }
115
+ // верхний и нижний паддинги - часть скроллируемой области, но контента в них нет:
116
+ // остаток скролла в их пределах не значит, что текст скрыт
117
+ const hiddenTop = input.scrollTop - verticalPadding.top;
118
+ const hiddenBottom = input.scrollHeight - input.scrollTop - input.clientHeight - verticalPadding.bottom;
119
+ setHasHiddenTop(hiddenTop > HIDDEN_CONTENT_THRESHOLD);
120
+ setHasHiddenBottom(hiddenBottom > HIDDEN_CONTENT_THRESHOLD);
121
+ }, [verticalPadding.top, verticalPadding.bottom]);
122
+ // позиция скролла отслеживается через onScroll, здесь - изменения контента, высоты и ширины
123
+ // контейнера: при упоре в maxHeight высота не меняется, но текст перетекает и за краями
124
+ // может появиться/исчезнуть скрытый контент
125
+ useLayoutEffect(() => {
126
+ updateFades();
127
+ }, [value, inputParams.height, containerWidth, updateFades]);
128
+ const handleKeyDown = useSendOnEnter({ onSend, onKeyDown });
129
+ const handleScroll = useCallback((event) => {
130
+ updateFades();
131
+ onScroll?.(event);
132
+ }, [updateFades, onScroll]);
133
+ const { isFocused, focusHandler, blurHandler } = useFocus();
134
+ return (jsxs("div", { className: classNames(styles.textInputWrapper, hoverContainerCssClass), children: [jsx("textarea", { enterKeyHint: "enter", ...textareaProps, onFocus: focusHandler, onBlur: blurHandler, className: classNames(styles.textInput, {
135
+ [styles.layoutVertical]: inputParams.layout === 'vertical',
136
+ [styles.withTopFade]: hasHiddenTop,
137
+ [styles.withBottomFade]: hasHiddenBottom,
138
+ [styles.textInputFocused]: isFocused,
139
+ }, className), style: {
140
+ ...style,
141
+ height: inputParams.height,
142
+ paddingTop: verticalPadding.top,
143
+ paddingBottom: verticalPadding.bottom,
144
+ }, ref: refCallback, value: value, onKeyDown: handleKeyDown, onScroll: handleScroll, "data-qa": "text-input" }), hasVerticalScroll && (jsx("div", { className: styles.scrollbarCell, children: jsx(Scrollbar, { ...verticalScrollbarProps }) }))] }));
145
+ };
146
+ const TextInput = forwardRef(TextInputComponent);
147
+
148
+ export { TextInput, useFocus };
149
+ //# sourceMappingURL=TextInput.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TextInput.js","sources":["src/internal/TextInput.tsx"],"sourcesContent":["import {\n type ForwardedRef,\n forwardRef,\n type RefObject,\n type TextareaHTMLAttributes,\n type UIEvent,\n type FocusEventHandler,\n useCallback,\n useLayoutEffect,\n useRef,\n useState,\n} from 'react';\nimport classNames from 'classnames';\n\nimport { useMultipleRefs } from '@hh.ru/magritte-common-use-multiple-refs';\nimport { useSendOnEnter } from '@hh.ru/magritte-ui-chat-input/internal/useSendOnEnter';\nimport { Scrollbar, useScrollbar } from '@hh.ru/magritte-ui-scrollbar';\n\nimport styles from '../styles.less';\n\ntype TextInputProps = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, 'value'> & {\n value: string;\n maxHeight?: number;\n layout: 'vertical' | 'horizontal';\n onSend?: VoidFunction;\n /** Ширина контейнера; её изменение пере-измеряет layout (перенос текста зависит от ширины) */\n containerWidth?: number;\n /** Есть центральный слот - ввод всегда на своём ряду, паддинги spacious */\n hasCentral: boolean;\n /** Контролы с обеих сторон - в вертикальном лейауте ввод уезжает на свой ряд, паддинги spacious */\n hasBothSideControls: boolean;\n};\n\ninterface TextInputState {\n layout: 'vertical' | 'horizontal';\n height: number;\n}\n\nconst MIN_HEIGHT = 20;\n\n// допуск на субпиксельные значения скролла\nconst HIDDEN_CONTENT_THRESHOLD = 4;\n\n// вертикальные паддинги ввода - источник правды здесь, ставятся инлайном (в LESS только\n// горизонтальные): фейд и раскладка считают по одним и тем же значениям без чтения стилей из DOM.\n// spacious - ввод на своём ряду (не в нижнем, где контролы), base - все остальные раскладки\nconst VERTICAL_PADDING = {\n base: { top: 8, bottom: 8 },\n spacious: { top: 12, bottom: 16 },\n} as const;\n\nconst clamp = (value: number, min: number, max: number) => Math.max(Math.min(max, value), min);\n\nconst useTextInputHeight = ({\n inputRef,\n layout,\n maxHeight,\n containerWidth,\n value,\n onScrollHeightChange,\n}: Pick<TextInputProps, 'layout' | 'maxHeight' | 'value' | 'containerWidth'> & {\n inputRef: RefObject<HTMLTextAreaElement>;\n onScrollHeightChange?: VoidFunction;\n}) => {\n const [currentState, setCurrentState] = useState<TextInputState>(() => ({\n layout,\n height: MIN_HEIGHT,\n }));\n const prevScrollHeightRef = useRef<number | null>(null);\n\n useLayoutEffect(() => {\n const input = inputRef.current;\n if (!input) {\n return;\n }\n\n // изменение реального scrollHeight (например, рост контента при упоре в maxHeight) —\n // повод пересчитать зависимое (скроллбар); читаем до измерительных тоглов\n if (prevScrollHeightRef.current !== null && prevScrollHeightRef.current !== input.scrollHeight) {\n onScrollHeightChange?.();\n }\n prevScrollHeightRef.current = input.scrollHeight;\n\n // высота контента на ширине конкретного слота: .measure схлопывает высоту и паддинги\n // (scrollHeight = чистая высота контента), layout-vertical выбирает вертикальный/горизонтальный слот\n const measureContentHeight = (targetVertical: boolean) => {\n input.classList.add(styles.measure);\n input.classList.toggle(styles.layoutVertical, targetVertical);\n const height = input.scrollHeight;\n input.classList.toggle(styles.layoutVertical, currentState.layout === 'vertical');\n input.classList.remove(styles.measure);\n return height;\n };\n\n // лейаут определяется по горизонтальному слоту (влезает ли контент в одну строку). Замер\n // горизонтального слота — форс-рефлоу, поэтому сначала снимаем заведомо известные случаи:\n // внешний layout='vertical' и перенос строки в значении гарантируют вертикаль, пустое\n // значение — горизонталь; замеряем только неоднозначный однострочный случай (перенос по ширине).\n // Высоту всегда берём на ширине целевого слота — иначе размер меряется на старом месте.\n let isVertical: boolean;\n if (layout === 'vertical' || value.includes('\\n')) {\n isVertical = true;\n } else if (value === '') {\n isVertical = false;\n } else {\n isVertical = measureContentHeight(false) > MIN_HEIGHT;\n }\n const contentHeight = isVertical ? measureContentHeight(true) : MIN_HEIGHT;\n\n const newState: TextInputState = {\n layout: isVertical ? 'vertical' : 'horizontal',\n height: clamp(contentHeight, MIN_HEIGHT, maxHeight ?? contentHeight),\n };\n\n if (\n (Object.keys(currentState) as Array<keyof TextInputState>).some(\n (key) => currentState[key] !== newState[key]\n )\n ) {\n setCurrentState(newState);\n }\n // currentState не должно быть в зависимостях, иначе зациклится обновление\n // eslint-disable-next-line disable-autofix/react-hooks/exhaustive-deps\n }, [layout, maxHeight, value, inputRef, containerWidth]);\n\n return currentState;\n};\n\nexport const useFocus = <T extends HTMLTextAreaElement = HTMLTextAreaElement>(): {\n focusHandler: FocusEventHandler<T>;\n blurHandler: FocusEventHandler<T>;\n isFocused: boolean;\n} => {\n const [isFocused, setFocused] = useState(false);\n const focusHandler = useCallback<FocusEventHandler<T>>((event) => {\n setFocused(event.target.classList.contains('focus-visible'));\n }, []);\n\n const blurHandler = useCallback<FocusEventHandler<T>>(() => {\n setFocused(false);\n }, []);\n\n return { focusHandler, blurHandler, isFocused };\n};\n\nconst TextInputComponent = (\n {\n value,\n maxHeight,\n layout,\n onSend,\n containerWidth,\n hasCentral,\n hasBothSideControls,\n onKeyDown,\n onScroll,\n className,\n style,\n ...textareaProps\n }: TextInputProps,\n forwardedRef: ForwardedRef<HTMLTextAreaElement>\n) => {\n const inputRef = useRef<HTMLTextAreaElement>(null);\n const { hasVerticalScroll, verticalScrollbarProps, scrollContainerRef, resizeCallbackRef, hoverContainerCssClass } =\n useScrollbar({ axis: 'vertical' });\n const refCallback = useMultipleRefs(inputRef, scrollContainerRef, forwardedRef);\n\n // ResizeObserver внутри useScrollbar видит только изменения размера textarea:\n // после упора в maxHeight растет лишь scrollHeight - пересчитываем трек по нему\n const handleScrollHeightChange = useCallback(() => {\n resizeCallbackRef.current?.();\n }, [resizeCallbackRef]);\n\n const inputParams = useTextInputHeight({\n inputRef,\n layout,\n maxHeight,\n value,\n containerWidth,\n onScrollHeightChange: handleScrollHeightChange,\n });\n\n const verticalPadding =\n hasCentral || (hasBothSideControls && inputParams.layout === 'vertical')\n ? VERTICAL_PADDING.spacious\n : VERTICAL_PADDING.base;\n\n // фейд у краёв показываем, только когда за соответствующим краем есть непроскролленный контент\n const [hasHiddenTop, setHasHiddenTop] = useState(false);\n const [hasHiddenBottom, setHasHiddenBottom] = useState(false);\n const updateFades = useCallback(() => {\n const input = inputRef.current;\n if (!input) {\n return;\n }\n // верхний и нижний паддинги - часть скроллируемой области, но контента в них нет:\n // остаток скролла в их пределах не значит, что текст скрыт\n const hiddenTop = input.scrollTop - verticalPadding.top;\n const hiddenBottom = input.scrollHeight - input.scrollTop - input.clientHeight - verticalPadding.bottom;\n setHasHiddenTop(hiddenTop > HIDDEN_CONTENT_THRESHOLD);\n setHasHiddenBottom(hiddenBottom > HIDDEN_CONTENT_THRESHOLD);\n }, [verticalPadding.top, verticalPadding.bottom]);\n\n // позиция скролла отслеживается через onScroll, здесь - изменения контента, высоты и ширины\n // контейнера: при упоре в maxHeight высота не меняется, но текст перетекает и за краями\n // может появиться/исчезнуть скрытый контент\n useLayoutEffect(() => {\n updateFades();\n }, [value, inputParams.height, containerWidth, updateFades]);\n\n const handleKeyDown = useSendOnEnter({ onSend, onKeyDown });\n const handleScroll = useCallback(\n (event: UIEvent<HTMLTextAreaElement>) => {\n updateFades();\n onScroll?.(event);\n },\n [updateFades, onScroll]\n );\n\n const { isFocused, focusHandler, blurHandler } = useFocus();\n\n return (\n <div className={classNames(styles.textInputWrapper, hoverContainerCssClass)}>\n <textarea\n enterKeyHint=\"enter\"\n {...textareaProps}\n onFocus={focusHandler}\n onBlur={blurHandler}\n className={classNames(\n styles.textInput,\n {\n [styles.layoutVertical]: inputParams.layout === 'vertical',\n [styles.withTopFade]: hasHiddenTop,\n [styles.withBottomFade]: hasHiddenBottom,\n [styles.textInputFocused]: isFocused,\n },\n className\n )}\n style={{\n ...style,\n height: inputParams.height,\n paddingTop: verticalPadding.top,\n paddingBottom: verticalPadding.bottom,\n }}\n ref={refCallback}\n value={value}\n onKeyDown={handleKeyDown}\n onScroll={handleScroll}\n data-qa=\"text-input\"\n />\n {hasVerticalScroll && (\n <div className={styles.scrollbarCell}>\n <Scrollbar {...verticalScrollbarProps} />\n </div>\n )}\n </div>\n );\n};\n\nexport const TextInput = forwardRef(TextInputComponent);\n"],"names":["_jsxs","_jsx"],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ import { type FC } from 'react';
2
+ import { type ChatInputButtonAria } from '@hh.ru/magritte-ui-chat-input/types';
3
+ export type VoiceRecordMode = 'recording' | 'processing';
4
+ export interface VoiceRecordProps {
5
+ mode: VoiceRecordMode;
6
+ /** Живой стрим микрофона во время записи (null в обработке) — индикатор по нему определяет,
7
+ * есть ли сейчас звук */
8
+ stream: MediaStream | null;
9
+ /** Метка старта записи (`Date.now()`); `null` вне записи. Таймер `mm:ss` тикаем локально,
10
+ * чтобы посекундное обновление не ререндерило корень ChatInput */
11
+ startedAt: number | null;
12
+ /** Текст в состоянии обработки (вместо таймера), например «Обрабатываю запись» */
13
+ processingLabel?: string;
14
+ /** Нажата кнопка подтверждения (check) */
15
+ onComplete: VoidFunction;
16
+ /** Нажата кнопка отмены (cross) */
17
+ onCancel: VoidFunction;
18
+ /** aria/data-qa пропсы кнопки подтверждения (галочка) */
19
+ completeButtonProps?: ChatInputButtonAria;
20
+ /** aria/data-qa пропсы кнопки отмены (крестик) */
21
+ cancelButtonProps?: ChatInputButtonAria;
22
+ 'data-qa'?: string;
23
+ }
24
+ export declare const VoiceRecord: FC<VoiceRecordProps>;
@@ -0,0 +1,45 @@
1
+ import './../index.css';
2
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
3
+ import { useState, useEffect } from 'react';
4
+ import classNames from 'classnames';
5
+ import { useTranslation } from '@hh.ru/magritte-common-use-translation';
6
+ import { FocusTarget } from './FocusTarget.js';
7
+ import { VoiceRecordIndicator } from './VoiceRecordIndicator.js';
8
+ import { FOCUS_PRIORITY } from './focusGroup.js';
9
+ import { CrossOutlinedSize24, CheckOutlinedSize24 } from '@hh.ru/magritte-ui-icon/icon';
10
+ import { Loader } from '@hh.ru/magritte-ui-loader';
11
+ import { Text } from '@hh.ru/magritte-ui-typography';
12
+ import { s as styles } from '../styles-CQQdqOFh.js';
13
+ import './useHasSound.js';
14
+
15
+ const formatDuration = (totalSeconds) => {
16
+ const minutes = Math.floor(totalSeconds / 60);
17
+ const seconds = totalSeconds % 60;
18
+ return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
19
+ };
20
+ const useElapsedSeconds = (startedAt, active) => {
21
+ const [elapsedSeconds, setElapsedSeconds] = useState(0);
22
+ useEffect(() => {
23
+ if (!active || startedAt === null) {
24
+ setElapsedSeconds(0);
25
+ return undefined;
26
+ }
27
+ const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));
28
+ update();
29
+ const intervalId = setInterval(update, 1000);
30
+ return () => clearInterval(intervalId);
31
+ }, [startedAt, active]);
32
+ return elapsedSeconds;
33
+ };
34
+ const VoiceRecord = ({ mode, stream, startedAt, processingLabel, onComplete, onCancel, completeButtonProps, cancelButtonProps, 'data-qa': dataQa, }) => {
35
+ const isProcessing = mode === 'processing';
36
+ const elapsedSeconds = useElapsedSeconds(startedAt, !isProcessing);
37
+ const completeLabel = useTranslation('ChatInput', 'completeRecord');
38
+ const cancelLabel = useTranslation('ChatInput', 'cancelRecord');
39
+ const processingTrl = useTranslation('ChatInput', 'processingLabel');
40
+ return (jsxs("div", { className: styles.voiceRecord, "data-qa": dataQa, children: [jsxs("div", { className: styles.voiceRecordLeft, children: [jsx(VoiceRecordIndicator, { stream: stream }), jsx(Text, { typography: "label-3-regular", style: "tertiary", ellipsis: true, children: isProcessing ? (processingLabel ?? processingTrl) : formatDuration(elapsedSeconds) })] }), jsx("div", { className: classNames(styles.voiceRecordRight, { [styles.voiceProcessing]: isProcessing }), children: isProcessing ? (jsx(Loader, { size: 24, color: "secondary" })) : (jsxs(Fragment, { children: [jsx(FocusTarget, { priority: FOCUS_PRIORITY.voiceCancel, children: jsx(CrossOutlinedSize24, { "aria-label": cancelLabel, "data-qa": "record-cancel", ...cancelButtonProps, borderRadius: 12, padding: 8, initialColor: "secondary", onClick: onCancel }) }), jsx(FocusTarget, { priority: FOCUS_PRIORITY.voiceComplete, children: jsx(CheckOutlinedSize24, { "aria-label": completeLabel, "data-qa": "record-complete", ...completeButtonProps, borderRadius: 12, padding: 8, initialColor: "inverse", backgroundStyle: "constant", onClick: onComplete }) })] })) })] }));
41
+ };
42
+ VoiceRecord.displayName = 'ChatInput.VoiceRecord';
43
+
44
+ export { VoiceRecord };
45
+ //# sourceMappingURL=VoiceRecord.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoiceRecord.js","sources":["src/internal/VoiceRecord.tsx"],"sourcesContent":["import { type FC, useEffect, useState } from 'react';\nimport classNames from 'classnames';\n\nimport { useTranslation } from '@hh.ru/magritte-common-use-translation';\nimport { FocusTarget } from '@hh.ru/magritte-ui-chat-input/internal/FocusTarget';\nimport { VoiceRecordIndicator } from '@hh.ru/magritte-ui-chat-input/internal/VoiceRecordIndicator';\nimport { FOCUS_PRIORITY } from '@hh.ru/magritte-ui-chat-input/internal/focusGroup';\nimport { type ChatInputButtonAria } from '@hh.ru/magritte-ui-chat-input/types';\nimport { CheckOutlinedSize24, CrossOutlinedSize24 } from '@hh.ru/magritte-ui-icon/icon';\nimport { Loader } from '@hh.ru/magritte-ui-loader';\nimport { Text } from '@hh.ru/magritte-ui-typography';\n\nimport styles from '../styles.less';\n\nexport type VoiceRecordMode = 'recording' | 'processing';\n\n// чисто презентационный блок: всё состояние (режим, таймер) приходит пропсами\n// из сессии записи (useVoiceRecordSession), кнопки только дёргают колбэки\nexport interface VoiceRecordProps {\n mode: VoiceRecordMode;\n /** Живой стрим микрофона во время записи (null в обработке) — индикатор по нему определяет,\n * есть ли сейчас звук */\n stream: MediaStream | null;\n /** Метка старта записи (`Date.now()`); `null` вне записи. Таймер `mm:ss` тикаем локально,\n * чтобы посекундное обновление не ререндерило корень ChatInput */\n startedAt: number | null;\n /** Текст в состоянии обработки (вместо таймера), например «Обрабатываю запись» */\n processingLabel?: string;\n /** Нажата кнопка подтверждения (check) */\n onComplete: VoidFunction;\n /** Нажата кнопка отмены (cross) */\n onCancel: VoidFunction;\n /** aria/data-qa пропсы кнопки подтверждения (галочка) */\n completeButtonProps?: ChatInputButtonAria;\n /** aria/data-qa пропсы кнопки отмены (крестик) */\n cancelButtonProps?: ChatInputButtonAria;\n 'data-qa'?: string;\n}\n\nconst formatDuration = (totalSeconds: number): string => {\n const minutes = Math.floor(totalSeconds / 60);\n const seconds = totalSeconds % 60;\n return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;\n};\n\nconst useElapsedSeconds = (startedAt: number | null, active: boolean): number => {\n const [elapsedSeconds, setElapsedSeconds] = useState(0);\n\n useEffect(() => {\n if (!active || startedAt === null) {\n setElapsedSeconds(0);\n return undefined;\n }\n const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1000)));\n update();\n const intervalId = setInterval(update, 1000);\n return () => clearInterval(intervalId);\n }, [startedAt, active]);\n\n return elapsedSeconds;\n};\n\nexport const VoiceRecord: FC<VoiceRecordProps> = ({\n mode,\n stream,\n startedAt,\n processingLabel,\n onComplete,\n onCancel,\n completeButtonProps,\n cancelButtonProps,\n 'data-qa': dataQa,\n}) => {\n const isProcessing = mode === 'processing';\n const elapsedSeconds = useElapsedSeconds(startedAt, !isProcessing);\n const completeLabel = useTranslation('ChatInput', 'completeRecord');\n const cancelLabel = useTranslation('ChatInput', 'cancelRecord');\n const processingTrl = useTranslation('ChatInput', 'processingLabel');\n\n return (\n <div className={styles.voiceRecord} data-qa={dataQa}>\n <div className={styles.voiceRecordLeft}>\n <VoiceRecordIndicator stream={stream} />\n <Text typography=\"label-3-regular\" style=\"tertiary\" ellipsis>\n {isProcessing ? (processingLabel ?? processingTrl) : formatDuration(elapsedSeconds)}\n </Text>\n </div>\n <div className={classNames(styles.voiceRecordRight, { [styles.voiceProcessing]: isProcessing })}>\n {isProcessing ? (\n <Loader size={24} color=\"secondary\" />\n ) : (\n <>\n <FocusTarget priority={FOCUS_PRIORITY.voiceCancel}>\n <CrossOutlinedSize24\n aria-label={cancelLabel}\n data-qa=\"record-cancel\"\n {...cancelButtonProps}\n borderRadius={12}\n padding={8}\n initialColor=\"secondary\"\n onClick={onCancel}\n />\n </FocusTarget>\n <FocusTarget priority={FOCUS_PRIORITY.voiceComplete}>\n <CheckOutlinedSize24\n aria-label={completeLabel}\n data-qa=\"record-complete\"\n {...completeButtonProps}\n borderRadius={12}\n padding={8}\n initialColor=\"inverse\"\n backgroundStyle=\"constant\"\n onClick={onComplete}\n />\n </FocusTarget>\n </>\n )}\n </div>\n </div>\n );\n};\n\nVoiceRecord.displayName = 'ChatInput.VoiceRecord';\n"],"names":["_jsxs","_jsx","_Fragment"],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ import { type FC } from 'react';
2
+ export declare const VoiceRecordIndicator: FC<{
3
+ stream: MediaStream | null;
4
+ }>;
@@ -0,0 +1,22 @@
1
+ import './../index.css';
2
+ import { jsx } from 'react/jsx-runtime';
3
+ import classNames from 'classnames';
4
+ import { useHasSound } from './useHasSound.js';
5
+ import { s as styles } from '../styles-CQQdqOFh.js';
6
+ import 'react';
7
+
8
+ const TARGET_HEIGHT = [2, 11, 22, 22, 22, 11, 22, 11, 6, 2];
9
+ const PHASE_SHIFT = [240, 120, 40, 210, 70, 180, 20, 150, 90, 260];
10
+ const VoiceRecordIndicator = ({ stream }) => {
11
+ const hasSound = useHasSound(stream);
12
+ return (jsx("div", { className: classNames(styles.voiceRecordIndicator, {
13
+ [styles.voiceRecordIndicatorHasSound]: hasSound,
14
+ }), children: TARGET_HEIGHT.map((targetHeight, index) => (jsx("div", { className: styles.voiceRecordIndicatorBar, style: {
15
+ '--target-height': targetHeight,
16
+ animationDelay: `${-PHASE_SHIFT[index]}ms`,
17
+ } }, index))) }));
18
+ };
19
+ VoiceRecordIndicator.displayName = 'VoiceRecordIndicator';
20
+
21
+ export { VoiceRecordIndicator };
22
+ //# sourceMappingURL=VoiceRecordIndicator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"VoiceRecordIndicator.js","sources":["src/internal/VoiceRecordIndicator.tsx"],"sourcesContent":["import { type CSSProperties, type FC } from 'react';\nimport classNames from 'classnames';\n\nimport { useHasSound } from '@hh.ru/magritte-ui-chat-input/internal/useHasSound';\n\nimport styles from '../styles.less';\n\nconst TARGET_HEIGHT = [2, 11, 22, 22, 22, 11, 22, 11, 6, 2];\nconst PHASE_SHIFT = [240, 120, 40, 210, 70, 180, 20, 150, 90, 260];\n\nexport const VoiceRecordIndicator: FC<{ stream: MediaStream | null }> = ({ stream }) => {\n const hasSound = useHasSound(stream);\n\n return (\n <div\n className={classNames(styles.voiceRecordIndicator, {\n [styles.voiceRecordIndicatorHasSound]: hasSound,\n })}\n >\n {TARGET_HEIGHT.map((targetHeight, index) => (\n <div\n key={index}\n className={styles.voiceRecordIndicatorBar}\n style={\n {\n '--target-height': targetHeight,\n animationDelay: `${-PHASE_SHIFT[index]}ms`,\n } as CSSProperties\n }\n />\n ))}\n </div>\n );\n};\n\nVoiceRecordIndicator.displayName = 'VoiceRecordIndicator';\n"],"names":["_jsx"],"mappings":";;;;;;AAOA,MAAM,aAAa,GAAG,CAAC,CAAC,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC;AAC5D,MAAM,WAAW,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,GAAG,CAAC,CAAC;MAEtD,oBAAoB,GAAuC,CAAC,EAAE,MAAM,EAAE,KAAI;AACnF,IAAA,MAAM,QAAQ,GAAG,WAAW,CAAC,MAAM,CAAC,CAAC;IAErC,QACIA,aACI,SAAS,EAAE,UAAU,CAAC,MAAM,CAAC,oBAAoB,EAAE;AAC/C,YAAA,CAAC,MAAM,CAAC,4BAA4B,GAAG,QAAQ;SAClD,CAAC,EAAA,QAAA,EAED,aAAa,CAAC,GAAG,CAAC,CAAC,YAAY,EAAE,KAAK,MACnCA,aAEI,SAAS,EAAE,MAAM,CAAC,uBAAuB,EACzC,KAAK,EACD;AACI,gBAAA,iBAAiB,EAAE,YAAY;AAC/B,gBAAA,cAAc,EAAE,CAAG,EAAA,CAAC,WAAW,CAAC,KAAK,CAAC,CAAI,EAAA,CAAA;AAC5B,aAAA,EAAA,EANjB,KAAK,CAQZ,CACL,CAAC,EAAA,CACA,EACR;AACN,EAAE;AAEF,oBAAoB,CAAC,WAAW,GAAG,sBAAsB;;;;"}
@@ -0,0 +1,22 @@
1
+ export declare const FOCUS_PRIORITY_ATTR = "data-magritte-chat-input-focus-priority";
2
+ /** Атрибут приоритета для спреда на существующий хост-элемент (слотовый div и т.п.);
3
+ * там, где хоста нет (иконки-кнопки), — обёртка FocusTarget */
4
+ export declare const focusPriorityProps: (priority: number) => Record<string, number>;
5
+ export declare const FOCUS_PRIORITY: {
6
+ /** Кнопка отправки — первая среди кнопок текстового режима */
7
+ readonly send: 1;
8
+ /** Кнопка записи голоса — сразу после отправки */
9
+ readonly voice: 2;
10
+ /** «Прочие» кнопки правого слота */
11
+ readonly rightSlot: 3;
12
+ /** «Прочие» кнопки левого слота */
13
+ readonly leftSlot: 4;
14
+ /** Кнопки центрального слота */
15
+ readonly centralSlot: 5;
16
+ /** Интерактив внутри превью */
17
+ readonly previewSlot: 6;
18
+ /** Подтверждение записи (галочка) — первая в слое записи */
19
+ readonly voiceComplete: 1;
20
+ /** Отмена записи (крестик) */
21
+ readonly voiceCancel: 2;
22
+ };
@@ -0,0 +1,36 @@
1
+ import './../index.css';
2
+ // Roving tabindex для кнопок ChatInput (см. useRovingFocusGroup). Приоритет обхода задаётся
3
+ // data-атрибутом на контейнере: либо на обёртке-маркере вокруг наших кнопок (FocusTarget), либо
4
+ // прямо на div-е слота — тогда он распространяется на все фокусабельные элементы приложения внутри.
5
+ // Для элемента действует ближайший контейнер-предок, поэтому send/voice внутри слота обгоняют
6
+ // «прочие» кнопки этого же слота.
7
+ const FOCUS_PRIORITY_ATTR = 'data-magritte-chat-input-focus-priority';
8
+ /** Атрибут приоритета для спреда на существующий хост-элемент (слотовый div и т.п.);
9
+ * там, где хоста нет (иконки-кнопки), — обёртка FocusTarget */
10
+ const focusPriorityProps = (priority) => ({
11
+ [FOCUS_PRIORITY_ATTR]: priority,
12
+ });
13
+ // Значения статические, без деления на режимы: неактивный слой режима всегда под aria-hidden
14
+ // (ModeTransition ставит его синхронно со сменой режима), а такие элементы из обхода исключаются —
15
+ // поэтому наборы текстового и голосового режимов никогда не встречаются в одном списке
16
+ const FOCUS_PRIORITY = {
17
+ /** Кнопка отправки — первая среди кнопок текстового режима */
18
+ send: 1,
19
+ /** Кнопка записи голоса — сразу после отправки */
20
+ voice: 2,
21
+ /** «Прочие» кнопки правого слота */
22
+ rightSlot: 3,
23
+ /** «Прочие» кнопки левого слота */
24
+ leftSlot: 4,
25
+ /** Кнопки центрального слота */
26
+ centralSlot: 5,
27
+ /** Интерактив внутри превью */
28
+ previewSlot: 6,
29
+ /** Подтверждение записи (галочка) — первая в слое записи */
30
+ voiceComplete: 1,
31
+ /** Отмена записи (крестик) */
32
+ voiceCancel: 2,
33
+ };
34
+
35
+ export { FOCUS_PRIORITY, FOCUS_PRIORITY_ATTR, focusPriorityProps };
36
+ //# sourceMappingURL=focusGroup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"focusGroup.js","sources":["src/internal/focusGroup.ts"],"sourcesContent":["// Roving tabindex для кнопок ChatInput (см. useRovingFocusGroup). Приоритет обхода задаётся\n// data-атрибутом на контейнере: либо на обёртке-маркере вокруг наших кнопок (FocusTarget), либо\n// прямо на div-е слота — тогда он распространяется на все фокусабельные элементы приложения внутри.\n// Для элемента действует ближайший контейнер-предок, поэтому send/voice внутри слота обгоняют\n// «прочие» кнопки этого же слота.\n\nexport const FOCUS_PRIORITY_ATTR = 'data-magritte-chat-input-focus-priority';\n\n/** Атрибут приоритета для спреда на существующий хост-элемент (слотовый div и т.п.);\n * там, где хоста нет (иконки-кнопки), — обёртка FocusTarget */\nexport const focusPriorityProps = (priority: number): Record<string, number> => ({\n [FOCUS_PRIORITY_ATTR]: priority,\n});\n\n// Значения статические, без деления на режимы: неактивный слой режима всегда под aria-hidden\n// (ModeTransition ставит его синхронно со сменой режима), а такие элементы из обхода исключаются —\n// поэтому наборы текстового и голосового режимов никогда не встречаются в одном списке\nexport const FOCUS_PRIORITY = {\n /** Кнопка отправки — первая среди кнопок текстового режима */\n send: 1,\n /** Кнопка записи голоса — сразу после отправки */\n voice: 2,\n /** «Прочие» кнопки правого слота */\n rightSlot: 3,\n /** «Прочие» кнопки левого слота */\n leftSlot: 4,\n /** Кнопки центрального слота */\n centralSlot: 5,\n /** Интерактив внутри превью */\n previewSlot: 6,\n /** Подтверждение записи (галочка) — первая в слое записи */\n voiceComplete: 1,\n /** Отмена записи (крестик) */\n voiceCancel: 2,\n} as const;\n"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AAEO,MAAM,mBAAmB,GAAG,0CAA0C;AAE7E;AAC+D;MAClD,kBAAkB,GAAG,CAAC,QAAgB,MAA8B;IAC7E,CAAC,mBAAmB,GAAG,QAAQ;AAClC,CAAA,EAAE;AAEH;AACA;AACA;AACa,MAAA,cAAc,GAAG;;AAE1B,IAAA,IAAI,EAAE,CAAC;;AAEP,IAAA,KAAK,EAAE,CAAC;;AAER,IAAA,SAAS,EAAE,CAAC;;AAEZ,IAAA,QAAQ,EAAE,CAAC;;AAEX,IAAA,WAAW,EAAE,CAAC;;AAEd,IAAA,WAAW,EAAE,CAAC;;AAEd,IAAA,aAAa,EAAE,CAAC;;AAEhB,IAAA,WAAW,EAAE,CAAC;;;;;"}
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает
3
+ * RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего
4
+ * громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.
5
+ * Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.
6
+ */
7
+ export declare const useHasSound: (stream: MediaStream | null) => boolean;
@@ -0,0 +1,77 @@
1
+ import './../index.css';
2
+ import { useState, useEffect } from 'react';
3
+
4
+ // Порог RMS, выше которого считаем, что звук есть (речь по RMS обычно ~0.1+, шумовой пол — ниже)
5
+ const SOUND_RMS_THRESHOLD = 0.04;
6
+ // Сколько держим флаг «звук есть» после последнего громкого кадра — чтобы паузы между словами
7
+ // не роняли индикатор в тишину и он не мигал
8
+ const RELEASE_MS = 350;
9
+ // Период опроса громкости: флаг булев с гистерезисом RELEASE_MS, детализация кадра дисплея тут
10
+ // не нужна — 100 мс дают запас в ~3.5 замера на окно удержания при кратно меньшей нагрузке, чем rAF
11
+ const SAMPLE_INTERVAL_MS = 100;
12
+ const getAudioContextConstructor = () => {
13
+ if (typeof window === 'undefined') {
14
+ return null;
15
+ }
16
+ const scoped = window;
17
+ return scoped.AudioContext ?? scoped.webkitAudioContext ?? null;
18
+ };
19
+ /**
20
+ * Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает
21
+ * RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего
22
+ * громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.
23
+ * Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.
24
+ */
25
+ const useHasSound = (stream) => {
26
+ const [hasSound, setHasSound] = useState(false);
27
+ useEffect(() => {
28
+ const AudioCtx = getAudioContextConstructor();
29
+ if (!stream || !AudioCtx || stream.getAudioTracks().length === 0) {
30
+ return undefined;
31
+ }
32
+ const audioContext = new AudioCtx();
33
+ // ctx может стартовать в suspended (autoplay policy); старт всегда из клика по микрофону,
34
+ // так что resume безопасен и обычно сразу переводит в running
35
+ void audioContext.resume();
36
+ const source = audioContext.createMediaStreamSource(stream);
37
+ const analyser = audioContext.createAnalyser();
38
+ analyser.fftSize = 256;
39
+ source.connect(analyser);
40
+ // Web Audio API принимает данные только в типизированный массив
41
+ // eslint-disable-next-line no-restricted-syntax
42
+ const buffer = new Uint8Array(analyser.fftSize);
43
+ let lastLoudAt = 0;
44
+ let current = false;
45
+ const tick = () => {
46
+ analyser.getByteTimeDomainData(buffer);
47
+ let sumSquares = 0;
48
+ for (let i = 0; i < buffer.length; i += 1) {
49
+ const sample = (buffer[i] - 128) / 128;
50
+ sumSquares += sample * sample;
51
+ }
52
+ const rms = Math.sqrt(sumSquares / buffer.length);
53
+ const now = performance.now();
54
+ if (rms > SOUND_RMS_THRESHOLD) {
55
+ lastLoudAt = now;
56
+ }
57
+ const next = now - lastLoudAt < RELEASE_MS;
58
+ if (next !== current) {
59
+ current = next;
60
+ setHasSound(next);
61
+ }
62
+ };
63
+ tick();
64
+ const intervalId = setInterval(tick, SAMPLE_INTERVAL_MS);
65
+ return () => {
66
+ clearInterval(intervalId);
67
+ source.disconnect();
68
+ analyser.disconnect();
69
+ void audioContext.close();
70
+ setHasSound(false);
71
+ };
72
+ }, [stream]);
73
+ return hasSound;
74
+ };
75
+
76
+ export { useHasSound };
77
+ //# sourceMappingURL=useHasSound.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useHasSound.js","sources":["src/internal/useHasSound.ts"],"sourcesContent":["import { useEffect, useState } from 'react';\n\n// Порог RMS, выше которого считаем, что звук есть (речь по RMS обычно ~0.1+, шумовой пол — ниже)\nconst SOUND_RMS_THRESHOLD = 0.04;\n// Сколько держим флаг «звук есть» после последнего громкого кадра — чтобы паузы между словами\n// не роняли индикатор в тишину и он не мигал\nconst RELEASE_MS = 350;\n// Период опроса громкости: флаг булев с гистерезисом RELEASE_MS, детализация кадра дисплея тут\n// не нужна — 100 мс дают запас в ~3.5 замера на окно удержания при кратно меньшей нагрузке, чем rAF\nconst SAMPLE_INTERVAL_MS = 100;\n\ntype AudioContextConstructor = typeof AudioContext;\n\nconst getAudioContextConstructor = (): AudioContextConstructor | null => {\n if (typeof window === 'undefined') {\n return null;\n }\n const scoped = window as unknown as {\n AudioContext?: AudioContextConstructor;\n webkitAudioContext?: AudioContextConstructor;\n };\n return scoped.AudioContext ?? scoped.webkitAudioContext ?? null;\n};\n\n/**\n * Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает\n * RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего\n * громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.\n * Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.\n */\nexport const useHasSound = (stream: MediaStream | null): boolean => {\n const [hasSound, setHasSound] = useState(false);\n\n useEffect(() => {\n const AudioCtx = getAudioContextConstructor();\n if (!stream || !AudioCtx || stream.getAudioTracks().length === 0) {\n return undefined;\n }\n\n const audioContext = new AudioCtx();\n // ctx может стартовать в suspended (autoplay policy); старт всегда из клика по микрофону,\n // так что resume безопасен и обычно сразу переводит в running\n void audioContext.resume();\n\n const source = audioContext.createMediaStreamSource(stream);\n const analyser = audioContext.createAnalyser();\n analyser.fftSize = 256;\n source.connect(analyser);\n\n // Web Audio API принимает данные только в типизированный массив\n // eslint-disable-next-line no-restricted-syntax\n const buffer = new Uint8Array(analyser.fftSize);\n let lastLoudAt = 0;\n let current = false;\n\n const tick = () => {\n analyser.getByteTimeDomainData(buffer);\n let sumSquares = 0;\n for (let i = 0; i < buffer.length; i += 1) {\n const sample = (buffer[i] - 128) / 128;\n sumSquares += sample * sample;\n }\n const rms = Math.sqrt(sumSquares / buffer.length);\n const now = performance.now();\n if (rms > SOUND_RMS_THRESHOLD) {\n lastLoudAt = now;\n }\n const next = now - lastLoudAt < RELEASE_MS;\n if (next !== current) {\n current = next;\n setHasSound(next);\n }\n };\n tick();\n const intervalId = setInterval(tick, SAMPLE_INTERVAL_MS);\n\n return () => {\n clearInterval(intervalId);\n source.disconnect();\n analyser.disconnect();\n void audioContext.close();\n setHasSound(false);\n };\n }, [stream]);\n\n return hasSound;\n};\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import { type RefObject } from 'react';
2
+ interface RovingFocusGroup {
3
+ /** Перевести фокус на текущий tab stop группы (после смены режима — первый по приоритету
4
+ * элемент активного слоя, т.е. галочка при входе в запись) */
5
+ focusStop: () => void;
6
+ }
7
+ /**
8
+ * Roving tabindex для кнопок ChatInput: вся группа кнопок — один tab stop. У всех участников
9
+ * tabindex="-1", у одного (текущего стопа) — "0"; внутри группы фокус ходит стрелками ←/→
10
+ * и Home/End (с зацикливанием) в порядке приоритетов (FOCUS_PRIORITY_ATTR на контейнерах).
11
+ * Tab не перехватывается: страница → textarea → кнопка-стоп → страница, границы нативные.
12
+ *
13
+ * Порядок и состав группы вычисляются из DOM лениво, а вот tabindex приходится поддерживать
14
+ * эагерно — он должен стоять в DOM до нажатия Tab. Расстановка актуализируется по трём каналам:
15
+ * начальный эффект, focusin (стоп следует за фокусом — клик или стрелка) и MutationObserver
16
+ * (маунт/анмаунт кнопок приложения, disabled, смена aria-hidden слоя при переключении режима).
17
+ */
18
+ export declare const useRovingFocusGroup: (root: RefObject<HTMLElement>) => RovingFocusGroup;
19
+ export {};