@hh.ru/magritte-ui-chat-input 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/index.css +117 -117
- package/index.d.ts +10 -10
- package/index.js +1 -1
- package/index.mock.d.ts +9 -9
- package/index.mock.js +37 -36
- package/index.mock.js.map +1 -1
- package/internal/ChatInputContext.d.ts +1 -1
- package/internal/ChatInputContext.js.map +1 -1
- package/internal/ChatInputSlotButton.js +13 -13
- package/internal/ChatInputSlotButton.js.map +1 -1
- package/internal/FocusTarget.js +3 -10
- package/internal/FocusTarget.js.map +1 -1
- package/internal/ModeTransition.js +41 -11
- package/internal/ModeTransition.js.map +1 -1
- package/internal/TextInput.d.ts +1 -1
- package/internal/TextInput.js +146 -126
- package/internal/TextInput.js.map +1 -1
- package/internal/VoiceRecord.d.ts +1 -1
- package/internal/VoiceRecord.js +66 -24
- package/internal/VoiceRecord.js.map +1 -1
- package/internal/VoiceRecordIndicator.js +22 -9
- package/internal/VoiceRecordIndicator.js.map +1 -1
- package/internal/flattenFragments.js +9 -13
- package/internal/flattenFragments.js.map +1 -1
- package/internal/focusGroup.js +18 -28
- package/internal/focusGroup.js.map +1 -1
- package/internal/useHasSound.js +49 -64
- package/internal/useHasSound.js.map +1 -1
- package/internal/useObjectUrl.js +15 -21
- package/internal/useObjectUrl.js.map +1 -1
- package/internal/useRovingFocusGroup.js +102 -137
- package/internal/useRovingFocusGroup.js.map +1 -1
- package/internal/useSendOnEnter.js +44 -46
- package/internal/useSendOnEnter.js.map +1 -1
- package/internal/useVoiceRecordSession.d.ts +1 -1
- package/internal/useVoiceRecordSession.js +185 -219
- package/internal/useVoiceRecordSession.js.map +1 -1
- package/internal/voiceSlotRegistry.d.ts +1 -1
- package/internal/voiceSlotRegistry.js +3 -3
- package/internal/voiceSlotRegistry.js.map +1 -1
- package/package.json +11 -11
- package/public/ChatInput.d.ts +6 -6
- package/public/ChatInput.js +129 -73
- package/public/ChatInput.js.map +1 -1
- package/public/ChatInputChips.js +4 -4
- package/public/ChatInputChips.js.map +1 -1
- package/public/ChatInputFile.d.ts +1 -1
- package/public/ChatInputFile.js +107 -47
- package/public/ChatInputFile.js.map +1 -1
- package/public/ChatInputFiles.js +10 -3
- package/public/ChatInputFiles.js.map +1 -1
- package/public/ChatInputPreview.js +14 -9
- package/public/ChatInputPreview.js.map +1 -1
- package/public/ChatInputSendButton.d.ts +1 -1
- package/public/ChatInputSendButton.js +34 -23
- package/public/ChatInputSendButton.js.map +1 -1
- package/public/ChatInputVoiceButton.d.ts +1 -1
- package/public/ChatInputVoiceButton.js +87 -91
- package/public/ChatInputVoiceButton.js.map +1 -1
- package/public/getChatInputFileType.d.ts +1 -1
- package/public/getChatInputFileType.js +7 -10
- package/public/getChatInputFileType.js.map +1 -1
- package/styles-hEUo5BXU.js +5 -0
- package/styles-hEUo5BXU.js.map +1 -0
- package/styles-CHMi0EYL.js +0 -5
- package/styles-CHMi0EYL.js.map +0 -1
|
@@ -1 +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 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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"}
|
|
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 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":[],"mappings":"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"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type FC } from 'react';
|
|
2
|
-
import { type ChatInputButtonAria } from '
|
|
2
|
+
import { type ChatInputButtonAria } from '../types';
|
|
3
3
|
export type VoiceRecordMode = 'recording' | 'processing';
|
|
4
4
|
export interface VoiceRecordProps {
|
|
5
5
|
mode: VoiceRecordMode;
|
package/internal/VoiceRecord.js
CHANGED
|
@@ -9,37 +9,79 @@ import { FOCUS_PRIORITY } from './focusGroup.js';
|
|
|
9
9
|
import { CrossOutlinedSize24, CheckOutlinedSize24 } from '@hh.ru/magritte-ui-icon/icon';
|
|
10
10
|
import { Loader } from '@hh.ru/magritte-ui-loader';
|
|
11
11
|
import { Text } from '@hh.ru/magritte-ui-typography';
|
|
12
|
-
import { s as styles } from '../styles-
|
|
12
|
+
import { s as styles } from '../styles-hEUo5BXU.js';
|
|
13
13
|
import './useHasSound.js';
|
|
14
14
|
|
|
15
15
|
const formatDuration = (totalSeconds) => {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
19
|
};
|
|
20
20
|
const useElapsedSeconds = (startedAt, active) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
const [elapsedSeconds, setElapsedSeconds] = useState(0);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!active || startedAt === null) {
|
|
24
|
+
setElapsedSeconds(0);
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
const update = () => setElapsedSeconds(Math.max(0, Math.floor((Date.now() - startedAt) / 1e3)));
|
|
28
|
+
update();
|
|
29
|
+
const intervalId = setInterval(update, 1e3);
|
|
30
|
+
return () => clearInterval(intervalId);
|
|
31
|
+
}, [startedAt, active]);
|
|
32
|
+
return elapsedSeconds;
|
|
33
33
|
};
|
|
34
|
-
const VoiceRecord = ({
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
34
|
+
const VoiceRecord = ({
|
|
35
|
+
mode,
|
|
36
|
+
stream,
|
|
37
|
+
startedAt,
|
|
38
|
+
processingLabel,
|
|
39
|
+
onComplete,
|
|
40
|
+
onCancel,
|
|
41
|
+
completeButtonProps,
|
|
42
|
+
cancelButtonProps,
|
|
43
|
+
"data-qa": dataQa
|
|
44
|
+
}) => {
|
|
45
|
+
const isProcessing = mode === "processing";
|
|
46
|
+
const elapsedSeconds = useElapsedSeconds(startedAt, !isProcessing);
|
|
47
|
+
const completeLabel = useTranslation("ChatInput", "completeRecord");
|
|
48
|
+
const cancelLabel = useTranslation("ChatInput", "cancelRecord");
|
|
49
|
+
const processingTrl = useTranslation("ChatInput", "processingLabel");
|
|
50
|
+
return /* @__PURE__ */ jsxs("div", { className: styles.voiceRecord, "data-qa": dataQa, children: [
|
|
51
|
+
/* @__PURE__ */ jsxs("div", { className: styles.voiceRecordLeft, children: [
|
|
52
|
+
/* @__PURE__ */ jsx(VoiceRecordIndicator, { stream }),
|
|
53
|
+
/* @__PURE__ */ jsx(Text, { typography: "label-3-regular", style: "tertiary", ellipsis: true, children: isProcessing ? processingLabel ?? processingTrl : formatDuration(elapsedSeconds) })
|
|
54
|
+
] }),
|
|
55
|
+
/* @__PURE__ */ jsx("div", { className: classNames(styles.voiceRecordRight, { [styles.voiceProcessing]: isProcessing }), children: isProcessing ? /* @__PURE__ */ jsx(Loader, { size: 24, color: "secondary" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
56
|
+
/* @__PURE__ */ jsx(FocusTarget, { priority: FOCUS_PRIORITY.voiceCancel, children: /* @__PURE__ */ jsx(
|
|
57
|
+
CrossOutlinedSize24,
|
|
58
|
+
{
|
|
59
|
+
"aria-label": cancelLabel,
|
|
60
|
+
"data-qa": "record-cancel",
|
|
61
|
+
...cancelButtonProps,
|
|
62
|
+
borderRadius: 12,
|
|
63
|
+
padding: 8,
|
|
64
|
+
initialColor: "secondary",
|
|
65
|
+
onClick: onCancel
|
|
66
|
+
}
|
|
67
|
+
) }),
|
|
68
|
+
/* @__PURE__ */ jsx(FocusTarget, { priority: FOCUS_PRIORITY.voiceComplete, children: /* @__PURE__ */ jsx(
|
|
69
|
+
CheckOutlinedSize24,
|
|
70
|
+
{
|
|
71
|
+
"aria-label": completeLabel,
|
|
72
|
+
"data-qa": "record-complete",
|
|
73
|
+
...completeButtonProps,
|
|
74
|
+
borderRadius: 12,
|
|
75
|
+
padding: 8,
|
|
76
|
+
initialColor: "inverse",
|
|
77
|
+
backgroundStyle: "constant",
|
|
78
|
+
onClick: onComplete
|
|
79
|
+
}
|
|
80
|
+
) })
|
|
81
|
+
] }) })
|
|
82
|
+
] });
|
|
41
83
|
};
|
|
42
|
-
VoiceRecord.displayName =
|
|
84
|
+
VoiceRecord.displayName = "ChatInput.VoiceRecord";
|
|
43
85
|
|
|
44
86
|
export { VoiceRecord };
|
|
45
87
|
//# sourceMappingURL=VoiceRecord.js.map
|
|
@@ -1 +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":[
|
|
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":[],"mappings":"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"}
|
|
@@ -2,21 +2,34 @@ import './../index.css';
|
|
|
2
2
|
import { jsx } from 'react/jsx-runtime';
|
|
3
3
|
import classNames from 'classnames';
|
|
4
4
|
import { useHasSound } from './useHasSound.js';
|
|
5
|
-
import { s as styles } from '../styles-
|
|
5
|
+
import { s as styles } from '../styles-hEUo5BXU.js';
|
|
6
6
|
import 'react';
|
|
7
7
|
|
|
8
8
|
const TARGET_HEIGHT = [2, 11, 22, 22, 22, 11, 22, 11, 6, 2];
|
|
9
9
|
const PHASE_SHIFT = [240, 120, 40, 210, 70, 180, 20, 150, 90, 260];
|
|
10
10
|
const VoiceRecordIndicator = ({ stream }) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
const hasSound = useHasSound(stream);
|
|
12
|
+
return /* @__PURE__ */ jsx(
|
|
13
|
+
"div",
|
|
14
|
+
{
|
|
15
|
+
className: classNames(styles.voiceRecordIndicator, {
|
|
16
|
+
[styles.voiceRecordIndicatorHasSound]: hasSound
|
|
17
|
+
}),
|
|
18
|
+
children: TARGET_HEIGHT.map((targetHeight, index) => /* @__PURE__ */ jsx(
|
|
19
|
+
"div",
|
|
20
|
+
{
|
|
21
|
+
className: styles.voiceRecordIndicatorBar,
|
|
22
|
+
style: {
|
|
23
|
+
"--target-height": targetHeight,
|
|
24
|
+
animationDelay: `${-PHASE_SHIFT[index]}ms`
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
index
|
|
28
|
+
))
|
|
29
|
+
}
|
|
30
|
+
);
|
|
18
31
|
};
|
|
19
|
-
VoiceRecordIndicator.displayName =
|
|
32
|
+
VoiceRecordIndicator.displayName = "VoiceRecordIndicator";
|
|
20
33
|
|
|
21
34
|
export { VoiceRecordIndicator };
|
|
22
35
|
//# sourceMappingURL=VoiceRecordIndicator.js.map
|
|
@@ -1 +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":[
|
|
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":[],"mappings":";;;;;;AAOA,MAAM,aAAA,GAAgB,CAAC,CAAA,EAAG,EAAI,EAAA,EAAA,EAAI,EAAI,EAAA,EAAA,EAAI,EAAI,EAAA,EAAA,EAAI,EAAI,EAAA,CAAA,EAAG,CAAC,CAAA,CAAA;AAC1D,MAAM,WAAA,GAAc,CAAC,GAAA,EAAK,GAAK,EAAA,EAAA,EAAI,GAAK,EAAA,EAAA,EAAI,GAAK,EAAA,EAAA,EAAI,GAAK,EAAA,EAAA,EAAI,GAAG,CAAA,CAAA;AAE1D,MAAM,oBAA2D,GAAA,CAAC,EAAE,MAAA,EAAa,KAAA;AACpF,EAAM,MAAA,QAAA,GAAW,YAAY,MAAM,CAAA,CAAA;AAEnC,EACI,uBAAA,GAAA;AAAA,IAAC,KAAA;AAAA,IAAA;AAAA,MACG,SAAA,EAAW,UAAW,CAAA,MAAA,CAAO,oBAAsB,EAAA;AAAA,QAC/C,CAAC,MAAO,CAAA,4BAA4B,GAAG,QAAA;AAAA,OAC1C,CAAA;AAAA,MAEA,QAAc,EAAA,aAAA,CAAA,GAAA,CAAI,CAAC,YAAA,EAAc,KAC9B,qBAAA,GAAA;AAAA,QAAC,KAAA;AAAA,QAAA;AAAA,UAEG,WAAW,MAAO,CAAA,uBAAA;AAAA,UAClB,KACI,EAAA;AAAA,YACI,iBAAmB,EAAA,YAAA;AAAA,YACnB,cAAgB,EAAA,CAAA,EAAG,CAAC,WAAA,CAAY,KAAK,CAAC,CAAA,EAAA,CAAA;AAAA,WAC1C;AAAA,SAAA;AAAA,QANC,KAAA;AAAA,OASZ,CAAA;AAAA,KAAA;AAAA,GACL,CAAA;AAER,EAAA;AAEA,oBAAA,CAAqB,WAAc,GAAA,sBAAA;;;;"}
|
|
@@ -1,19 +1,15 @@
|
|
|
1
1
|
import './../index.css';
|
|
2
2
|
import { Children, isValidElement, Fragment, cloneElement } from 'react';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (child.type === Fragment) {
|
|
14
|
-
return flattenFragments(child.props.children, key);
|
|
15
|
-
}
|
|
16
|
-
return keyPrefix ? cloneElement(child, { key }) : child;
|
|
4
|
+
const flattenFragments = (children, keyPrefix = "") => Children.toArray(children).flatMap((child) => {
|
|
5
|
+
if (!isValidElement(child)) {
|
|
6
|
+
return child;
|
|
7
|
+
}
|
|
8
|
+
const key = `${keyPrefix}${child.key}`;
|
|
9
|
+
if (child.type === Fragment) {
|
|
10
|
+
return flattenFragments(child.props.children, key);
|
|
11
|
+
}
|
|
12
|
+
return keyPrefix ? cloneElement(child, { key }) : child;
|
|
17
13
|
});
|
|
18
14
|
|
|
19
15
|
export { flattenFragments };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"flattenFragments.js","sources":["src/internal/flattenFragments.ts"],"sourcesContent":["import { Children, cloneElement, Fragment, isValidElement, type PropsWithChildren, type ReactNode } from 'react';\n\n/** Раскрывает фрагменты в плоский список детей. Children.toArray сохраняет ключи детей —\n * их можно перенести на обёртку, чтобы удаление элемента из середины не сдвигало стейт соседей.\n * Внутренний Children.toArray нумерует детей фрагмента заново (.0, .1), поэтому к их ключам\n * добавляется ключ фрагмента — иначе они совпадут с ключами соседей фрагмента */\nexport const flattenFragments = (children: ReactNode, keyPrefix = ''): ReactNode[] =>\n Children.toArray(children).flatMap((child) => {\n if (!isValidElement(child)) {\n return child;\n }\n const key = `${keyPrefix}${child.key}`;\n if (child.type === Fragment) {\n return flattenFragments((child.props as PropsWithChildren).children, key);\n }\n return keyPrefix ? cloneElement(child, { key }) : child;\n });\n"],"names":[],"mappings":";;
|
|
1
|
+
{"version":3,"file":"flattenFragments.js","sources":["src/internal/flattenFragments.ts"],"sourcesContent":["import { Children, cloneElement, Fragment, isValidElement, type PropsWithChildren, type ReactNode } from 'react';\n\n/** Раскрывает фрагменты в плоский список детей. Children.toArray сохраняет ключи детей —\n * их можно перенести на обёртку, чтобы удаление элемента из середины не сдвигало стейт соседей.\n * Внутренний Children.toArray нумерует детей фрагмента заново (.0, .1), поэтому к их ключам\n * добавляется ключ фрагмента — иначе они совпадут с ключами соседей фрагмента */\nexport const flattenFragments = (children: ReactNode, keyPrefix = ''): ReactNode[] =>\n Children.toArray(children).flatMap((child) => {\n if (!isValidElement(child)) {\n return child;\n }\n const key = `${keyPrefix}${child.key}`;\n if (child.type === Fragment) {\n return flattenFragments((child.props as PropsWithChildren).children, key);\n }\n return keyPrefix ? cloneElement(child, { key }) : child;\n });\n"],"names":[],"mappings":";;AAMa,MAAA,gBAAA,GAAmB,CAAC,QAAA,EAAqB,SAAY,GAAA,EAAA,KAC9D,QAAS,CAAA,OAAA,CAAQ,QAAQ,CAAA,CAAE,OAAQ,CAAA,CAAC,KAAU,KAAA;AAC1C,EAAI,IAAA,CAAC,cAAe,CAAA,KAAK,CAAG,EAAA;AACxB,IAAO,OAAA,KAAA,CAAA;AAAA,GACX;AACA,EAAA,MAAM,GAAM,GAAA,CAAA,EAAG,SAAS,CAAA,EAAG,MAAM,GAAG,CAAA,CAAA,CAAA;AACpC,EAAI,IAAA,KAAA,CAAM,SAAS,QAAU,EAAA;AACzB,IAAA,OAAO,gBAAkB,CAAA,KAAA,CAAM,KAA4B,CAAA,QAAA,EAAU,GAAG,CAAA,CAAA;AAAA,GAC5E;AACA,EAAA,OAAO,YAAY,YAAa,CAAA,KAAA,EAAO,EAAE,GAAA,EAAK,CAAI,GAAA,KAAA,CAAA;AACtD,CAAC;;;;"}
|
package/internal/focusGroup.js
CHANGED
|
@@ -1,35 +1,25 @@
|
|
|
1
1
|
import './../index.css';
|
|
2
|
-
|
|
3
|
-
// data-атрибутом на контейнере: либо на обёртке-маркере вокруг наших кнопок (FocusTarget), либо
|
|
4
|
-
// прямо на div-е слота — тогда он распространяется на все фокусабельные элементы приложения внутри.
|
|
5
|
-
// Для элемента действует ближайший контейнер-предок, поэтому send/voice внутри слота обгоняют
|
|
6
|
-
// «прочие» кнопки этого же слота.
|
|
7
|
-
const FOCUS_PRIORITY_ATTR = 'data-magritte-chat-input-focus-priority';
|
|
8
|
-
/** Атрибут приоритета для спреда на существующий хост-элемент (слотовый div и т.п.);
|
|
9
|
-
* там, где хоста нет (иконки-кнопки), — обёртка FocusTarget */
|
|
2
|
+
const FOCUS_PRIORITY_ATTR = "data-magritte-chat-input-focus-priority";
|
|
10
3
|
const focusPriorityProps = (priority) => ({
|
|
11
|
-
|
|
4
|
+
[FOCUS_PRIORITY_ATTR]: priority
|
|
12
5
|
});
|
|
13
|
-
// Значения статические, без деления на режимы: неактивный слой режима всегда под aria-hidden
|
|
14
|
-
// (ModeTransition ставит его синхронно со сменой режима), а такие элементы из обхода исключаются —
|
|
15
|
-
// поэтому наборы текстового и голосового режимов никогда не встречаются в одном списке
|
|
16
6
|
const FOCUS_PRIORITY = {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
7
|
+
/** Кнопка отправки — первая среди кнопок текстового режима */
|
|
8
|
+
send: 1,
|
|
9
|
+
/** Кнопка записи голоса — сразу после отправки */
|
|
10
|
+
voice: 2,
|
|
11
|
+
/** «Прочие» кнопки правого слота */
|
|
12
|
+
rightSlot: 3,
|
|
13
|
+
/** «Прочие» кнопки левого слота */
|
|
14
|
+
leftSlot: 4,
|
|
15
|
+
/** Кнопки центрального слота */
|
|
16
|
+
centralSlot: 5,
|
|
17
|
+
/** Интерактив внутри превью */
|
|
18
|
+
previewSlot: 6,
|
|
19
|
+
/** Подтверждение записи (галочка) — первая в слое записи */
|
|
20
|
+
voiceComplete: 1,
|
|
21
|
+
/** Отмена записи (крестик) */
|
|
22
|
+
voiceCancel: 2
|
|
33
23
|
};
|
|
34
24
|
|
|
35
25
|
export { FOCUS_PRIORITY, FOCUS_PRIORITY_ATTR, focusPriorityProps };
|
|
@@ -1 +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":"
|
|
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":"AAMO,MAAM,mBAAsB,GAAA,0CAAA;AAItB,MAAA,kBAAA,GAAqB,CAAC,QAA8C,MAAA;AAAA,EAC7E,CAAC,mBAAmB,GAAG,QAAA;AAC3B,CAAA,EAAA;AAKO,MAAM,cAAiB,GAAA;AAAA;AAAA,EAE1B,IAAM,EAAA,CAAA;AAAA;AAAA,EAEN,KAAO,EAAA,CAAA;AAAA;AAAA,EAEP,SAAW,EAAA,CAAA;AAAA;AAAA,EAEX,QAAU,EAAA,CAAA;AAAA;AAAA,EAEV,WAAa,EAAA,CAAA;AAAA;AAAA,EAEb,WAAa,EAAA,CAAA;AAAA;AAAA,EAEb,aAAe,EAAA,CAAA;AAAA;AAAA,EAEf,WAAa,EAAA,CAAA;AACjB;;;;"}
|
package/internal/useHasSound.js
CHANGED
|
@@ -1,76 +1,61 @@
|
|
|
1
1
|
import './../index.css';
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
|
|
4
|
-
// Порог RMS, выше которого считаем, что звук есть (речь по RMS обычно ~0.1+, шумовой пол — ниже)
|
|
5
4
|
const SOUND_RMS_THRESHOLD = 0.02;
|
|
6
|
-
// Сколько держим флаг «звук есть» после последнего громкого кадра — чтобы паузы между словами
|
|
7
|
-
// не роняли индикатор в тишину и он не мигал
|
|
8
5
|
const RELEASE_MS = 350;
|
|
9
|
-
// Период опроса громкости: флаг булев с гистерезисом RELEASE_MS, детализация кадра дисплея тут
|
|
10
|
-
// не нужна — 100 мс дают запас в ~3.5 замера на окно удержания при кратно меньшей нагрузке, чем rAF
|
|
11
6
|
const SAMPLE_INTERVAL_MS = 100;
|
|
12
7
|
const getAudioContextConstructor = () => {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
8
|
+
if (typeof window === "undefined") {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const scoped = window;
|
|
12
|
+
return scoped.AudioContext ?? scoped.webkitAudioContext ?? null;
|
|
18
13
|
};
|
|
19
|
-
/**
|
|
20
|
-
* Флаг «в стриме микрофона сейчас есть звук». Пока стрим есть — держит rAF-цикл и читает
|
|
21
|
-
* RMS через AnalyserNode, сравнивая с порогом (флаг ещё RELEASE_MS удерживается после последнего
|
|
22
|
-
* громкого кадра, чтобы не мигать на паузах между словами). Ререндер только на смене флага.
|
|
23
|
-
* Треки стрима не трогаем — ими владеет сессия записи (`useVoiceRecordSession`), только читаем.
|
|
24
|
-
*/
|
|
25
14
|
const useHasSound = (stream) => {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
setHasSound(false);
|
|
71
|
-
};
|
|
72
|
-
}, [stream]);
|
|
73
|
-
return hasSound;
|
|
15
|
+
const [hasSound, setHasSound] = useState(false);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const AudioCtx = getAudioContextConstructor();
|
|
18
|
+
if (!stream || !AudioCtx || stream.getAudioTracks().length === 0) {
|
|
19
|
+
return void 0;
|
|
20
|
+
}
|
|
21
|
+
const audioContext = new AudioCtx();
|
|
22
|
+
void audioContext.resume();
|
|
23
|
+
const source = audioContext.createMediaStreamSource(stream);
|
|
24
|
+
const analyser = audioContext.createAnalyser();
|
|
25
|
+
analyser.fftSize = 256;
|
|
26
|
+
source.connect(analyser);
|
|
27
|
+
const buffer = new Uint8Array(analyser.fftSize);
|
|
28
|
+
let lastLoudAt = 0;
|
|
29
|
+
let current = false;
|
|
30
|
+
const tick = () => {
|
|
31
|
+
analyser.getByteTimeDomainData(buffer);
|
|
32
|
+
let sumSquares = 0;
|
|
33
|
+
for (let i = 0; i < buffer.length; i += 1) {
|
|
34
|
+
const sample = (buffer[i] - 128) / 128;
|
|
35
|
+
sumSquares += sample * sample;
|
|
36
|
+
}
|
|
37
|
+
const rms = Math.sqrt(sumSquares / buffer.length);
|
|
38
|
+
const now = performance.now();
|
|
39
|
+
if (rms > SOUND_RMS_THRESHOLD) {
|
|
40
|
+
lastLoudAt = now;
|
|
41
|
+
}
|
|
42
|
+
const next = now - lastLoudAt < RELEASE_MS;
|
|
43
|
+
if (next !== current) {
|
|
44
|
+
current = next;
|
|
45
|
+
setHasSound(next);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
tick();
|
|
49
|
+
const intervalId = setInterval(tick, SAMPLE_INTERVAL_MS);
|
|
50
|
+
return () => {
|
|
51
|
+
clearInterval(intervalId);
|
|
52
|
+
source.disconnect();
|
|
53
|
+
analyser.disconnect();
|
|
54
|
+
void audioContext.close();
|
|
55
|
+
setHasSound(false);
|
|
56
|
+
};
|
|
57
|
+
}, [stream]);
|
|
58
|
+
return hasSound;
|
|
74
59
|
};
|
|
75
60
|
|
|
76
61
|
export { useHasSound };
|