@umodeapporg/ui 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Form/RichTextEditor.js +22 -3
- package/dist/components/Form/RichTextEditor.js.map +1 -1
- package/dist/components/UI/BulkActionBar.js +1 -1
- package/dist/components/UI/BulkActionBar.js.map +1 -1
- package/dist/hooks/useVoiceTranscription.js +26 -2
- package/dist/hooks/useVoiceTranscription.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { forwardRef, useRef, useState,
|
|
2
|
+
import { forwardRef, useRef, useState, useReducer, useCallback, useEffect, useImperativeHandle, useMemo } from "react";
|
|
3
3
|
import classNames from "classnames";
|
|
4
4
|
import { useTranslation } from "react-i18next";
|
|
5
5
|
import { ControlledMenuRoot, ControlledMenuHandler, ControlledMenuList, ControlledMenuItem } from "../UI/ControlledMenu.js";
|
|
@@ -94,7 +94,9 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
|
|
|
94
94
|
const [linkUrl, setLinkUrl] = useState("");
|
|
95
95
|
const [isFocused, setIsFocused] = useState(false);
|
|
96
96
|
const [formattingOpen, setFormattingOpen] = useState(false);
|
|
97
|
+
const [, recheckPlaceholder] = useReducer((checkCount) => checkCount + 1, 0);
|
|
97
98
|
const savedRangeRef = useRef(null);
|
|
99
|
+
const caretBelongsAtEndRef = useRef(false);
|
|
98
100
|
const isInternalUpdateRef = useRef(false);
|
|
99
101
|
const fileInputRef = useRef(null);
|
|
100
102
|
const imageInputRef = useRef(null);
|
|
@@ -108,14 +110,26 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
|
|
|
108
110
|
const attachmentsController = attachments?.controller ?? (inlineUpload ? inlineController : null);
|
|
109
111
|
const inlineStagedAttachments = inlineController.stagedAttachments;
|
|
110
112
|
const toolbarVisible = formattingToggle ? formattingOpen : showToolbar;
|
|
113
|
+
const moveCaretToEnd = useCallback((editor) => {
|
|
114
|
+
const range = document.createRange();
|
|
115
|
+
range.selectNodeContents(editor);
|
|
116
|
+
range.collapse(false);
|
|
117
|
+
const selection = window.getSelection();
|
|
118
|
+
selection?.removeAllRanges();
|
|
119
|
+
selection?.addRange(range);
|
|
120
|
+
savedRangeRef.current = range.cloneRange();
|
|
121
|
+
}, []);
|
|
111
122
|
useEffect(() => {
|
|
112
123
|
const editor = editorRef.current;
|
|
113
124
|
if (!editor) return;
|
|
114
125
|
if (isInternalUpdateRef.current) return;
|
|
115
126
|
if (editor.innerHTML !== value) {
|
|
116
127
|
editor.innerHTML = value;
|
|
128
|
+
if (document.activeElement === editor) moveCaretToEnd(editor);
|
|
129
|
+
else caretBelongsAtEndRef.current = true;
|
|
130
|
+
recheckPlaceholder();
|
|
117
131
|
}
|
|
118
|
-
}, [value]);
|
|
132
|
+
}, [moveCaretToEnd, value]);
|
|
119
133
|
const emitChange = useCallback(() => {
|
|
120
134
|
const editor = editorRef.current;
|
|
121
135
|
if (!editor) return;
|
|
@@ -465,7 +479,12 @@ const RichTextEditor = forwardRef(function RichTextEditor2({
|
|
|
465
479
|
saveRange();
|
|
466
480
|
syncActiveCommands();
|
|
467
481
|
},
|
|
468
|
-
onFocus: () =>
|
|
482
|
+
onFocus: () => {
|
|
483
|
+
setIsFocused(true);
|
|
484
|
+
if (!caretBelongsAtEndRef.current || !editorRef.current) return;
|
|
485
|
+
caretBelongsAtEndRef.current = false;
|
|
486
|
+
moveCaretToEnd(editorRef.current);
|
|
487
|
+
},
|
|
469
488
|
onBlur: () => {
|
|
470
489
|
saveRange();
|
|
471
490
|
setIsFocused(false);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RichTextEditor.js","sources":["../../../src/components/Form/RichTextEditor.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n ControlledMenuHandler,\n ControlledMenuItem,\n ControlledMenuList,\n ControlledMenuRoot,\n} from '@/components/UI/ControlledMenu';\nimport {\n ControlledPopoverContent,\n ControlledPopoverHandler,\n ControlledPopoverRoot,\n} from '@/components/UI/ControlledPopover';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport {\n activeRichTextColor,\n applyRichTextColor,\n buildFileChipMarkup,\n normalizeRichTextColorNodes,\n RICH_TEXT_COLOR_SWATCH_CLASS,\n RICH_TEXT_COLORS,\n TRichTextColor,\n} from '@/helpers/richText';\nimport { IUseFileAttachmentsResult, TFileUploader, useFileAttachments } from '@/hooks/useFileAttachments';\n\nimport { MaterialSymbol, SymbolCodepoints } from '../UI/MaterialSymbol';\n\nimport AttachmentPreviewList, { IStagedImageAction } from './attachments/AttachmentPreviewList';\nimport FileDropzone from './attachments/FileDropzone';\nimport ControlledButton from './ControlledButton';\nimport ControlledInput from './ControlledInput';\n\n/** An extra entry in the attach menu, for a source only the host knows about (a camera dialog). */\nexport interface IRichTextAttachAction {\n key: string;\n icon: SymbolCodepoints;\n label: string;\n onClick: () => void;\n}\n\n/**\n * Files travel BESIDE the text: the host owns a list of attachments of its own (a chat message\n * has one) and reads `controller.readyAttachments` when it saves.\n */\nexport interface IRichTextAttachmentsConfig {\n controller: IUseFileAttachmentsResult;\n extraActions?: Array<IRichTextAttachAction>;\n /**\n * Actions offered on a staged IMAGE before it is sent, so a picture is corrected on its way out\n * instead of being sent and then corrected as a second file.\n */\n imageActions?: Array<IStagedImageAction>;\n /** The host already renders the staged previews somewhere else. */\n hidePreviews?: boolean;\n /** The host already wraps a wider drop target around this editor. */\n hideDropzone?: boolean;\n}\n\n/**\n * Files travel INSIDE the text: the upload result is written into the body as a chip, so the\n * reference persists anywhere the HTML itself is stored and no host needs a new field.\n */\nexport interface IRichTextInlineUploadConfig {\n uploadFile?: TFileUploader;\n maxFiles?: number;\n maxFileSizeBytes?: number;\n}\n\ninterface IRichTextEditorProps {\n value?: string;\n onChange?: (html: string) => void;\n placeholder?: string;\n disabled?: boolean;\n error?: boolean;\n className?: string;\n minHeight?: number;\n /** Rendered inside the toolbar row, right-aligned: host actions that belong to the field. */\n toolbarSlot?: React.ReactNode;\n /** Rendered in the editor's own bottom bar, after the controls the editor owns. */\n actionsSlot?: React.ReactNode;\n onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;\n /** Fires when the editable area loses focus, for hosts that persist on blur. */\n onBlur?: () => void;\n /**\n * Fires with the full HTML when the editor changes the content on its own, which today means\n * an inline upload landing after the field already lost focus. A host that persists on blur\n * would otherwise never hear about the file it just attached.\n */\n onCommit?: (html: string) => void;\n /** 'bare' drops the border and focus ring: the host owns the chrome around the field. */\n variant?: 'default' | 'bare';\n /** Hosts with their own action bar (the chat composer) toggle the formatting row on demand. */\n showToolbar?: boolean;\n /** Renders the formatting toggle in the bottom bar and lets the editor own the row's state. */\n formattingToggle?: boolean;\n attachments?: IRichTextAttachmentsConfig;\n inlineUpload?: IRichTextInlineUploadConfig;\n}\n\nexport interface IRichTextEditorHandle {\n focus: () => void;\n /** Viewport rect of the caret, for anchoring a popover to what is being typed. */\n getCaretRect: () => DOMRect | null;\n /**\n * Text typed after the trigger and before the caret, or null when the caret is not inside a\n * trigger word. Drives both \"is the mention menu relevant\" and \"what is being searched\".\n * Spaces are allowed inside the query (people have compound names); the host decides when a\n * spaced query stopped matching anyone and closes the menu.\n */\n getMentionQuery: (trigger: string) => string | null;\n /**\n * Deletes the trigger AND the query the user typed after it, then writes the markup in their\n * place, so picking someone from a mention menu replaces everything typed since the `@`\n * instead of stacking the mention after it.\n */\n replaceTriggerWith: (trigger: string, query: string, html: string) => void;\n}\n\ninterface IToolbarGroup {\n buttons: Array<IToolbarButton>;\n}\n\ninterface IToolbarButton {\n command: string;\n arg?: string;\n icon: SymbolCodepoints;\n tooltipKey: string;\n isBlock?: boolean;\n}\n\nconst TOOLBAR_GROUPS: Array<IToolbarGroup> = [\n {\n buttons: [\n { command: 'bold', icon: 'format_bold', tooltipKey: 'createTask.rich.bold' },\n { command: 'italic', icon: 'format_italic', tooltipKey: 'createTask.rich.italic' },\n { command: 'strikeThrough', icon: 'strikethrough_s', tooltipKey: 'createTask.rich.strikethrough' },\n ],\n },\n {\n buttons: [\n {\n command: 'insertUnorderedList',\n icon: 'format_list_bulleted',\n tooltipKey: 'createTask.rich.bulletList',\n isBlock: true,\n },\n {\n command: 'insertOrderedList',\n icon: 'format_list_numbered',\n tooltipKey: 'createTask.rich.numberedList',\n isBlock: true,\n },\n {\n command: 'formatBlock',\n arg: 'BLOCKQUOTE',\n icon: 'format_quote',\n tooltipKey: 'createTask.rich.quote',\n isBlock: true,\n },\n ],\n },\n {\n buttons: [\n { command: 'indent', icon: 'format_indent_increase', tooltipKey: 'createTask.rich.indent' },\n { command: 'outdent', icon: 'format_indent_decrease', tooltipKey: 'createTask.rich.outdent' },\n ],\n },\n];\n\nconst MENTION_QUERY_MAX_LENGTH = 40;\nconst IMAGE_INPUT_ACCEPT = 'image/*';\nconst DROPZONE_OVERLAY_CLASS = 'rounded-xl';\n\nconst TOOLBAR_BUTTON_CLASS = 'flex size-7 items-center justify-center rounded-lg transition-colors';\nconst TOOLBAR_BUTTON_IDLE_CLASS = 'text-foreground-subtle hover:bg-muted-hover hover:text-foreground-muted';\nconst TOOLBAR_BUTTON_ACTIVE_CLASS = 'bg-brand-soft text-brand-emphasis';\nconst ACTION_BUTTON_CLASS =\n 'flex size-8 items-center justify-center rounded-full text-foreground-faint transition-colors hover:bg-muted-hover hover:text-foreground-muted disabled:text-foreground-faint';\nconst ACTION_BUTTON_ACTIVE_CLASS =\n 'flex size-8 items-center justify-center rounded-full bg-brand-soft text-brand-emphasis transition-colors hover:bg-brand-soft';\n\nconst isCommandActive = (command: string, arg?: string): boolean => {\n try {\n if (arg) return document.queryCommandValue(command) === arg.toLowerCase();\n return document.queryCommandState(command);\n } catch {\n return false;\n }\n};\n\nconst RichTextEditor = forwardRef<IRichTextEditorHandle, IRichTextEditorProps>(function RichTextEditor(\n {\n value = '',\n onChange,\n placeholder,\n disabled = false,\n error = false,\n className,\n minHeight = 120,\n toolbarSlot,\n actionsSlot,\n onKeyDown,\n onBlur,\n onCommit,\n variant = 'default',\n showToolbar = true,\n formattingToggle = false,\n attachments,\n inlineUpload,\n },\n ref,\n) {\n const { t } = useTranslation('ui');\n const editorRef = useRef<HTMLDivElement>(null);\n const [activeCommands, setActiveCommands] = useState<Set<string>>(new Set());\n const [activeColor, setActiveColor] = useState<TRichTextColor>('default');\n const [colorOpen, setColorOpen] = useState(false);\n const [showLinkInput, setShowLinkInput] = useState(false);\n const [linkUrl, setLinkUrl] = useState('');\n const [isFocused, setIsFocused] = useState(false);\n const [formattingOpen, setFormattingOpen] = useState(false);\n const savedRangeRef = useRef<Range | null>(null);\n const isInternalUpdateRef = useRef(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const imageInputRef = useRef<HTMLInputElement>(null);\n const insertedStagingIdsRef = useRef<Set<string>>(new Set());\n const pendingColorRef = useRef<TRichTextColor>('default');\n\n const inlineController = useFileAttachments({\n uploadFile: inlineUpload?.uploadFile,\n maxFiles: inlineUpload?.maxFiles,\n maxFileSizeBytes: inlineUpload?.maxFileSizeBytes,\n });\n\n const attachmentsController = attachments?.controller ?? (inlineUpload ? inlineController : null);\n const inlineStagedAttachments = inlineController.stagedAttachments;\n const toolbarVisible = formattingToggle ? formattingOpen : showToolbar;\n\n useEffect(() => {\n const editor = editorRef.current;\n if (!editor) return;\n if (isInternalUpdateRef.current) return;\n if (editor.innerHTML !== value) {\n editor.innerHTML = value;\n }\n }, [value]);\n\n const emitChange = useCallback(() => {\n const editor = editorRef.current;\n if (!editor) return;\n isInternalUpdateRef.current = true;\n onChange?.(editor.innerHTML);\n isInternalUpdateRef.current = false;\n }, [onChange]);\n\n const syncActiveCommands = useCallback(() => {\n const active = new Set<string>();\n TOOLBAR_GROUPS.flatMap((group) => group.buttons).forEach(({ command, arg }) => {\n if (isCommandActive(command, arg)) active.add(arg ?? command);\n });\n setActiveCommands(active);\n\n const editor = editorRef.current;\n if (editor) setActiveColor(activeRichTextColor(editor));\n }, []);\n\n const execFormat = useCallback(\n (command: string, arg?: string) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n editor.focus();\n document.execCommand(command, false, arg);\n emitChange();\n syncActiveCommands();\n },\n [disabled, emitChange, syncActiveCommands],\n );\n\n const saveRange = useCallback(() => {\n const selection = window.getSelection();\n if (selection && selection.rangeCount > 0) {\n savedRangeRef.current = selection.getRangeAt(0).cloneRange();\n }\n }, []);\n\n const restoreRange = useCallback(() => {\n const range = savedRangeRef.current;\n if (!range) return;\n const selection = window.getSelection();\n if (!selection) return;\n selection.removeAllRanges();\n selection.addRange(range);\n }, []);\n\n const applyLink = useCallback(() => {\n const url = linkUrl.trim();\n restoreRange();\n if (url) execFormat('createLink', url.startsWith('http') ? url : `https://${url}`);\n setShowLinkInput(false);\n setLinkUrl('');\n }, [linkUrl, restoreRange, execFormat]);\n\n const handleApplyColor = useCallback(\n (color: TRichTextColor) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n editor.focus();\n restoreRange();\n pendingColorRef.current = color;\n applyRichTextColor(editor, color);\n emitChange();\n setActiveColor(color);\n setColorOpen(false);\n },\n [disabled, emitChange, restoreRange],\n );\n\n const insertHtmlAtCaret = useCallback(\n (html: string, commit = false) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const editorHadFocus = editor.contains(previouslyFocused);\n\n editor.focus();\n restoreRange();\n\n const selection = window.getSelection();\n const caretIsInside = selection?.anchorNode ? editor.contains(selection.anchorNode) : false;\n\n if (!caretIsInside) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n\n document.execCommand('insertHTML', false, html);\n saveRange();\n emitChange();\n\n if (!editorHadFocus) previouslyFocused?.focus();\n if (commit) onCommit?.(editor.innerHTML);\n },\n [disabled, emitChange, onCommit, restoreRange, saveRange],\n );\n\n const inlineControllerRef = useRef(inlineController);\n inlineControllerRef.current = inlineController;\n\n // An inline upload lands in the body the moment it finishes, and leaves the staging list so\n // the progress card disappears with it.\n useEffect(() => {\n if (!inlineUpload) return;\n\n inlineStagedAttachments.forEach((staged) => {\n const uploaded = staged.uploaded;\n if (staged.status !== 'done' || !uploaded) return;\n\n if (!insertedStagingIdsRef.current.has(staged.stagingId)) {\n insertedStagingIdsRef.current.add(staged.stagingId);\n insertHtmlAtCaret(`${buildFileChipMarkup(uploaded)} `, true);\n }\n inlineControllerRef.current.removeAttachment(staged.stagingId);\n });\n }, [inlineUpload, inlineStagedAttachments, insertHtmlAtCaret]);\n\n const handleInput = useCallback(() => {\n const editor = editorRef.current;\n if (editor) normalizeRichTextColorNodes(editor, pendingColorRef.current);\n emitChange();\n syncActiveCommands();\n }, [emitChange, syncActiveCommands]);\n\n const handleFilesPicked = (event: React.ChangeEvent<HTMLInputElement>) => {\n const files = Array.from(event.target.files ?? []);\n if (files.length) attachmentsController?.addFiles(files);\n event.target.value = '';\n };\n\n const handlePaste = (event: React.ClipboardEvent) => {\n if (!attachmentsController) return;\n\n const files = Array.from(event.clipboardData?.files ?? []);\n if (!files.length) return;\n\n event.preventDefault();\n event.stopPropagation();\n attachmentsController.addFiles(files);\n };\n\n useImperativeHandle(ref, () => ({\n focus: () => editorRef.current?.focus(),\n\n getCaretRect: () => {\n const selection = window.getSelection();\n if (!selection?.rangeCount) return null;\n\n const range = selection.getRangeAt(0).cloneRange();\n range.collapse(false);\n\n const [rect] = Array.from(range.getClientRects());\n if (rect) return rect;\n\n // A collapsed range inside an empty node measures nothing, so a throwaway marker gives it\n // something to measure. Removed in the same tick, before anything can observe it.\n const marker = document.createElement('span');\n marker.textContent = '\\u200b';\n range.insertNode(marker);\n const markerRect = marker.getBoundingClientRect();\n marker.remove();\n\n return markerRect;\n },\n\n getMentionQuery: (trigger) => {\n const selection = window.getSelection();\n if (!selection?.rangeCount) return null;\n\n const range = selection.getRangeAt(0);\n if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) return null;\n\n const textBeforeCaret = range.startContainer.textContent?.slice(0, range.startOffset) ?? '';\n const escapedTrigger = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const match = new RegExp(`(?:^|\\\\s)${escapedTrigger}([^${escapedTrigger}]*)$`).exec(textBeforeCaret);\n if (!match) return null;\n\n const query = match[1];\n if (query.startsWith(' ') || query.length > MENTION_QUERY_MAX_LENGTH) return null;\n\n return query;\n },\n\n replaceTriggerWith: (trigger, query, html) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n editor.focus();\n restoreRange();\n\n const selection = window.getSelection();\n const range = selection?.rangeCount ? selection.getRangeAt(0) : null;\n\n if (range?.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) {\n const textBeforeCaret = range.startContainer.textContent?.slice(0, range.startOffset) ?? '';\n const typed = `${trigger}${query}`;\n if (textBeforeCaret.endsWith(typed)) {\n range.setStart(range.startContainer, range.startOffset - typed.length);\n range.deleteContents();\n } else if (textBeforeCaret.endsWith(trigger)) {\n range.setStart(range.startContainer, range.startOffset - trigger.length);\n range.deleteContents();\n }\n }\n\n document.execCommand('insertHTML', false, `${html} `);\n emitChange();\n },\n }));\n\n const isEmpty = !editorRef.current?.textContent?.trim() && !editorRef.current?.querySelector('img, li, blockquote');\n\n const attachMenuActions = useMemo<Array<IRichTextAttachAction>>(\n () => [\n {\n key: 'file',\n icon: 'description',\n label: t('followUp.attachments.pickFile'),\n onClick: () => fileInputRef.current?.click(),\n },\n {\n key: 'image',\n icon: 'image',\n label: t('followUp.attachments.pickImage'),\n onClick: () => imageInputRef.current?.click(),\n },\n ...(attachments?.extraActions ?? []),\n ],\n [attachments?.extraActions, t],\n );\n\n const showPreviews = Boolean(attachmentsController) && !attachments?.hidePreviews;\n const showActionsBar = Boolean(attachmentsController) || Boolean(actionsSlot) || formattingToggle;\n\n const editorShell = (\n <div\n className={classNames(\n 'flex w-full flex-col transition-colors',\n variant === 'default' && 'rounded-xl border bg-surface',\n variant === 'default' &&\n (error\n ? 'border-danger-border'\n : isFocused\n ? 'border-brand-border ring-2 ring-brand-border'\n : 'border-border'),\n className,\n )}\n >\n {toolbarVisible && (\n <div\n className={classNames(\n 'flex flex-wrap items-center gap-px border-b px-2 py-1.5',\n error ? 'border-danger-border' : 'border-border-subtle',\n )}\n >\n {TOOLBAR_GROUPS.map((group, groupIndex) => (\n <div\n key={groupIndex}\n className={classNames(\n 'flex items-center gap-px',\n groupIndex < TOOLBAR_GROUPS.length - 1 && 'mr-1 border-r border-border pr-1',\n )}\n >\n {group.buttons.map(({ command, arg, icon, tooltipKey }) => {\n const key = arg ?? command;\n const isActive = activeCommands.has(key);\n\n return (\n <ControlledTooltip key={key} content={t(tooltipKey)}>\n <ControlledButton\n aria-label={t(tooltipKey)}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n execFormat(command, arg);\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n isActive ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon={icon} size={18} />\n </ControlledButton>\n </ControlledTooltip>\n );\n })}\n </div>\n ))}\n\n <div className=\"flex items-center gap-px border-l border-border pl-1\">\n <ControlledPopoverRoot open={colorOpen} handler={setColorOpen} placement=\"bottom-start\">\n <ControlledTooltip content={t('createTask.rich.textColor')}>\n <ControlledPopoverHandler>\n <ControlledButton\n aria-label={t('createTask.rich.textColor')}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n saveRange();\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n colorOpen ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon=\"format_color_text\" size={18} />\n </ControlledButton>\n </ControlledPopoverHandler>\n </ControlledTooltip>\n\n <ControlledPopoverContent className=\"zIndexMax w-44 p-2\">\n <div className=\"flex flex-wrap gap-1.5\">\n {RICH_TEXT_COLORS.map((color) => (\n <ControlledTooltip key={color} content={t(`createTask.rich.colors.${color}`)}>\n <ControlledButton\n aria-label={t(`createTask.rich.colors.${color}`)}\n defaultHtmlButton\n aria-pressed={activeColor === color}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n handleApplyColor(color);\n }}\n className={classNames(\n 'flex size-7 items-center justify-center rounded-lg border transition-colors',\n activeColor === color ? 'border-brand-border ring-2 ring-brand-border' : 'border-border',\n )}\n >\n <span\n className={classNames(\n 'size-4 rounded-full',\n RICH_TEXT_COLOR_SWATCH_CLASS[color],\n color === 'default' && 'ring-1 ring-inset ring-border-strong',\n )}\n />\n </ControlledButton>\n </ControlledTooltip>\n ))}\n </div>\n </ControlledPopoverContent>\n </ControlledPopoverRoot>\n\n <ControlledTooltip content={t('createTask.rich.link')}>\n <ControlledButton\n aria-label={t('createTask.rich.link')}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n saveRange();\n setShowLinkInput((previous) => !previous);\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n showLinkInput ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon=\"link\" size={18} />\n </ControlledButton>\n </ControlledTooltip>\n </div>\n\n {toolbarSlot && <div className=\"ml-auto flex items-center gap-x-1\">{toolbarSlot}</div>}\n </div>\n )}\n\n {showLinkInput && (\n <div className=\"flex items-center gap-2 border-b border-border-subtle bg-muted px-3 py-2\">\n <ControlledInput\n autoFocus\n value={linkUrl}\n onChange={(event) => setLinkUrl(event.target.value)}\n placeholder=\"https://\"\n aria-label={t('createTask.rich.link')}\n onKeyDown={(event: React.KeyboardEvent) => {\n if (event.key === 'Enter') applyLink();\n if (event.key === 'Escape') setShowLinkInput(false);\n }}\n />\n <ControlledButton size=\"sm\" onClick={applyLink}>\n {t('actions.apply')}\n </ControlledButton>\n <ControlledButton size=\"sm\" variant=\"outlined\" tone=\"neutral\" onClick={() => setShowLinkInput(false)}>\n {t('actions.cancel')}\n </ControlledButton>\n </div>\n )}\n\n <div className=\"relative\">\n <div\n ref={editorRef}\n contentEditable={!disabled}\n suppressContentEditableWarning\n onInput={handleInput}\n onKeyDown={onKeyDown}\n onPaste={handlePaste}\n onKeyUp={() => {\n saveRange();\n syncActiveCommands();\n }}\n onMouseUp={() => {\n saveRange();\n syncActiveCommands();\n }}\n onFocus={() => setIsFocused(true)}\n onBlur={() => {\n saveRange();\n setIsFocused(false);\n onBlur?.();\n }}\n className={classNames(\n 'w-full px-3 py-2.5 text-sm text-foreground outline-none',\n '[&_ol]:ml-4 [&_ol]:list-decimal [&_ul]:ml-4 [&_ul]:list-disc',\n '[&_blockquote]:border-l-2 [&_blockquote]:border-brand-border [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-foreground-subtle',\n '[&_a]:text-brand-emphasis [&_a]:underline',\n '[&_a.rt-file]:text-foreground-muted [&_a.rt-file]:no-underline',\n '[&_a.rt-image-link]:no-underline',\n '[&_user]:whitespace-nowrap [&_user]:rounded [&_user]:bg-brand-soft [&_user]:px-1 [&_user]:font-medium [&_user]:text-brand-emphasis',\n disabled && 'cursor-not-allowed opacity-60',\n )}\n style={{ minHeight }}\n />\n {isEmpty && !isFocused && placeholder && (\n <span className=\"pointer-events-none absolute left-3 top-2.5 text-sm text-foreground-faint\">\n {placeholder}\n </span>\n )}\n </div>\n\n {showPreviews && attachmentsController && attachmentsController.stagedAttachments.length > 0 && (\n <div className=\"px-3 pb-2\">\n <AttachmentPreviewList\n stagedAttachments={attachmentsController.stagedAttachments}\n onRemove={attachmentsController.removeAttachment}\n onRetry={attachmentsController.retryAttachment}\n imageActions={attachments?.imageActions}\n />\n </div>\n )}\n\n {showActionsBar && (\n <div className=\"flex items-center gap-x-0.5 px-2 pb-2 pt-0.5\">\n {formattingToggle && (\n <ControlledTooltip content={t('followUp.chat.formatting')} className=\"z-[9999]\">\n <ControlledButton\n aria-label={t('followUp.chat.formatting')}\n defaultHtmlButton\n disabled={disabled}\n onClick={() => setFormattingOpen((previous) => !previous)}\n aria-pressed={formattingOpen}\n className={formattingOpen ? ACTION_BUTTON_ACTIVE_CLASS : ACTION_BUTTON_CLASS}\n >\n <MaterialSymbol icon=\"format_size\" size={18} />\n </ControlledButton>\n </ControlledTooltip>\n )}\n\n {attachmentsController && (\n <ControlledMenuRoot placement=\"top-start\">\n <ControlledTooltip content={t('followUp.attachments.attach')} className=\"z-[9999]\">\n <ControlledMenuHandler>\n <ControlledButton\n defaultHtmlButton\n aria-label={t('followUp.attachments.attach')}\n disabled={disabled}\n className={ACTION_BUTTON_CLASS}\n >\n <MaterialSymbol icon=\"attach_file\" size={18} />\n </ControlledButton>\n </ControlledMenuHandler>\n </ControlledTooltip>\n <ControlledMenuList className=\"zIndexMax p-1\">\n {attachMenuActions.map((action) => (\n <ControlledMenuItem key={action.key} className=\"flex items-center gap-x-2\" onClick={action.onClick}>\n <MaterialSymbol icon={action.icon} size={18} />\n {action.label}\n </ControlledMenuItem>\n ))}\n </ControlledMenuList>\n </ControlledMenuRoot>\n )}\n\n {actionsSlot}\n </div>\n )}\n\n {attachmentsController && (\n <>\n <input ref={fileInputRef} type=\"file\" multiple hidden onChange={handleFilesPicked} />\n <input\n ref={imageInputRef}\n type=\"file\"\n multiple\n hidden\n accept={IMAGE_INPUT_ACCEPT}\n onChange={handleFilesPicked}\n />\n </>\n )}\n </div>\n );\n\n if (!attachmentsController || attachments?.hideDropzone) return editorShell;\n\n return (\n <FileDropzone\n stopPropagation\n disabled={disabled}\n className=\"flex-none\"\n overlayClassName={DROPZONE_OVERLAY_CLASS}\n onFilesDropped={(files) => attachmentsController.addFiles(files)}\n >\n {editorShell}\n </FileDropzone>\n );\n});\n\nexport default RichTextEditor;\n"],"names":["RichTextEditor"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"RichTextEditor.js","sources":["../../../src/components/Form/RichTextEditor.tsx"],"sourcesContent":["import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useReducer, useRef, useState } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport {\n ControlledMenuHandler,\n ControlledMenuItem,\n ControlledMenuList,\n ControlledMenuRoot,\n} from '@/components/UI/ControlledMenu';\nimport {\n ControlledPopoverContent,\n ControlledPopoverHandler,\n ControlledPopoverRoot,\n} from '@/components/UI/ControlledPopover';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport {\n activeRichTextColor,\n applyRichTextColor,\n buildFileChipMarkup,\n normalizeRichTextColorNodes,\n RICH_TEXT_COLOR_SWATCH_CLASS,\n RICH_TEXT_COLORS,\n TRichTextColor,\n} from '@/helpers/richText';\nimport { IUseFileAttachmentsResult, TFileUploader, useFileAttachments } from '@/hooks/useFileAttachments';\n\nimport { MaterialSymbol, SymbolCodepoints } from '../UI/MaterialSymbol';\n\nimport AttachmentPreviewList, { IStagedImageAction } from './attachments/AttachmentPreviewList';\nimport FileDropzone from './attachments/FileDropzone';\nimport ControlledButton from './ControlledButton';\nimport ControlledInput from './ControlledInput';\n\n/** An extra entry in the attach menu, for a source only the host knows about (a camera dialog). */\nexport interface IRichTextAttachAction {\n key: string;\n icon: SymbolCodepoints;\n label: string;\n onClick: () => void;\n}\n\n/**\n * Files travel BESIDE the text: the host owns a list of attachments of its own (a chat message\n * has one) and reads `controller.readyAttachments` when it saves.\n */\nexport interface IRichTextAttachmentsConfig {\n controller: IUseFileAttachmentsResult;\n extraActions?: Array<IRichTextAttachAction>;\n /**\n * Actions offered on a staged IMAGE before it is sent, so a picture is corrected on its way out\n * instead of being sent and then corrected as a second file.\n */\n imageActions?: Array<IStagedImageAction>;\n /** The host already renders the staged previews somewhere else. */\n hidePreviews?: boolean;\n /** The host already wraps a wider drop target around this editor. */\n hideDropzone?: boolean;\n}\n\n/**\n * Files travel INSIDE the text: the upload result is written into the body as a chip, so the\n * reference persists anywhere the HTML itself is stored and no host needs a new field.\n */\nexport interface IRichTextInlineUploadConfig {\n uploadFile?: TFileUploader;\n maxFiles?: number;\n maxFileSizeBytes?: number;\n}\n\ninterface IRichTextEditorProps {\n value?: string;\n onChange?: (html: string) => void;\n placeholder?: string;\n disabled?: boolean;\n error?: boolean;\n className?: string;\n minHeight?: number;\n /** Rendered inside the toolbar row, right-aligned: host actions that belong to the field. */\n toolbarSlot?: React.ReactNode;\n /** Rendered in the editor's own bottom bar, after the controls the editor owns. */\n actionsSlot?: React.ReactNode;\n onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;\n /** Fires when the editable area loses focus, for hosts that persist on blur. */\n onBlur?: () => void;\n /**\n * Fires with the full HTML when the editor changes the content on its own, which today means\n * an inline upload landing after the field already lost focus. A host that persists on blur\n * would otherwise never hear about the file it just attached.\n */\n onCommit?: (html: string) => void;\n /** 'bare' drops the border and focus ring: the host owns the chrome around the field. */\n variant?: 'default' | 'bare';\n /** Hosts with their own action bar (the chat composer) toggle the formatting row on demand. */\n showToolbar?: boolean;\n /** Renders the formatting toggle in the bottom bar and lets the editor own the row's state. */\n formattingToggle?: boolean;\n attachments?: IRichTextAttachmentsConfig;\n inlineUpload?: IRichTextInlineUploadConfig;\n}\n\nexport interface IRichTextEditorHandle {\n focus: () => void;\n /** Viewport rect of the caret, for anchoring a popover to what is being typed. */\n getCaretRect: () => DOMRect | null;\n /**\n * Text typed after the trigger and before the caret, or null when the caret is not inside a\n * trigger word. Drives both \"is the mention menu relevant\" and \"what is being searched\".\n * Spaces are allowed inside the query (people have compound names); the host decides when a\n * spaced query stopped matching anyone and closes the menu.\n */\n getMentionQuery: (trigger: string) => string | null;\n /**\n * Deletes the trigger AND the query the user typed after it, then writes the markup in their\n * place, so picking someone from a mention menu replaces everything typed since the `@`\n * instead of stacking the mention after it.\n */\n replaceTriggerWith: (trigger: string, query: string, html: string) => void;\n}\n\ninterface IToolbarGroup {\n buttons: Array<IToolbarButton>;\n}\n\ninterface IToolbarButton {\n command: string;\n arg?: string;\n icon: SymbolCodepoints;\n tooltipKey: string;\n isBlock?: boolean;\n}\n\nconst TOOLBAR_GROUPS: Array<IToolbarGroup> = [\n {\n buttons: [\n { command: 'bold', icon: 'format_bold', tooltipKey: 'createTask.rich.bold' },\n { command: 'italic', icon: 'format_italic', tooltipKey: 'createTask.rich.italic' },\n { command: 'strikeThrough', icon: 'strikethrough_s', tooltipKey: 'createTask.rich.strikethrough' },\n ],\n },\n {\n buttons: [\n {\n command: 'insertUnorderedList',\n icon: 'format_list_bulleted',\n tooltipKey: 'createTask.rich.bulletList',\n isBlock: true,\n },\n {\n command: 'insertOrderedList',\n icon: 'format_list_numbered',\n tooltipKey: 'createTask.rich.numberedList',\n isBlock: true,\n },\n {\n command: 'formatBlock',\n arg: 'BLOCKQUOTE',\n icon: 'format_quote',\n tooltipKey: 'createTask.rich.quote',\n isBlock: true,\n },\n ],\n },\n {\n buttons: [\n { command: 'indent', icon: 'format_indent_increase', tooltipKey: 'createTask.rich.indent' },\n { command: 'outdent', icon: 'format_indent_decrease', tooltipKey: 'createTask.rich.outdent' },\n ],\n },\n];\n\nconst MENTION_QUERY_MAX_LENGTH = 40;\nconst IMAGE_INPUT_ACCEPT = 'image/*';\nconst DROPZONE_OVERLAY_CLASS = 'rounded-xl';\n\nconst TOOLBAR_BUTTON_CLASS = 'flex size-7 items-center justify-center rounded-lg transition-colors';\nconst TOOLBAR_BUTTON_IDLE_CLASS = 'text-foreground-subtle hover:bg-muted-hover hover:text-foreground-muted';\nconst TOOLBAR_BUTTON_ACTIVE_CLASS = 'bg-brand-soft text-brand-emphasis';\nconst ACTION_BUTTON_CLASS =\n 'flex size-8 items-center justify-center rounded-full text-foreground-faint transition-colors hover:bg-muted-hover hover:text-foreground-muted disabled:text-foreground-faint';\nconst ACTION_BUTTON_ACTIVE_CLASS =\n 'flex size-8 items-center justify-center rounded-full bg-brand-soft text-brand-emphasis transition-colors hover:bg-brand-soft';\n\nconst isCommandActive = (command: string, arg?: string): boolean => {\n try {\n if (arg) return document.queryCommandValue(command) === arg.toLowerCase();\n return document.queryCommandState(command);\n } catch {\n return false;\n }\n};\n\nconst RichTextEditor = forwardRef<IRichTextEditorHandle, IRichTextEditorProps>(function RichTextEditor(\n {\n value = '',\n onChange,\n placeholder,\n disabled = false,\n error = false,\n className,\n minHeight = 120,\n toolbarSlot,\n actionsSlot,\n onKeyDown,\n onBlur,\n onCommit,\n variant = 'default',\n showToolbar = true,\n formattingToggle = false,\n attachments,\n inlineUpload,\n },\n ref,\n) {\n const { t } = useTranslation('ui');\n const editorRef = useRef<HTMLDivElement>(null);\n const [activeCommands, setActiveCommands] = useState<Set<string>>(new Set());\n const [activeColor, setActiveColor] = useState<TRichTextColor>('default');\n const [colorOpen, setColorOpen] = useState(false);\n const [showLinkInput, setShowLinkInput] = useState(false);\n const [linkUrl, setLinkUrl] = useState('');\n const [isFocused, setIsFocused] = useState(false);\n const [formattingOpen, setFormattingOpen] = useState(false);\n const [, recheckPlaceholder] = useReducer((checkCount: number) => checkCount + 1, 0);\n const savedRangeRef = useRef<Range | null>(null);\n const caretBelongsAtEndRef = useRef(false);\n const isInternalUpdateRef = useRef(false);\n const fileInputRef = useRef<HTMLInputElement>(null);\n const imageInputRef = useRef<HTMLInputElement>(null);\n const insertedStagingIdsRef = useRef<Set<string>>(new Set());\n const pendingColorRef = useRef<TRichTextColor>('default');\n\n const inlineController = useFileAttachments({\n uploadFile: inlineUpload?.uploadFile,\n maxFiles: inlineUpload?.maxFiles,\n maxFileSizeBytes: inlineUpload?.maxFileSizeBytes,\n });\n\n const attachmentsController = attachments?.controller ?? (inlineUpload ? inlineController : null);\n const inlineStagedAttachments = inlineController.stagedAttachments;\n const toolbarVisible = formattingToggle ? formattingOpen : showToolbar;\n\n const moveCaretToEnd = useCallback((editor: HTMLElement) => {\n const range = document.createRange();\n range.selectNodeContents(editor);\n range.collapse(false);\n\n const selection = window.getSelection();\n selection?.removeAllRanges();\n selection?.addRange(range);\n savedRangeRef.current = range.cloneRange();\n }, []);\n\n useEffect(() => {\n const editor = editorRef.current;\n if (!editor) return;\n if (isInternalUpdateRef.current) return;\n if (editor.innerHTML !== value) {\n editor.innerHTML = value;\n\n // Rewriting the content throws the caret back to the start of the field.\n if (document.activeElement === editor) moveCaretToEnd(editor);\n else caretBelongsAtEndRef.current = true;\n\n // The placeholder reads the field during render, which ran before this write.\n recheckPlaceholder();\n }\n }, [moveCaretToEnd, value]);\n\n const emitChange = useCallback(() => {\n const editor = editorRef.current;\n if (!editor) return;\n isInternalUpdateRef.current = true;\n onChange?.(editor.innerHTML);\n isInternalUpdateRef.current = false;\n }, [onChange]);\n\n const syncActiveCommands = useCallback(() => {\n const active = new Set<string>();\n TOOLBAR_GROUPS.flatMap((group) => group.buttons).forEach(({ command, arg }) => {\n if (isCommandActive(command, arg)) active.add(arg ?? command);\n });\n setActiveCommands(active);\n\n const editor = editorRef.current;\n if (editor) setActiveColor(activeRichTextColor(editor));\n }, []);\n\n const execFormat = useCallback(\n (command: string, arg?: string) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n editor.focus();\n document.execCommand(command, false, arg);\n emitChange();\n syncActiveCommands();\n },\n [disabled, emitChange, syncActiveCommands],\n );\n\n const saveRange = useCallback(() => {\n const selection = window.getSelection();\n if (selection && selection.rangeCount > 0) {\n savedRangeRef.current = selection.getRangeAt(0).cloneRange();\n }\n }, []);\n\n const restoreRange = useCallback(() => {\n const range = savedRangeRef.current;\n if (!range) return;\n const selection = window.getSelection();\n if (!selection) return;\n selection.removeAllRanges();\n selection.addRange(range);\n }, []);\n\n const applyLink = useCallback(() => {\n const url = linkUrl.trim();\n restoreRange();\n if (url) execFormat('createLink', url.startsWith('http') ? url : `https://${url}`);\n setShowLinkInput(false);\n setLinkUrl('');\n }, [linkUrl, restoreRange, execFormat]);\n\n const handleApplyColor = useCallback(\n (color: TRichTextColor) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n editor.focus();\n restoreRange();\n pendingColorRef.current = color;\n applyRichTextColor(editor, color);\n emitChange();\n setActiveColor(color);\n setColorOpen(false);\n },\n [disabled, emitChange, restoreRange],\n );\n\n const insertHtmlAtCaret = useCallback(\n (html: string, commit = false) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n const previouslyFocused = document.activeElement as HTMLElement | null;\n const editorHadFocus = editor.contains(previouslyFocused);\n\n editor.focus();\n restoreRange();\n\n const selection = window.getSelection();\n const caretIsInside = selection?.anchorNode ? editor.contains(selection.anchorNode) : false;\n\n if (!caretIsInside) {\n const range = document.createRange();\n range.selectNodeContents(editor);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n\n document.execCommand('insertHTML', false, html);\n saveRange();\n emitChange();\n\n if (!editorHadFocus) previouslyFocused?.focus();\n if (commit) onCommit?.(editor.innerHTML);\n },\n [disabled, emitChange, onCommit, restoreRange, saveRange],\n );\n\n const inlineControllerRef = useRef(inlineController);\n inlineControllerRef.current = inlineController;\n\n // An inline upload lands in the body the moment it finishes, and leaves the staging list so\n // the progress card disappears with it.\n useEffect(() => {\n if (!inlineUpload) return;\n\n inlineStagedAttachments.forEach((staged) => {\n const uploaded = staged.uploaded;\n if (staged.status !== 'done' || !uploaded) return;\n\n if (!insertedStagingIdsRef.current.has(staged.stagingId)) {\n insertedStagingIdsRef.current.add(staged.stagingId);\n insertHtmlAtCaret(`${buildFileChipMarkup(uploaded)} `, true);\n }\n inlineControllerRef.current.removeAttachment(staged.stagingId);\n });\n }, [inlineUpload, inlineStagedAttachments, insertHtmlAtCaret]);\n\n const handleInput = useCallback(() => {\n const editor = editorRef.current;\n if (editor) normalizeRichTextColorNodes(editor, pendingColorRef.current);\n emitChange();\n syncActiveCommands();\n }, [emitChange, syncActiveCommands]);\n\n const handleFilesPicked = (event: React.ChangeEvent<HTMLInputElement>) => {\n const files = Array.from(event.target.files ?? []);\n if (files.length) attachmentsController?.addFiles(files);\n event.target.value = '';\n };\n\n const handlePaste = (event: React.ClipboardEvent) => {\n if (!attachmentsController) return;\n\n const files = Array.from(event.clipboardData?.files ?? []);\n if (!files.length) return;\n\n event.preventDefault();\n event.stopPropagation();\n attachmentsController.addFiles(files);\n };\n\n useImperativeHandle(ref, () => ({\n focus: () => editorRef.current?.focus(),\n\n getCaretRect: () => {\n const selection = window.getSelection();\n if (!selection?.rangeCount) return null;\n\n const range = selection.getRangeAt(0).cloneRange();\n range.collapse(false);\n\n const [rect] = Array.from(range.getClientRects());\n if (rect) return rect;\n\n // A collapsed range inside an empty node measures nothing, so a throwaway marker gives it\n // something to measure. Removed in the same tick, before anything can observe it.\n const marker = document.createElement('span');\n marker.textContent = '\\u200b';\n range.insertNode(marker);\n const markerRect = marker.getBoundingClientRect();\n marker.remove();\n\n return markerRect;\n },\n\n getMentionQuery: (trigger) => {\n const selection = window.getSelection();\n if (!selection?.rangeCount) return null;\n\n const range = selection.getRangeAt(0);\n if (!range.collapsed || range.startContainer.nodeType !== Node.TEXT_NODE) return null;\n\n const textBeforeCaret = range.startContainer.textContent?.slice(0, range.startOffset) ?? '';\n const escapedTrigger = trigger.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const match = new RegExp(`(?:^|\\\\s)${escapedTrigger}([^${escapedTrigger}]*)$`).exec(textBeforeCaret);\n if (!match) return null;\n\n const query = match[1];\n if (query.startsWith(' ') || query.length > MENTION_QUERY_MAX_LENGTH) return null;\n\n return query;\n },\n\n replaceTriggerWith: (trigger, query, html) => {\n const editor = editorRef.current;\n if (!editor || disabled) return;\n\n editor.focus();\n restoreRange();\n\n const selection = window.getSelection();\n const range = selection?.rangeCount ? selection.getRangeAt(0) : null;\n\n if (range?.collapsed && range.startContainer.nodeType === Node.TEXT_NODE) {\n const textBeforeCaret = range.startContainer.textContent?.slice(0, range.startOffset) ?? '';\n const typed = `${trigger}${query}`;\n if (textBeforeCaret.endsWith(typed)) {\n range.setStart(range.startContainer, range.startOffset - typed.length);\n range.deleteContents();\n } else if (textBeforeCaret.endsWith(trigger)) {\n range.setStart(range.startContainer, range.startOffset - trigger.length);\n range.deleteContents();\n }\n }\n\n document.execCommand('insertHTML', false, `${html} `);\n emitChange();\n },\n }));\n\n const isEmpty = !editorRef.current?.textContent?.trim() && !editorRef.current?.querySelector('img, li, blockquote');\n\n const attachMenuActions = useMemo<Array<IRichTextAttachAction>>(\n () => [\n {\n key: 'file',\n icon: 'description',\n label: t('followUp.attachments.pickFile'),\n onClick: () => fileInputRef.current?.click(),\n },\n {\n key: 'image',\n icon: 'image',\n label: t('followUp.attachments.pickImage'),\n onClick: () => imageInputRef.current?.click(),\n },\n ...(attachments?.extraActions ?? []),\n ],\n [attachments?.extraActions, t],\n );\n\n const showPreviews = Boolean(attachmentsController) && !attachments?.hidePreviews;\n const showActionsBar = Boolean(attachmentsController) || Boolean(actionsSlot) || formattingToggle;\n\n const editorShell = (\n <div\n className={classNames(\n 'flex w-full flex-col transition-colors',\n variant === 'default' && 'rounded-xl border bg-surface',\n variant === 'default' &&\n (error\n ? 'border-danger-border'\n : isFocused\n ? 'border-brand-border ring-2 ring-brand-border'\n : 'border-border'),\n className,\n )}\n >\n {toolbarVisible && (\n <div\n className={classNames(\n 'flex flex-wrap items-center gap-px border-b px-2 py-1.5',\n error ? 'border-danger-border' : 'border-border-subtle',\n )}\n >\n {TOOLBAR_GROUPS.map((group, groupIndex) => (\n <div\n key={groupIndex}\n className={classNames(\n 'flex items-center gap-px',\n groupIndex < TOOLBAR_GROUPS.length - 1 && 'mr-1 border-r border-border pr-1',\n )}\n >\n {group.buttons.map(({ command, arg, icon, tooltipKey }) => {\n const key = arg ?? command;\n const isActive = activeCommands.has(key);\n\n return (\n <ControlledTooltip key={key} content={t(tooltipKey)}>\n <ControlledButton\n aria-label={t(tooltipKey)}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n execFormat(command, arg);\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n isActive ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon={icon} size={18} />\n </ControlledButton>\n </ControlledTooltip>\n );\n })}\n </div>\n ))}\n\n <div className=\"flex items-center gap-px border-l border-border pl-1\">\n <ControlledPopoverRoot open={colorOpen} handler={setColorOpen} placement=\"bottom-start\">\n <ControlledTooltip content={t('createTask.rich.textColor')}>\n <ControlledPopoverHandler>\n <ControlledButton\n aria-label={t('createTask.rich.textColor')}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n saveRange();\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n colorOpen ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon=\"format_color_text\" size={18} />\n </ControlledButton>\n </ControlledPopoverHandler>\n </ControlledTooltip>\n\n <ControlledPopoverContent className=\"zIndexMax w-44 p-2\">\n <div className=\"flex flex-wrap gap-1.5\">\n {RICH_TEXT_COLORS.map((color) => (\n <ControlledTooltip key={color} content={t(`createTask.rich.colors.${color}`)}>\n <ControlledButton\n aria-label={t(`createTask.rich.colors.${color}`)}\n defaultHtmlButton\n aria-pressed={activeColor === color}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n handleApplyColor(color);\n }}\n className={classNames(\n 'flex size-7 items-center justify-center rounded-lg border transition-colors',\n activeColor === color ? 'border-brand-border ring-2 ring-brand-border' : 'border-border',\n )}\n >\n <span\n className={classNames(\n 'size-4 rounded-full',\n RICH_TEXT_COLOR_SWATCH_CLASS[color],\n color === 'default' && 'ring-1 ring-inset ring-border-strong',\n )}\n />\n </ControlledButton>\n </ControlledTooltip>\n ))}\n </div>\n </ControlledPopoverContent>\n </ControlledPopoverRoot>\n\n <ControlledTooltip content={t('createTask.rich.link')}>\n <ControlledButton\n aria-label={t('createTask.rich.link')}\n defaultHtmlButton\n disabled={disabled}\n onMouseDown={(event: React.MouseEvent) => {\n event.preventDefault();\n saveRange();\n setShowLinkInput((previous) => !previous);\n }}\n className={classNames(\n TOOLBAR_BUTTON_CLASS,\n showLinkInput ? TOOLBAR_BUTTON_ACTIVE_CLASS : TOOLBAR_BUTTON_IDLE_CLASS,\n disabled && 'cursor-not-allowed opacity-40',\n )}\n >\n <MaterialSymbol icon=\"link\" size={18} />\n </ControlledButton>\n </ControlledTooltip>\n </div>\n\n {toolbarSlot && <div className=\"ml-auto flex items-center gap-x-1\">{toolbarSlot}</div>}\n </div>\n )}\n\n {showLinkInput && (\n <div className=\"flex items-center gap-2 border-b border-border-subtle bg-muted px-3 py-2\">\n <ControlledInput\n autoFocus\n value={linkUrl}\n onChange={(event) => setLinkUrl(event.target.value)}\n placeholder=\"https://\"\n aria-label={t('createTask.rich.link')}\n onKeyDown={(event: React.KeyboardEvent) => {\n if (event.key === 'Enter') applyLink();\n if (event.key === 'Escape') setShowLinkInput(false);\n }}\n />\n <ControlledButton size=\"sm\" onClick={applyLink}>\n {t('actions.apply')}\n </ControlledButton>\n <ControlledButton size=\"sm\" variant=\"outlined\" tone=\"neutral\" onClick={() => setShowLinkInput(false)}>\n {t('actions.cancel')}\n </ControlledButton>\n </div>\n )}\n\n <div className=\"relative\">\n <div\n ref={editorRef}\n contentEditable={!disabled}\n suppressContentEditableWarning\n onInput={handleInput}\n onKeyDown={onKeyDown}\n onPaste={handlePaste}\n onKeyUp={() => {\n saveRange();\n syncActiveCommands();\n }}\n onMouseUp={() => {\n saveRange();\n syncActiveCommands();\n }}\n onFocus={() => {\n setIsFocused(true);\n if (!caretBelongsAtEndRef.current || !editorRef.current) return;\n caretBelongsAtEndRef.current = false;\n moveCaretToEnd(editorRef.current);\n }}\n onBlur={() => {\n saveRange();\n setIsFocused(false);\n onBlur?.();\n }}\n className={classNames(\n 'w-full px-3 py-2.5 text-sm text-foreground outline-none',\n '[&_ol]:ml-4 [&_ol]:list-decimal [&_ul]:ml-4 [&_ul]:list-disc',\n '[&_blockquote]:border-l-2 [&_blockquote]:border-brand-border [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-foreground-subtle',\n '[&_a]:text-brand-emphasis [&_a]:underline',\n '[&_a.rt-file]:text-foreground-muted [&_a.rt-file]:no-underline',\n '[&_a.rt-image-link]:no-underline',\n '[&_user]:whitespace-nowrap [&_user]:rounded [&_user]:bg-brand-soft [&_user]:px-1 [&_user]:font-medium [&_user]:text-brand-emphasis',\n disabled && 'cursor-not-allowed opacity-60',\n )}\n style={{ minHeight }}\n />\n {isEmpty && !isFocused && placeholder && (\n <span className=\"pointer-events-none absolute left-3 top-2.5 text-sm text-foreground-faint\">\n {placeholder}\n </span>\n )}\n </div>\n\n {showPreviews && attachmentsController && attachmentsController.stagedAttachments.length > 0 && (\n <div className=\"px-3 pb-2\">\n <AttachmentPreviewList\n stagedAttachments={attachmentsController.stagedAttachments}\n onRemove={attachmentsController.removeAttachment}\n onRetry={attachmentsController.retryAttachment}\n imageActions={attachments?.imageActions}\n />\n </div>\n )}\n\n {showActionsBar && (\n <div className=\"flex items-center gap-x-0.5 px-2 pb-2 pt-0.5\">\n {formattingToggle && (\n <ControlledTooltip content={t('followUp.chat.formatting')} className=\"z-[9999]\">\n <ControlledButton\n aria-label={t('followUp.chat.formatting')}\n defaultHtmlButton\n disabled={disabled}\n onClick={() => setFormattingOpen((previous) => !previous)}\n aria-pressed={formattingOpen}\n className={formattingOpen ? ACTION_BUTTON_ACTIVE_CLASS : ACTION_BUTTON_CLASS}\n >\n <MaterialSymbol icon=\"format_size\" size={18} />\n </ControlledButton>\n </ControlledTooltip>\n )}\n\n {attachmentsController && (\n <ControlledMenuRoot placement=\"top-start\">\n <ControlledTooltip content={t('followUp.attachments.attach')} className=\"z-[9999]\">\n <ControlledMenuHandler>\n <ControlledButton\n defaultHtmlButton\n aria-label={t('followUp.attachments.attach')}\n disabled={disabled}\n className={ACTION_BUTTON_CLASS}\n >\n <MaterialSymbol icon=\"attach_file\" size={18} />\n </ControlledButton>\n </ControlledMenuHandler>\n </ControlledTooltip>\n <ControlledMenuList className=\"zIndexMax p-1\">\n {attachMenuActions.map((action) => (\n <ControlledMenuItem key={action.key} className=\"flex items-center gap-x-2\" onClick={action.onClick}>\n <MaterialSymbol icon={action.icon} size={18} />\n {action.label}\n </ControlledMenuItem>\n ))}\n </ControlledMenuList>\n </ControlledMenuRoot>\n )}\n\n {actionsSlot}\n </div>\n )}\n\n {attachmentsController && (\n <>\n <input ref={fileInputRef} type=\"file\" multiple hidden onChange={handleFilesPicked} />\n <input\n ref={imageInputRef}\n type=\"file\"\n multiple\n hidden\n accept={IMAGE_INPUT_ACCEPT}\n onChange={handleFilesPicked}\n />\n </>\n )}\n </div>\n );\n\n if (!attachmentsController || attachments?.hideDropzone) return editorShell;\n\n return (\n <FileDropzone\n stopPropagation\n disabled={disabled}\n className=\"flex-none\"\n overlayClassName={DROPZONE_OVERLAY_CLASS}\n onFilesDropped={(files) => attachmentsController.addFiles(files)}\n >\n {editorShell}\n </FileDropzone>\n );\n});\n\nexport default RichTextEditor;\n"],"names":["RichTextEditor"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"BulkActionBar.js","sources":["../../../src/components/UI/BulkActionBar.tsx"],"sourcesContent":["import classNames from 'classnames';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport OutlinedChip from '@/components/UI/Chips/OutlinedChip';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport HorizontalScrollArea from '@/components/UI/HorizontalScrollArea';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\n\nexport type TBulkActionTone = 'primary' | 'neutral' | 'danger';\n\nexport interface IBulkAction {\n key: string;\n label: string;\n icon: SymbolCodepoints;\n onClick: () => void;\n tone?: TBulkActionTone;\n disabled?: boolean;\n /** Explica o bloqueio quando a ação está desabilitada. */\n tooltip?: string;\n}\n\nexport interface IBulkActionBarSelectAll {\n allSelected: boolean;\n tooltip: string;\n onToggle: () => void;\n disabled?: boolean;\n}\n\ninterface IBulkActionBarProps {\n count: number;\n countLabel: string;\n /** Texto exibido quando nada está selecionado (a barra continua visível guiando o usuário). */\n hintLabel?: string;\n selectAll?: IBulkActionBarSelectAll;\n actions: Array<IBulkAction>;\n closeTooltip: string;\n onClose: () => void;\n className?: string;\n}\n\nconst TONE_CLASSES: Record<TBulkActionTone, { enabled: string; disabled: string }> = {\n primary: {\n enabled: 'bg-brand text-brand-fg hover:bg-brand-hover',\n disabled: 'cursor-not-allowed bg-muted-hover text-foreground-faint',\n },\n neutral: {\n enabled: 'border border-border text-foreground-muted hover:bg-muted-hover',\n disabled: 'cursor-not-allowed border border-border text-foreground-faint',\n },\n danger: {\n enabled: 'border border-danger-border text-danger-emphasis hover:bg-danger-soft',\n disabled: 'cursor-not-allowed border border-border text-foreground-faint',\n },\n};\n\n/**\n * Barra de ações em lote compartilhada (seleção do canvas do criAI, seleção do mapa de produtos).\n * As ações são declarativas para que cada superfície acrescente as suas sem forkar o visual.\n */\nconst BulkActionBar: React.FC<IBulkActionBarProps> = ({\n count,\n countLabel,\n hintLabel,\n selectAll,\n actions,\n closeTooltip,\n onClose,\n className,\n}) => {\n const renderAction = (action: IBulkAction) => {\n const tone = TONE_CLASSES[action.tone ?? 'primary'];\n\n const button = (\n <ControlledButton\n defaultHtmlButton\n disabled={action.disabled}\n onClick={action.onClick}\n className={classNames(\n 'flex h-9 shrink-0 items-center gap-x-1.5 rounded-lg px-3 text-sm font-medium transition-colors',\n action.disabled ? tone.disabled : tone.enabled,\n )}\n >\n <MaterialSymbol icon={action.icon} size={17} />\n {action.label}\n </ControlledButton>\n );\n\n if (action.disabled && action.tooltip) {\n return (\n <ControlledTooltip key={action.key} content={action.tooltip} className=\"w-52 text-center\">\n <span className=\"inline-flex\">{button}</span>\n </ControlledTooltip>\n );\n }\n\n return (\n <span key={action.key} className=\"inline-flex\">\n {button}\n </span>\n );\n };\n\n return (\n // The bar is capped to the viewport, so on a narrow screen its actions used to\n // be clipped past the cap with no way to reach them. It now scrolls inside that\n // cap: every action stays reachable and the bar keeps its single-line shape.\n <HorizontalScrollArea\n containerClassName={classNames(\n 'pointer-events-auto max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-surface shadow-lg',\n className,\n )}\n className=\"py-1.5 pl-2 pr-1.5\"\n >\n <div className=\"flex w-max items-center gap-2 whitespace-nowrap\">\n {selectAll && (\n <ControlledTooltip content={selectAll.tooltip}>\n <span className=\"inline-flex\">\n <ControlledButton\n aria-label={selectAll.tooltip}\n defaultHtmlButton\n disabled={selectAll.disabled}\n onClick={selectAll.onToggle}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-lg text-foreground-muted transition-colors hover:bg-muted-hover disabled:hover:bg-transparent\"\n >\n <span\n className={classNames(\n 'flex size-5 items-center justify-center rounded-md border-2 transition-colors',\n selectAll.allSelected\n ? 'border-brand bg-brand text-brand-fg'\n : 'border-border-emphasis bg-surface text-transparent',\n selectAll.disabled && 'border-border',\n )}\n >\n <MaterialSymbol icon=\"check\" size={14} />\n </span>\n </ControlledButton>\n </span>\n </ControlledTooltip>\n )}\n\n <span className=\"flex min-w-0 items-center gap-2 pr-1 text-sm text-foreground-muted\">\n {count > 0 ? (\n <>\n <OutlinedChip\n count={count}\n tone=\"purple\"\n variant=\"soft\"\n size=\"sm\"\n className=\"
|
|
1
|
+
{"version":3,"file":"BulkActionBar.js","sources":["../../../src/components/UI/BulkActionBar.tsx"],"sourcesContent":["import classNames from 'classnames';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport OutlinedChip from '@/components/UI/Chips/OutlinedChip';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport HorizontalScrollArea from '@/components/UI/HorizontalScrollArea';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\n\nexport type TBulkActionTone = 'primary' | 'neutral' | 'danger';\n\nexport interface IBulkAction {\n key: string;\n label: string;\n icon: SymbolCodepoints;\n onClick: () => void;\n tone?: TBulkActionTone;\n disabled?: boolean;\n /** Explica o bloqueio quando a ação está desabilitada. */\n tooltip?: string;\n}\n\nexport interface IBulkActionBarSelectAll {\n allSelected: boolean;\n tooltip: string;\n onToggle: () => void;\n disabled?: boolean;\n}\n\ninterface IBulkActionBarProps {\n count: number;\n countLabel: string;\n /** Texto exibido quando nada está selecionado (a barra continua visível guiando o usuário). */\n hintLabel?: string;\n selectAll?: IBulkActionBarSelectAll;\n actions: Array<IBulkAction>;\n closeTooltip: string;\n onClose: () => void;\n className?: string;\n}\n\nconst TONE_CLASSES: Record<TBulkActionTone, { enabled: string; disabled: string }> = {\n primary: {\n enabled: 'bg-brand text-brand-fg hover:bg-brand-hover',\n disabled: 'cursor-not-allowed bg-muted-hover text-foreground-faint',\n },\n neutral: {\n enabled: 'border border-border text-foreground-muted hover:bg-muted-hover',\n disabled: 'cursor-not-allowed border border-border text-foreground-faint',\n },\n danger: {\n enabled: 'border border-danger-border text-danger-emphasis hover:bg-danger-soft',\n disabled: 'cursor-not-allowed border border-border text-foreground-faint',\n },\n};\n\n/**\n * Barra de ações em lote compartilhada (seleção do canvas do criAI, seleção do mapa de produtos).\n * As ações são declarativas para que cada superfície acrescente as suas sem forkar o visual.\n */\nconst BulkActionBar: React.FC<IBulkActionBarProps> = ({\n count,\n countLabel,\n hintLabel,\n selectAll,\n actions,\n closeTooltip,\n onClose,\n className,\n}) => {\n const renderAction = (action: IBulkAction) => {\n const tone = TONE_CLASSES[action.tone ?? 'primary'];\n\n const button = (\n <ControlledButton\n defaultHtmlButton\n disabled={action.disabled}\n onClick={action.onClick}\n className={classNames(\n 'flex h-9 shrink-0 items-center gap-x-1.5 rounded-lg px-3 text-sm font-medium transition-colors',\n action.disabled ? tone.disabled : tone.enabled,\n )}\n >\n <MaterialSymbol icon={action.icon} size={17} />\n {action.label}\n </ControlledButton>\n );\n\n if (action.disabled && action.tooltip) {\n return (\n <ControlledTooltip key={action.key} content={action.tooltip} className=\"w-52 text-center\">\n <span className=\"inline-flex\">{button}</span>\n </ControlledTooltip>\n );\n }\n\n return (\n <span key={action.key} className=\"inline-flex\">\n {button}\n </span>\n );\n };\n\n return (\n // The bar is capped to the viewport, so on a narrow screen its actions used to\n // be clipped past the cap with no way to reach them. It now scrolls inside that\n // cap: every action stays reachable and the bar keeps its single-line shape.\n <HorizontalScrollArea\n containerClassName={classNames(\n 'pointer-events-auto max-w-[calc(100vw-2rem)] rounded-xl border border-border bg-surface shadow-lg',\n className,\n )}\n className=\"py-1.5 pl-2 pr-1.5\"\n >\n <div className=\"flex w-max items-center gap-2 whitespace-nowrap\">\n {selectAll && (\n <ControlledTooltip content={selectAll.tooltip}>\n <span className=\"inline-flex\">\n <ControlledButton\n aria-label={selectAll.tooltip}\n defaultHtmlButton\n disabled={selectAll.disabled}\n onClick={selectAll.onToggle}\n className=\"flex size-9 shrink-0 items-center justify-center rounded-lg text-foreground-muted transition-colors hover:bg-muted-hover disabled:hover:bg-transparent\"\n >\n <span\n className={classNames(\n 'flex size-5 items-center justify-center rounded-md border-2 transition-colors',\n selectAll.allSelected\n ? 'border-brand bg-brand text-brand-fg'\n : 'border-border-emphasis bg-surface text-transparent',\n selectAll.disabled && 'border-border',\n )}\n >\n <MaterialSymbol icon=\"check\" size={14} />\n </span>\n </ControlledButton>\n </span>\n </ControlledTooltip>\n )}\n\n <span className=\"flex min-w-0 items-center gap-2 pr-1 text-sm text-foreground-muted\">\n {count > 0 ? (\n <>\n <OutlinedChip\n count={count}\n tone=\"purple\"\n variant=\"soft\"\n size=\"sm\"\n className=\"h-6 min-w-6 shrink-0 bg-brand-soft px-1\"\n labelClassName=\"font-bold text-brand-emphasis\"\n />\n <span className=\"truncate\">{countLabel}</span>\n </>\n ) : (\n <span className=\"flex min-w-0 items-center gap-1.5 text-foreground-subtle\">\n <MaterialSymbol icon=\"touch_app\" size={16} className=\"shrink-0\" />\n <span className=\"truncate\">{hintLabel ?? countLabel}</span>\n </span>\n )}\n </span>\n\n <div className=\"h-5 w-px shrink-0 bg-muted-active\" />\n\n {actions.map(renderAction)}\n\n <div className=\"h-5 w-px shrink-0 bg-muted-active\" />\n\n <ControlledTooltip content={closeTooltip}>\n <span className=\"inline-flex\">\n <ControlledButton\n aria-label={closeTooltip}\n defaultHtmlButton\n onClick={onClose}\n className=\"flex size-9 items-center justify-center rounded-lg text-foreground-subtle transition-colors hover:bg-muted-hover\"\n >\n <MaterialSymbol icon=\"close\" size={18} />\n </ControlledButton>\n </span>\n </ControlledTooltip>\n </div>\n </HorizontalScrollArea>\n );\n};\n\nexport default BulkActionBar;\n"],"names":[],"mappings":"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"}
|
|
@@ -7,6 +7,21 @@ const getRecognitionCtor = () => {
|
|
|
7
7
|
const subscribeToNothing = () => () => void 0;
|
|
8
8
|
const isRecognitionAvailable = () => getRecognitionCtor() !== null;
|
|
9
9
|
const isUnavailableOnServer = () => false;
|
|
10
|
+
const listensOneUtterancePerSession = () => {
|
|
11
|
+
const { userAgent, maxTouchPoints } = navigator;
|
|
12
|
+
return /Android|iPhone|iPad|iPod/i.test(userAgent) || /Macintosh/i.test(userAgent) && maxTouchPoints > 1;
|
|
13
|
+
};
|
|
14
|
+
const readUtterance = (event) => {
|
|
15
|
+
let utterance = "";
|
|
16
|
+
for (let resultIndex = 0; resultIndex < event.results.length; resultIndex += 1) {
|
|
17
|
+
const transcript = event.results[resultIndex]?.[0]?.transcript?.trim() ?? "";
|
|
18
|
+
const normalizedTranscript = transcript.toLowerCase();
|
|
19
|
+
const normalizedUtterance = utterance.toLowerCase();
|
|
20
|
+
if (!transcript || normalizedUtterance.endsWith(normalizedTranscript)) continue;
|
|
21
|
+
utterance = normalizedTranscript.startsWith(normalizedUtterance) ? transcript : `${utterance} ${transcript}`;
|
|
22
|
+
}
|
|
23
|
+
return utterance;
|
|
24
|
+
};
|
|
10
25
|
const VOICE_COMMANDS = [
|
|
11
26
|
{ key: "comma", phrases: ["vírgula", "virgula", "comma"], insert: ", " },
|
|
12
27
|
{ key: "period", phrases: ["ponto", "ponto final", "period"], insert: ". " },
|
|
@@ -97,15 +112,22 @@ const useVoiceTranscription = ({
|
|
|
97
112
|
const startSession = useCallback(() => {
|
|
98
113
|
const Ctor = getRecognitionCtor();
|
|
99
114
|
if (!Ctor) return false;
|
|
115
|
+
const oneUtterancePerSession = listensOneUtterancePerSession();
|
|
100
116
|
const recognition = new Ctor();
|
|
101
117
|
recognition.lang = lang;
|
|
102
|
-
recognition.continuous =
|
|
118
|
+
recognition.continuous = !oneUtterancePerSession;
|
|
103
119
|
recognition.interimResults = true;
|
|
104
120
|
let sessionHeardSpeech = false;
|
|
105
121
|
let sessionEndedByError = false;
|
|
122
|
+
let uncommittedTranscript = "";
|
|
106
123
|
recognition.onresult = (event) => {
|
|
107
124
|
if (recognitionRef.current !== recognition) return;
|
|
108
125
|
sessionHeardSpeech = true;
|
|
126
|
+
if (oneUtterancePerSession) {
|
|
127
|
+
uncommittedTranscript = readUtterance(event);
|
|
128
|
+
onInterimRef.current?.(uncommittedTranscript);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
109
131
|
let interim = "";
|
|
110
132
|
for (let resultIndex = event.resultIndex; resultIndex < event.results.length; resultIndex += 1) {
|
|
111
133
|
const result = event.results[resultIndex];
|
|
@@ -116,7 +138,8 @@ const useVoiceTranscription = ({
|
|
|
116
138
|
interim += transcript;
|
|
117
139
|
}
|
|
118
140
|
}
|
|
119
|
-
|
|
141
|
+
uncommittedTranscript = interim.trim();
|
|
142
|
+
onInterimRef.current?.(uncommittedTranscript);
|
|
120
143
|
};
|
|
121
144
|
recognition.onerror = (event) => {
|
|
122
145
|
if (recognitionRef.current !== recognition) return;
|
|
@@ -128,6 +151,7 @@ const useVoiceTranscription = ({
|
|
|
128
151
|
};
|
|
129
152
|
recognition.onend = () => {
|
|
130
153
|
if (recognitionRef.current !== recognition) return;
|
|
154
|
+
if (uncommittedTranscript) onCommitRef.current(buildUpdateFromTranscript(uncommittedTranscript));
|
|
131
155
|
onInterimRef.current?.("");
|
|
132
156
|
silentRestartCountRef.current = sessionHeardSpeech ? 0 : silentRestartCountRef.current + 1;
|
|
133
157
|
const shouldResume = wantsListeningRef.current && !sessionEndedByError && silentRestartCountRef.current <= MAX_SILENT_RESTARTS;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useVoiceTranscription.js","sources":["../../src/hooks/useVoiceTranscription.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\n\nimport { uiLogger } from '@/helpers/uiLogger';\n\ninterface ISpeechRecognitionAlternative {\n transcript: string;\n}\n\ninterface ISpeechRecognitionResult {\n 0: ISpeechRecognitionAlternative;\n isFinal: boolean;\n}\n\ninterface ISpeechRecognitionEvent {\n resultIndex: number;\n results: { length: number; [index: number]: ISpeechRecognitionResult };\n}\n\ninterface ISpeechRecognition {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: ISpeechRecognitionEvent) => void) | null;\n onerror: ((event: { error: string }) => void) | null;\n onend: (() => void) | null;\n}\n\ntype TSpeechRecognitionCtor = new () => ISpeechRecognition;\n\nconst getRecognitionCtor = (): TSpeechRecognitionCtor | null => {\n const w = window as unknown as {\n SpeechRecognition?: TSpeechRecognitionCtor;\n webkitSpeechRecognition?: TSpeechRecognitionCtor;\n };\n return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n};\n\n/* The API never appears or goes away while a page is open, so there is nothing to subscribe to.\n The store exists for the server answer: `window` is not there, and the first client render has\n to agree with it for hydration to hold. */\nconst subscribeToNothing = () => () => undefined;\nconst isRecognitionAvailable = () => getRecognitionCtor() !== null;\nconst isUnavailableOnServer = () => false;\n\nexport type TVoiceCommandKey = 'comma' | 'period' | 'newline' | 'space' | 'question' | 'exclamation' | 'deleteWord';\n\ninterface IVoiceCommand {\n key: TVoiceCommandKey;\n phrases: Array<string>;\n insert?: string;\n action?: 'deleteWord';\n}\n\n// Spoken keywords matched against the user's speech. They must stay as literal\n// recognition tokens (not UI strings), so the i18n check is disabled here.\n/* eslint-disable i18n-check/no-portuguese-strings */\nexport const VOICE_COMMANDS: Array<IVoiceCommand> = [\n { key: 'comma', phrases: ['vírgula', 'virgula', 'comma'], insert: ', ' },\n { key: 'period', phrases: ['ponto', 'ponto final', 'period'], insert: '. ' },\n { key: 'newline', phrases: ['nova linha', 'quebra de linha', 'parágrafo', 'paragrafo', 'new line'], insert: '\\n' },\n { key: 'space', phrases: ['espaço', 'espaco', 'space'], insert: ' ' },\n {\n key: 'question',\n phrases: ['interrogação', 'interrogacao', 'ponto de interrogação', 'question mark'],\n insert: '? ',\n },\n {\n key: 'exclamation',\n phrases: ['exclamação', 'exclamacao', 'ponto de exclamação', 'exclamation mark'],\n insert: '! ',\n },\n { key: 'deleteWord', phrases: ['apagar', 'apaga', 'apagar palavra', 'delete'], action: 'deleteWord' },\n];\n/* eslint-enable i18n-check/no-portuguese-strings */\n\nconst PHRASE_TO_COMMAND = new Map<string, IVoiceCommand>();\nfor (const command of VOICE_COMMANDS) {\n for (const phrase of command.phrases) PHRASE_TO_COMMAND.set(phrase, command);\n}\n\nconst normalize = (value: string): string =>\n value\n .trim()\n .toLowerCase()\n .replace(/[.,!?;:]+$/, '');\n\nexport interface IVoiceTranscriptionUpdate {\n insert: string;\n deleteWords: number;\n}\n\nconst buildUpdateFromTranscript = (transcript: string): IVoiceTranscriptionUpdate => {\n const words = transcript.trim().split(/\\s+/).filter(Boolean);\n let insert = '';\n let deleteWords = 0;\n\n for (const word of words) {\n const command = PHRASE_TO_COMMAND.get(normalize(word));\n if (command?.action === 'deleteWord') {\n deleteWords += 1;\n insert = '';\n continue;\n }\n if (command?.insert !== undefined) {\n insert = insert.replace(/\\s+$/, '') + command.insert;\n continue;\n }\n insert += (insert && !insert.endsWith(' ') && !insert.endsWith('\\n') ? ' ' : '') + word;\n }\n\n return { insert, deleteWords };\n};\n\nconst appendTranscriptText = (current: string, addition: string): string => {\n if (!addition) return current;\n const needsSpace = current.length > 0 && !/\\s$/.test(current) && !/^[\\s.,!?]/.test(addition);\n return current + (needsSpace ? ' ' : '') + addition;\n};\n\nexport const applyVoiceUpdate = (current: string, update: IVoiceTranscriptionUpdate): string => {\n let next = current;\n for (let wordIndex = 0; wordIndex < update.deleteWords; wordIndex += 1) {\n next = next.replace(/\\s*\\S+\\s*$/, '');\n }\n return appendTranscriptText(next, update.insert);\n};\n\nexport const appendInterimTranscript = (committed: string, interim: string): string =>\n appendTranscriptText(committed, interim);\n\nexport type TVoiceTranscriptionError = 'permission' | 'microphone' | 'network' | 'unavailable';\n\nconst RECOGNITION_ERROR_TO_VOICE_ERROR: Record<string, TVoiceTranscriptionError> = {\n 'not-allowed': 'permission',\n 'service-not-allowed': 'permission',\n 'audio-capture': 'microphone',\n network: 'network',\n 'language-not-supported': 'unavailable',\n 'bad-grammar': 'unavailable',\n};\n\nconst MAX_SILENT_RESTARTS = 2;\n\ninterface IUseVoiceTranscriptionParams {\n lang?: string;\n onCommit: (update: IVoiceTranscriptionUpdate) => void;\n onInterim?: (text: string) => void;\n}\n\ninterface IUseVoiceTranscriptionResult {\n isSupported: boolean;\n isListening: boolean;\n error: TVoiceTranscriptionError | null;\n toggle: () => void;\n stop: () => void;\n}\n\nconst detachRecognition = (recognition: ISpeechRecognition) => {\n recognition.onresult = null;\n recognition.onerror = null;\n recognition.onend = null;\n};\n\nexport const useVoiceTranscription = ({\n lang = 'pt-BR',\n onCommit,\n onInterim,\n}: IUseVoiceTranscriptionParams): IUseVoiceTranscriptionResult => {\n const [isListening, setIsListening] = useState(false);\n const [error, setError] = useState<TVoiceTranscriptionError | null>(null);\n const recognitionRef = useRef<ISpeechRecognition | null>(null);\n const wantsListeningRef = useRef(false);\n const silentRestartCountRef = useRef(0);\n const onCommitRef = useRef(onCommit);\n const onInterimRef = useRef(onInterim);\n\n onCommitRef.current = onCommit;\n onInterimRef.current = onInterim;\n\n const isSupported = useSyncExternalStore(subscribeToNothing, isRecognitionAvailable, isUnavailableOnServer);\n\n const stop = useCallback(() => {\n wantsListeningRef.current = false;\n recognitionRef.current?.stop();\n }, []);\n\n const startSession = useCallback((): boolean => {\n const Ctor = getRecognitionCtor();\n if (!Ctor) return false;\n\n const recognition = new Ctor();\n recognition.lang = lang;\n recognition.continuous = true;\n recognition.interimResults = true;\n\n let sessionHeardSpeech = false;\n let sessionEndedByError = false;\n\n recognition.onresult = (event) => {\n if (recognitionRef.current !== recognition) return;\n sessionHeardSpeech = true;\n let interim = '';\n for (let resultIndex = event.resultIndex; resultIndex < event.results.length; resultIndex += 1) {\n const result = event.results[resultIndex];\n const transcript = result?.[0]?.transcript ?? '';\n if (result?.isFinal) {\n onCommitRef.current(buildUpdateFromTranscript(transcript));\n } else {\n interim += transcript;\n }\n }\n onInterimRef.current?.(interim.trim());\n };\n\n recognition.onerror = (event) => {\n if (recognitionRef.current !== recognition) return;\n if (event.error === 'no-speech') return;\n sessionEndedByError = true;\n if (event.error === 'aborted') return;\n uiLogger.warn('[useVoiceTranscription] error', event.error);\n setError(RECOGNITION_ERROR_TO_VOICE_ERROR[event.error] ?? 'unavailable');\n };\n\n recognition.onend = () => {\n if (recognitionRef.current !== recognition) return;\n onInterimRef.current?.('');\n silentRestartCountRef.current = sessionHeardSpeech ? 0 : silentRestartCountRef.current + 1;\n const shouldResume =\n wantsListeningRef.current && !sessionEndedByError && silentRestartCountRef.current <= MAX_SILENT_RESTARTS;\n if (shouldResume && startSession()) return;\n wantsListeningRef.current = false;\n recognitionRef.current = null;\n setIsListening(false);\n };\n\n recognitionRef.current = recognition;\n try {\n recognition.start();\n return true;\n } catch (startError) {\n uiLogger.warn('[useVoiceTranscription] start failed', startError);\n detachRecognition(recognition);\n recognitionRef.current = null;\n setError('unavailable');\n return false;\n }\n }, [lang]);\n\n const start = useCallback(() => {\n setError(null);\n silentRestartCountRef.current = 0;\n wantsListeningRef.current = true;\n if (startSession()) {\n setIsListening(true);\n return;\n }\n wantsListeningRef.current = false;\n }, [startSession]);\n\n const toggle = useCallback(() => {\n if (isListening) stop();\n else start();\n }, [isListening, start, stop]);\n\n useEffect(\n () => () => {\n wantsListeningRef.current = false;\n const recognition = recognitionRef.current;\n if (!recognition) return;\n detachRecognition(recognition);\n recognitionRef.current = null;\n recognition.abort();\n },\n [],\n );\n\n return { isSupported, isListening, error, toggle, stop };\n};\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"useVoiceTranscription.js","sources":["../../src/hooks/useVoiceTranscription.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';\n\nimport { uiLogger } from '@/helpers/uiLogger';\n\ninterface ISpeechRecognitionAlternative {\n transcript: string;\n}\n\ninterface ISpeechRecognitionResult {\n 0: ISpeechRecognitionAlternative;\n isFinal: boolean;\n}\n\ninterface ISpeechRecognitionEvent {\n resultIndex: number;\n results: { length: number; [index: number]: ISpeechRecognitionResult };\n}\n\ninterface ISpeechRecognition {\n lang: string;\n continuous: boolean;\n interimResults: boolean;\n start: () => void;\n stop: () => void;\n abort: () => void;\n onresult: ((event: ISpeechRecognitionEvent) => void) | null;\n onerror: ((event: { error: string }) => void) | null;\n onend: (() => void) | null;\n}\n\ntype TSpeechRecognitionCtor = new () => ISpeechRecognition;\n\nconst getRecognitionCtor = (): TSpeechRecognitionCtor | null => {\n const w = window as unknown as {\n SpeechRecognition?: TSpeechRecognitionCtor;\n webkitSpeechRecognition?: TSpeechRecognitionCtor;\n };\n return w.SpeechRecognition ?? w.webkitSpeechRecognition ?? null;\n};\n\n/* The API never appears or goes away while a page is open, so there is nothing to subscribe to.\n The store exists for the server answer: `window` is not there, and the first client render has\n to agree with it for hydration to hold. */\nconst subscribeToNothing = () => () => undefined;\nconst isRecognitionAvailable = () => getRecognitionCtor() !== null;\nconst isUnavailableOnServer = () => false;\n\n/* Phones run another engine behind the same API. Asked to listen continuously it delivers earlier\n phrases again and flags unfinished ones as final, so `isFinal` cannot be trusted there and a phone\n listens one utterance per session, restarting between them. iPadOS reports itself as a Mac. */\nconst listensOneUtterancePerSession = (): boolean => {\n const { userAgent, maxTouchPoints } = navigator;\n return /Android|iPhone|iPad|iPod/i.test(userAgent) || (/Macintosh/i.test(userAgent) && maxTouchPoints > 1);\n};\n\n/* Those engines either grow one result in place or deliver the text so far again as a new result,\n so a result that already holds what came before replaces it instead of being appended to it. */\nconst readUtterance = (event: ISpeechRecognitionEvent): string => {\n let utterance = '';\n for (let resultIndex = 0; resultIndex < event.results.length; resultIndex += 1) {\n const transcript = event.results[resultIndex]?.[0]?.transcript?.trim() ?? '';\n const normalizedTranscript = transcript.toLowerCase();\n const normalizedUtterance = utterance.toLowerCase();\n if (!transcript || normalizedUtterance.endsWith(normalizedTranscript)) continue;\n utterance = normalizedTranscript.startsWith(normalizedUtterance) ? transcript : `${utterance} ${transcript}`;\n }\n return utterance;\n};\n\nexport type TVoiceCommandKey = 'comma' | 'period' | 'newline' | 'space' | 'question' | 'exclamation' | 'deleteWord';\n\ninterface IVoiceCommand {\n key: TVoiceCommandKey;\n phrases: Array<string>;\n insert?: string;\n action?: 'deleteWord';\n}\n\n// Spoken keywords matched against the user's speech. They must stay as literal\n// recognition tokens (not UI strings), so the i18n check is disabled here.\n/* eslint-disable i18n-check/no-portuguese-strings */\nexport const VOICE_COMMANDS: Array<IVoiceCommand> = [\n { key: 'comma', phrases: ['vírgula', 'virgula', 'comma'], insert: ', ' },\n { key: 'period', phrases: ['ponto', 'ponto final', 'period'], insert: '. ' },\n { key: 'newline', phrases: ['nova linha', 'quebra de linha', 'parágrafo', 'paragrafo', 'new line'], insert: '\\n' },\n { key: 'space', phrases: ['espaço', 'espaco', 'space'], insert: ' ' },\n {\n key: 'question',\n phrases: ['interrogação', 'interrogacao', 'ponto de interrogação', 'question mark'],\n insert: '? ',\n },\n {\n key: 'exclamation',\n phrases: ['exclamação', 'exclamacao', 'ponto de exclamação', 'exclamation mark'],\n insert: '! ',\n },\n { key: 'deleteWord', phrases: ['apagar', 'apaga', 'apagar palavra', 'delete'], action: 'deleteWord' },\n];\n/* eslint-enable i18n-check/no-portuguese-strings */\n\nconst PHRASE_TO_COMMAND = new Map<string, IVoiceCommand>();\nfor (const command of VOICE_COMMANDS) {\n for (const phrase of command.phrases) PHRASE_TO_COMMAND.set(phrase, command);\n}\n\nconst normalize = (value: string): string =>\n value\n .trim()\n .toLowerCase()\n .replace(/[.,!?;:]+$/, '');\n\nexport interface IVoiceTranscriptionUpdate {\n insert: string;\n deleteWords: number;\n}\n\nconst buildUpdateFromTranscript = (transcript: string): IVoiceTranscriptionUpdate => {\n const words = transcript.trim().split(/\\s+/).filter(Boolean);\n let insert = '';\n let deleteWords = 0;\n\n for (const word of words) {\n const command = PHRASE_TO_COMMAND.get(normalize(word));\n if (command?.action === 'deleteWord') {\n deleteWords += 1;\n insert = '';\n continue;\n }\n if (command?.insert !== undefined) {\n insert = insert.replace(/\\s+$/, '') + command.insert;\n continue;\n }\n insert += (insert && !insert.endsWith(' ') && !insert.endsWith('\\n') ? ' ' : '') + word;\n }\n\n return { insert, deleteWords };\n};\n\nconst appendTranscriptText = (current: string, addition: string): string => {\n if (!addition) return current;\n const needsSpace = current.length > 0 && !/\\s$/.test(current) && !/^[\\s.,!?]/.test(addition);\n return current + (needsSpace ? ' ' : '') + addition;\n};\n\nexport const applyVoiceUpdate = (current: string, update: IVoiceTranscriptionUpdate): string => {\n let next = current;\n for (let wordIndex = 0; wordIndex < update.deleteWords; wordIndex += 1) {\n next = next.replace(/\\s*\\S+\\s*$/, '');\n }\n return appendTranscriptText(next, update.insert);\n};\n\nexport const appendInterimTranscript = (committed: string, interim: string): string =>\n appendTranscriptText(committed, interim);\n\nexport type TVoiceTranscriptionError = 'permission' | 'microphone' | 'network' | 'unavailable';\n\nconst RECOGNITION_ERROR_TO_VOICE_ERROR: Record<string, TVoiceTranscriptionError> = {\n 'not-allowed': 'permission',\n 'service-not-allowed': 'permission',\n 'audio-capture': 'microphone',\n network: 'network',\n 'language-not-supported': 'unavailable',\n 'bad-grammar': 'unavailable',\n};\n\nconst MAX_SILENT_RESTARTS = 2;\n\ninterface IUseVoiceTranscriptionParams {\n lang?: string;\n onCommit: (update: IVoiceTranscriptionUpdate) => void;\n onInterim?: (text: string) => void;\n}\n\ninterface IUseVoiceTranscriptionResult {\n isSupported: boolean;\n isListening: boolean;\n error: TVoiceTranscriptionError | null;\n toggle: () => void;\n stop: () => void;\n}\n\nconst detachRecognition = (recognition: ISpeechRecognition) => {\n recognition.onresult = null;\n recognition.onerror = null;\n recognition.onend = null;\n};\n\nexport const useVoiceTranscription = ({\n lang = 'pt-BR',\n onCommit,\n onInterim,\n}: IUseVoiceTranscriptionParams): IUseVoiceTranscriptionResult => {\n const [isListening, setIsListening] = useState(false);\n const [error, setError] = useState<TVoiceTranscriptionError | null>(null);\n const recognitionRef = useRef<ISpeechRecognition | null>(null);\n const wantsListeningRef = useRef(false);\n const silentRestartCountRef = useRef(0);\n const onCommitRef = useRef(onCommit);\n const onInterimRef = useRef(onInterim);\n\n onCommitRef.current = onCommit;\n onInterimRef.current = onInterim;\n\n const isSupported = useSyncExternalStore(subscribeToNothing, isRecognitionAvailable, isUnavailableOnServer);\n\n const stop = useCallback(() => {\n wantsListeningRef.current = false;\n recognitionRef.current?.stop();\n }, []);\n\n const startSession = useCallback((): boolean => {\n const Ctor = getRecognitionCtor();\n if (!Ctor) return false;\n\n const oneUtterancePerSession = listensOneUtterancePerSession();\n\n const recognition = new Ctor();\n recognition.lang = lang;\n recognition.continuous = !oneUtterancePerSession;\n recognition.interimResults = true;\n\n let sessionHeardSpeech = false;\n let sessionEndedByError = false;\n let uncommittedTranscript = '';\n\n recognition.onresult = (event) => {\n if (recognitionRef.current !== recognition) return;\n sessionHeardSpeech = true;\n\n if (oneUtterancePerSession) {\n uncommittedTranscript = readUtterance(event);\n onInterimRef.current?.(uncommittedTranscript);\n return;\n }\n\n let interim = '';\n for (let resultIndex = event.resultIndex; resultIndex < event.results.length; resultIndex += 1) {\n const result = event.results[resultIndex];\n const transcript = result?.[0]?.transcript ?? '';\n if (result?.isFinal) {\n onCommitRef.current(buildUpdateFromTranscript(transcript));\n } else {\n interim += transcript;\n }\n }\n uncommittedTranscript = interim.trim();\n onInterimRef.current?.(uncommittedTranscript);\n };\n\n recognition.onerror = (event) => {\n if (recognitionRef.current !== recognition) return;\n if (event.error === 'no-speech') return;\n sessionEndedByError = true;\n if (event.error === 'aborted') return;\n uiLogger.warn('[useVoiceTranscription] error', event.error);\n setError(RECOGNITION_ERROR_TO_VOICE_ERROR[event.error] ?? 'unavailable');\n };\n\n recognition.onend = () => {\n if (recognitionRef.current !== recognition) return;\n if (uncommittedTranscript) onCommitRef.current(buildUpdateFromTranscript(uncommittedTranscript));\n onInterimRef.current?.('');\n silentRestartCountRef.current = sessionHeardSpeech ? 0 : silentRestartCountRef.current + 1;\n const shouldResume =\n wantsListeningRef.current && !sessionEndedByError && silentRestartCountRef.current <= MAX_SILENT_RESTARTS;\n if (shouldResume && startSession()) return;\n wantsListeningRef.current = false;\n recognitionRef.current = null;\n setIsListening(false);\n };\n\n recognitionRef.current = recognition;\n try {\n recognition.start();\n return true;\n } catch (startError) {\n uiLogger.warn('[useVoiceTranscription] start failed', startError);\n detachRecognition(recognition);\n recognitionRef.current = null;\n setError('unavailable');\n return false;\n }\n }, [lang]);\n\n const start = useCallback(() => {\n setError(null);\n silentRestartCountRef.current = 0;\n wantsListeningRef.current = true;\n if (startSession()) {\n setIsListening(true);\n return;\n }\n wantsListeningRef.current = false;\n }, [startSession]);\n\n const toggle = useCallback(() => {\n if (isListening) stop();\n else start();\n }, [isListening, start, stop]);\n\n useEffect(\n () => () => {\n wantsListeningRef.current = false;\n const recognition = recognitionRef.current;\n if (!recognition) return;\n detachRecognition(recognition);\n recognitionRef.current = null;\n recognition.abort();\n },\n [],\n );\n\n return { isSupported, isListening, error, toggle, stop };\n};\n"],"names":[],"mappings":"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"}
|