({
setInputValueRaw,
inputValueRef,
insertTextRef
}: UseVoiceIntegrationArgs)
| 116 | interimRange: InterimRange | null; |
| 117 | }; |
| 118 | export function useVoiceIntegration({ |
| 119 | setInputValueRaw, |
| 120 | inputValueRef, |
| 121 | insertTextRef |
| 122 | }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { |
| 123 | const { |
| 124 | addNotification |
| 125 | } = useNotifications(); |
| 126 | |
| 127 | // Tracks the input content before/after the cursor when voice starts, |
| 128 | // so interim transcripts can be inserted at the cursor position without |
| 129 | // clobbering surrounding user text. |
| 130 | const voicePrefixRef = useRef<string | null>(null); |
| 131 | const voiceSuffixRef = useRef<string>(''); |
| 132 | // Tracks the last input value this hook wrote (via anchor, interim effect, |
| 133 | // or handleVoiceTranscript). If inputValueRef.current diverges, the user |
| 134 | // submitted or edited — both write paths bail to avoid clobbering. This is |
| 135 | // the only guard that correctly handles empty-prefix-empty-suffix: a |
| 136 | // startsWith('')/endsWith('') check vacuously passes, and a length check |
| 137 | // can't distinguish a cleared input from a never-set one. |
| 138 | const lastSetInputRef = useRef<string | null>(null); |
| 139 | |
| 140 | // Strip trailing hold-key chars (and optionally capture the voice |
| 141 | // anchor). Called during warmup (to clean up chars that leaked past |
| 142 | // stopImmediatePropagation — listener order is not guaranteed) and |
| 143 | // on activation (with anchor=true to capture the prefix/suffix around |
| 144 | // the cursor for interim transcript placement). The caller passes the |
| 145 | // exact count it expects to strip so pre-existing chars at the |
| 146 | // boundary are preserved (e.g. the "v" in "hav" when hold-key is "v"). |
| 147 | // The floor option sets a minimum trailing count to leave behind |
| 148 | // (during warmup this is the count we intentionally let through, so |
| 149 | // defensive cleanup only removes leaks). Returns the number of |
| 150 | // trailing chars remaining after stripping. When nothing changes, no |
| 151 | // state update is performed. |
| 152 | const stripTrailing = useCallback((maxStrip: number, { |
| 153 | char = ' ', |
| 154 | anchor = false, |
| 155 | floor = 0 |
| 156 | }: StripOpts = {}) => { |
| 157 | const prev = inputValueRef.current; |
| 158 | const offset = insertTextRef.current?.cursorOffset ?? prev.length; |
| 159 | const beforeCursor = prev.slice(0, offset); |
| 160 | const afterCursor = prev.slice(offset); |
| 161 | // When the hold key is space, also count full-width spaces (U+3000) |
| 162 | // that a CJK IME may have inserted for the same physical key. |
| 163 | // U+3000 is BMP single-code-unit so indices align with beforeCursor. |
| 164 | const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor; |
| 165 | let trailing = 0; |
| 166 | while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) { |
| 167 | trailing++; |
| 168 | } |
| 169 | const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)); |
| 170 | const remaining = trailing - stripCount; |
| 171 | const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount); |
| 172 | // When anchoring with a non-space suffix, insert a gap space so the |
| 173 | // waveform cursor sits on the gap instead of covering the first |
| 174 | // suffix letter. The interim transcript effect maintains this same |
| 175 | // structure (prefix + leading + interim + trailing + suffix), so |
no test coverage detected